clawpowers 1.1.4 → 2.2.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.
Files changed (131) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/COMPATIBILITY.md +13 -0
  3. package/KNOWN_LIMITATIONS.md +19 -0
  4. package/LICENSE +44 -0
  5. package/LICENSING.md +10 -0
  6. package/README.md +378 -210
  7. package/SECURITY.md +52 -0
  8. package/dist/index.d.ts +1477 -0
  9. package/dist/index.js +3464 -0
  10. package/dist/index.js.map +1 -0
  11. package/native/Cargo.lock +4863 -0
  12. package/native/Cargo.toml +73 -0
  13. package/native/crates/canonical/Cargo.toml +24 -0
  14. package/native/crates/canonical/src/lib.rs +673 -0
  15. package/native/crates/compression/Cargo.toml +20 -0
  16. package/native/crates/compression/benches/compression_bench.rs +42 -0
  17. package/native/crates/compression/src/lib.rs +393 -0
  18. package/native/crates/evm-eth/Cargo.toml +13 -0
  19. package/native/crates/evm-eth/src/lib.rs +105 -0
  20. package/native/crates/fee/Cargo.toml +15 -0
  21. package/native/crates/fee/src/lib.rs +281 -0
  22. package/native/crates/index/Cargo.toml +16 -0
  23. package/native/crates/index/src/lib.rs +277 -0
  24. package/native/crates/policy/Cargo.toml +17 -0
  25. package/native/crates/policy/src/lib.rs +614 -0
  26. package/native/crates/security/Cargo.toml +22 -0
  27. package/native/crates/security/src/lib.rs +478 -0
  28. package/native/crates/tokens/Cargo.toml +13 -0
  29. package/native/crates/tokens/src/lib.rs +534 -0
  30. package/native/crates/verification/Cargo.toml +23 -0
  31. package/native/crates/verification/src/lib.rs +333 -0
  32. package/native/crates/wallet/Cargo.toml +20 -0
  33. package/native/crates/wallet/src/lib.rs +261 -0
  34. package/native/crates/x402/Cargo.toml +30 -0
  35. package/native/crates/x402/src/lib.rs +423 -0
  36. package/native/ffi/Cargo.toml +34 -0
  37. package/native/ffi/build.rs +4 -0
  38. package/native/ffi/index.node +0 -0
  39. package/native/ffi/src/lib.rs +352 -0
  40. package/native/ffi/tests/integration.rs +354 -0
  41. package/native/pyo3/Cargo.toml +26 -0
  42. package/native/pyo3/pyproject.toml +16 -0
  43. package/native/pyo3/src/lib.rs +407 -0
  44. package/native/pyo3/tests/test_smoke.py +180 -0
  45. package/native/wasm/Cargo.toml +44 -0
  46. package/native/wasm/pkg/.gitignore +6 -0
  47. package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -0
  48. package/native/wasm/pkg/clawpowers_wasm.js +872 -0
  49. package/native/wasm/pkg/clawpowers_wasm_bg.wasm +0 -0
  50. package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -0
  51. package/native/wasm/pkg/package.json +17 -0
  52. package/native/wasm/pkg-node/.gitignore +6 -0
  53. package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -0
  54. package/native/wasm/pkg-node/clawpowers_wasm.js +798 -0
  55. package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm +0 -0
  56. package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -0
  57. package/native/wasm/pkg-node/package.json +13 -0
  58. package/native/wasm/src/lib.rs +433 -0
  59. package/package.json +71 -44
  60. package/src/skills/catalog.ts +435 -0
  61. package/src/skills/executor.ts +56 -0
  62. package/src/skills/index.ts +3 -0
  63. package/src/skills/itp/SKILL.md +112 -0
  64. package/src/skills/loader.ts +193 -0
  65. package/.claude-plugin/manifest.json +0 -19
  66. package/.codex/INSTALL.md +0 -36
  67. package/.cursor-plugin/manifest.json +0 -21
  68. package/.opencode/INSTALL.md +0 -52
  69. package/ARCHITECTURE.md +0 -69
  70. package/bin/clawpowers.js +0 -625
  71. package/bin/clawpowers.sh +0 -91
  72. package/docs/demo/clawpowers-demo.cast +0 -197
  73. package/docs/demo/clawpowers-demo.gif +0 -0
  74. package/docs/launch-images/25-skills-breakdown.jpg +0 -0
  75. package/docs/launch-images/clawpowers-vs-superpowers.jpg +0 -0
  76. package/docs/launch-images/economic-code-optimization.jpg +0 -0
  77. package/docs/launch-images/native-vs-bridge-2.jpg +0 -0
  78. package/docs/launch-images/native-vs-bridge.jpg +0 -0
  79. package/docs/launch-images/post1-hero-lobster.jpg +0 -0
  80. package/docs/launch-images/post2-dashboard.jpg +0 -0
  81. package/docs/launch-images/post3-superpowers.jpg +0 -0
  82. package/docs/launch-images/post4-before-after.jpg +0 -0
  83. package/docs/launch-images/post5-install-now.jpg +0 -0
  84. package/docs/launch-images/ultimate-stack.jpg +0 -0
  85. package/docs/launch-posts.md +0 -76
  86. package/docs/quickstart-first-transaction.md +0 -204
  87. package/gemini-extension.json +0 -32
  88. package/hooks/session-start +0 -205
  89. package/hooks/session-start.cmd +0 -43
  90. package/hooks/session-start.js +0 -163
  91. package/runtime/demo/README.md +0 -78
  92. package/runtime/demo/x402-mock-server.js +0 -230
  93. package/runtime/feedback/analyze.js +0 -621
  94. package/runtime/feedback/analyze.sh +0 -546
  95. package/runtime/init.js +0 -210
  96. package/runtime/init.sh +0 -178
  97. package/runtime/metrics/collector.js +0 -361
  98. package/runtime/metrics/collector.sh +0 -308
  99. package/runtime/payments/ledger.js +0 -305
  100. package/runtime/payments/ledger.sh +0 -262
  101. package/runtime/payments/pipeline.js +0 -455
  102. package/runtime/persistence/store.js +0 -433
  103. package/runtime/persistence/store.sh +0 -303
  104. package/skill.json +0 -106
  105. package/skills/agent-bounties/SKILL.md +0 -553
  106. package/skills/agent-payments/SKILL.md +0 -479
  107. package/skills/brainstorming/SKILL.md +0 -233
  108. package/skills/content-pipeline/SKILL.md +0 -282
  109. package/skills/cross-project-knowledge/SKILL.md +0 -345
  110. package/skills/dispatching-parallel-agents/SKILL.md +0 -305
  111. package/skills/economic-code-optimization/SKILL.md +0 -265
  112. package/skills/executing-plans/SKILL.md +0 -255
  113. package/skills/finishing-a-development-branch/SKILL.md +0 -260
  114. package/skills/formal-verification-lite/SKILL.md +0 -441
  115. package/skills/learn-how-to-learn/SKILL.md +0 -235
  116. package/skills/market-intelligence/SKILL.md +0 -323
  117. package/skills/meta-skill-evolution/SKILL.md +0 -325
  118. package/skills/prospecting/SKILL.md +0 -454
  119. package/skills/receiving-code-review/SKILL.md +0 -225
  120. package/skills/requesting-code-review/SKILL.md +0 -206
  121. package/skills/security-audit/SKILL.md +0 -353
  122. package/skills/self-healing-code/SKILL.md +0 -369
  123. package/skills/subagent-driven-development/SKILL.md +0 -244
  124. package/skills/systematic-debugging/SKILL.md +0 -355
  125. package/skills/test-driven-development/SKILL.md +0 -416
  126. package/skills/using-clawpowers/SKILL.md +0 -160
  127. package/skills/using-git-worktrees/SKILL.md +0 -261
  128. package/skills/validator/SKILL.md +0 -281
  129. package/skills/verification-before-completion/SKILL.md +0 -254
  130. package/skills/writing-plans/SKILL.md +0 -276
  131. package/skills/writing-skills/SKILL.md +0 -260
@@ -0,0 +1,614 @@
1
+ //! Spending policy engine for the ClawPowers agent wallet system.
2
+ //!
3
+ //! Provides [`SpendingPolicy`] — a configurable rule set that determines
4
+ //! whether a proposed on-chain transaction should be approved, denied, or
5
+ //! escalated for human review.
6
+ //!
7
+ //! # Quick start
8
+ //! ```
9
+ //! use clawpowers_policy::{SpendingPolicy, ProposedTx, PolicyDecision};
10
+ //! use clawpowers_tokens::TokenAmount;
11
+ //! use alloy_primitives::Address;
12
+ //!
13
+ //! let policy = SpendingPolicy::builder()
14
+ //! .max_per_tx(TokenAmount::from_human(100.0, 6))
15
+ //! .fail_closed(true)
16
+ //! .build();
17
+ //!
18
+ //! let tx = ProposedTx {
19
+ //! recipient: Address::ZERO,
20
+ //! amount: TokenAmount::from_human(50.0, 6),
21
+ //! merchant_allowlist_check: false,
22
+ //! };
23
+ //!
24
+ //! assert!(matches!(policy.evaluate(&tx), PolicyDecision::Approve));
25
+ //! ```
26
+
27
+ use alloy_primitives::Address;
28
+ use clawpowers_tokens::TokenAmount;
29
+ use std::time::{SystemTime, UNIX_EPOCH};
30
+ use thiserror::Error;
31
+
32
+ // ── PolicyError ───────────────────────────────────────────────────────────────
33
+
34
+ /// Errors produced during policy construction.
35
+ #[derive(Debug, Error)]
36
+ pub enum PolicyError {
37
+ /// A required field was missing when calling [`PolicyBuilder::build`].
38
+ #[error("Policy configuration error: {0}")]
39
+ Configuration(String),
40
+ }
41
+
42
+ // ── RollingCap ────────────────────────────────────────────────────────────────
43
+
44
+ /// A rolling spending cap over a sliding time window.
45
+ #[derive(Debug, Clone)]
46
+ pub struct RollingCap {
47
+ /// Maximum cumulative spend allowed within the window.
48
+ pub amount: TokenAmount,
49
+ /// Window duration in seconds.
50
+ pub window_secs: u64,
51
+ }
52
+
53
+ // ── ProposedTx ────────────────────────────────────────────────────────────────
54
+
55
+ /// A transaction proposed for approval.
56
+ #[derive(Debug, Clone)]
57
+ pub struct ProposedTx {
58
+ /// Target Ethereum address for this transaction.
59
+ pub recipient: Address,
60
+ /// Amount being sent.
61
+ pub amount: TokenAmount,
62
+ /// When `true` and a non-empty [`SpendingPolicy::merchant_allowlist`] is
63
+ /// configured, the recipient will be checked against the list.
64
+ pub merchant_allowlist_check: bool,
65
+ }
66
+
67
+ // ── PolicyDecision ────────────────────────────────────────────────────────────
68
+
69
+ /// The outcome of evaluating a [`ProposedTx`] against a [`SpendingPolicy`].
70
+ #[derive(Debug, Clone, PartialEq, Eq)]
71
+ pub enum PolicyDecision {
72
+ /// The transaction is approved; proceed with signing.
73
+ Approve,
74
+ /// The transaction is denied. The payload explains why.
75
+ Deny(String),
76
+ /// The transaction requires a human to approve before proceeding.
77
+ RequireHumanApproval(String),
78
+ }
79
+
80
+ // ── SpendRecord ──────────────────────────────────────────────────────────────
81
+
82
+ #[derive(Debug, Clone)]
83
+ struct SpendRecord {
84
+ amount: TokenAmount,
85
+ timestamp_secs: u64,
86
+ }
87
+
88
+ // ── SpendingPolicy ────────────────────────────────────────────────────────────
89
+
90
+ /// A configurable spending policy for an agent wallet.
91
+ ///
92
+ /// All checks respect the `fail_closed` flag: when `true` (the default), any
93
+ /// failed check results in an immediate [`PolicyDecision::Deny`] rather than
94
+ /// allowing the transaction through.
95
+ ///
96
+ /// Use [`SpendingPolicy::builder()`] to construct instances.
97
+ #[derive(Debug)]
98
+ pub struct SpendingPolicy {
99
+ /// Maximum amount allowed per single transaction.
100
+ pub max_per_tx: Option<TokenAmount>,
101
+ /// Optional rolling spend cap over a sliding time window.
102
+ pub rolling_cap: Option<RollingCap>,
103
+ /// Explicit list of allowed recipient addresses. An empty list means "any
104
+ /// recipient is allowed".
105
+ pub merchant_allowlist: Vec<Address>,
106
+ /// When `true`, any policy violation causes an immediate
107
+ /// [`PolicyDecision::Deny`]. When `false`, policy failures escalate to
108
+ /// [`PolicyDecision::RequireHumanApproval`].
109
+ pub fail_closed: bool,
110
+ /// Internal history of recorded spends, used for rolling-cap evaluation.
111
+ spend_history: Vec<SpendRecord>,
112
+ }
113
+
114
+ impl SpendingPolicy {
115
+ /// Returns a new [`PolicyBuilder`] for constructing a [`SpendingPolicy`].
116
+ pub fn builder() -> PolicyBuilder {
117
+ PolicyBuilder::default()
118
+ }
119
+
120
+ /// Evaluates a [`ProposedTx`] against this policy and returns a
121
+ /// [`PolicyDecision`].
122
+ pub fn evaluate(&self, tx: &ProposedTx) -> PolicyDecision {
123
+ // --- 1. max_per_tx check ---
124
+ if let Some(ref max) = self.max_per_tx {
125
+ match tx.amount.partial_cmp(max) {
126
+ None => {
127
+ return self.violation(
128
+ "Decimal mismatch between transaction amount and max_per_tx".to_string(),
129
+ );
130
+ }
131
+ Some(ord) => {
132
+ if ord == std::cmp::Ordering::Greater {
133
+ let reason = format!(
134
+ "Transaction amount {} exceeds max_per_tx {}",
135
+ tx.amount.to_human(),
136
+ max.to_human()
137
+ );
138
+ return self.violation(reason);
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ // --- 2. merchant allowlist check ---
145
+ if tx.merchant_allowlist_check
146
+ && !self.merchant_allowlist.is_empty()
147
+ && !self.merchant_allowlist.contains(&tx.recipient)
148
+ {
149
+ let reason = format!(
150
+ "Recipient {:#x} is not in the merchant allowlist",
151
+ tx.recipient
152
+ );
153
+ return self.violation(reason);
154
+ }
155
+
156
+ // --- 3. rolling cap check ---
157
+ if let Some(ref cap) = self.rolling_cap {
158
+ let now = now_secs();
159
+ let window_start = now.saturating_sub(cap.window_secs);
160
+
161
+ let mut window_total = TokenAmount::zero(cap.amount.decimals);
162
+ for record in &self.spend_history {
163
+ if record.timestamp_secs >= window_start {
164
+ match window_total.add(&record.amount) {
165
+ Ok(new_total) => window_total = new_total,
166
+ Err(_) => {
167
+ return self.violation(
168
+ "Rolling cap accounting error (decimal mismatch in history)"
169
+ .to_string(),
170
+ );
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // Check if adding this tx would breach the cap
177
+ match window_total.add(&tx.amount) {
178
+ Err(_) => {
179
+ return self.violation("Rolling cap overflow".to_string());
180
+ }
181
+ Ok(projected) => match projected.partial_cmp(&cap.amount) {
182
+ None => {
183
+ return self.violation("Decimal mismatch in rolling cap".to_string());
184
+ }
185
+ Some(ord) => {
186
+ if ord == std::cmp::Ordering::Greater {
187
+ let reason = format!(
188
+ "Transaction would exceed rolling cap of {} \
189
+ (current window total: {})",
190
+ cap.amount.to_human(),
191
+ window_total.to_human()
192
+ );
193
+ return self.violation(reason);
194
+ }
195
+ }
196
+ },
197
+ }
198
+ }
199
+
200
+ PolicyDecision::Approve
201
+ }
202
+
203
+ /// Records a completed spend into the rolling-window history.
204
+ ///
205
+ /// Call this *after* a transaction has been successfully broadcast, not
206
+ /// before evaluation.
207
+ pub fn record_spend(&mut self, amount: TokenAmount) {
208
+ self.spend_history.push(SpendRecord {
209
+ amount,
210
+ timestamp_secs: now_secs(),
211
+ });
212
+ }
213
+
214
+ /// Purges spend records older than the rolling window from memory.
215
+ ///
216
+ /// Optional housekeeping — the rolling cap logic filters automatically, but
217
+ /// calling this periodically avoids unbounded growth.
218
+ pub fn prune_history(&mut self) {
219
+ if let Some(ref cap) = self.rolling_cap {
220
+ let now = now_secs();
221
+ let window_start = now.saturating_sub(cap.window_secs);
222
+ self.spend_history
223
+ .retain(|r| r.timestamp_secs >= window_start);
224
+ }
225
+ }
226
+
227
+ /// Returns the appropriate denial/escalation decision based on `fail_closed`.
228
+ fn violation(&self, reason: String) -> PolicyDecision {
229
+ if self.fail_closed {
230
+ PolicyDecision::Deny(reason)
231
+ } else {
232
+ PolicyDecision::RequireHumanApproval(reason)
233
+ }
234
+ }
235
+ }
236
+
237
+ fn now_secs() -> u64 {
238
+ SystemTime::now()
239
+ .duration_since(UNIX_EPOCH)
240
+ .unwrap_or_default()
241
+ .as_secs()
242
+ }
243
+
244
+ // ── PolicyBuilder ─────────────────────────────────────────────────────────────
245
+
246
+ /// Builder for [`SpendingPolicy`].
247
+ #[derive(Debug, Default)]
248
+ pub struct PolicyBuilder {
249
+ max_per_tx: Option<TokenAmount>,
250
+ rolling_cap: Option<RollingCap>,
251
+ merchant_allowlist: Vec<Address>,
252
+ fail_closed: bool,
253
+ }
254
+
255
+ impl PolicyBuilder {
256
+ /// Sets the per-transaction spending limit.
257
+ pub fn max_per_tx(mut self, amount: TokenAmount) -> Self {
258
+ self.max_per_tx = Some(amount);
259
+ self
260
+ }
261
+
262
+ /// Sets a rolling spending cap.
263
+ pub fn rolling_cap(mut self, cap: RollingCap) -> Self {
264
+ self.rolling_cap = Some(cap);
265
+ self
266
+ }
267
+
268
+ /// Adds an address to the merchant allowlist.
269
+ pub fn allow_merchant(mut self, addr: Address) -> Self {
270
+ self.merchant_allowlist.push(addr);
271
+ self
272
+ }
273
+
274
+ /// Sets the `fail_closed` flag (default: `false` — bool default).
275
+ ///
276
+ /// Pass `true` to make all policy failures produce a hard [`PolicyDecision::Deny`].
277
+ pub fn fail_closed(mut self, v: bool) -> Self {
278
+ self.fail_closed = v;
279
+ self
280
+ }
281
+
282
+ /// Builds the [`SpendingPolicy`].
283
+ pub fn build(self) -> SpendingPolicy {
284
+ SpendingPolicy {
285
+ max_per_tx: self.max_per_tx,
286
+ rolling_cap: self.rolling_cap,
287
+ merchant_allowlist: self.merchant_allowlist,
288
+ fail_closed: self.fail_closed,
289
+ spend_history: Vec::new(),
290
+ }
291
+ }
292
+ }
293
+
294
+ // ── Tests ─────────────────────────────────────────────────────────────────────
295
+
296
+ #[cfg(test)]
297
+ mod tests {
298
+ use super::*;
299
+ use alloy_primitives::address;
300
+
301
+ fn usdc(human: f64) -> TokenAmount {
302
+ TokenAmount::from_human(human, 6)
303
+ }
304
+
305
+ // ── Approve scenarios ─────────────────────────────────────────────────
306
+
307
+ #[test]
308
+ fn test_approve_when_no_constraints() {
309
+ let policy = SpendingPolicy::builder().fail_closed(true).build();
310
+ let tx = ProposedTx {
311
+ recipient: Address::ZERO,
312
+ amount: usdc(999_999.0),
313
+ merchant_allowlist_check: false,
314
+ };
315
+ assert_eq!(policy.evaluate(&tx), PolicyDecision::Approve);
316
+ }
317
+
318
+ #[test]
319
+ fn test_approve_within_max_per_tx() {
320
+ let policy = SpendingPolicy::builder()
321
+ .max_per_tx(usdc(100.0))
322
+ .fail_closed(true)
323
+ .build();
324
+ let tx = ProposedTx {
325
+ recipient: Address::ZERO,
326
+ amount: usdc(99.99),
327
+ merchant_allowlist_check: false,
328
+ };
329
+ assert_eq!(policy.evaluate(&tx), PolicyDecision::Approve);
330
+ }
331
+
332
+ #[test]
333
+ fn test_approve_exact_max_per_tx() {
334
+ let policy = SpendingPolicy::builder()
335
+ .max_per_tx(usdc(100.0))
336
+ .fail_closed(true)
337
+ .build();
338
+ let tx = ProposedTx {
339
+ recipient: Address::ZERO,
340
+ amount: usdc(100.0),
341
+ merchant_allowlist_check: false,
342
+ };
343
+ assert_eq!(policy.evaluate(&tx), PolicyDecision::Approve);
344
+ }
345
+
346
+ #[test]
347
+ fn test_approve_recipient_in_allowlist() {
348
+ let merchant = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
349
+ let policy = SpendingPolicy::builder()
350
+ .allow_merchant(merchant)
351
+ .fail_closed(true)
352
+ .build();
353
+ let tx = ProposedTx {
354
+ recipient: merchant,
355
+ amount: usdc(50.0),
356
+ merchant_allowlist_check: true,
357
+ };
358
+ assert_eq!(policy.evaluate(&tx), PolicyDecision::Approve);
359
+ }
360
+
361
+ #[test]
362
+ fn test_approve_allowlist_check_disabled_for_unknown_recipient() {
363
+ let merchant = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
364
+ let policy = SpendingPolicy::builder()
365
+ .allow_merchant(merchant)
366
+ .fail_closed(true)
367
+ .build();
368
+ // merchant_allowlist_check = false → allowlist not enforced
369
+ let tx = ProposedTx {
370
+ recipient: Address::ZERO,
371
+ amount: usdc(50.0),
372
+ merchant_allowlist_check: false,
373
+ };
374
+ assert_eq!(policy.evaluate(&tx), PolicyDecision::Approve);
375
+ }
376
+
377
+ // ── Deny scenarios ────────────────────────────────────────────────────
378
+
379
+ #[test]
380
+ fn test_deny_exceeds_max_per_tx() {
381
+ let policy = SpendingPolicy::builder()
382
+ .max_per_tx(usdc(100.0))
383
+ .fail_closed(true)
384
+ .build();
385
+ let tx = ProposedTx {
386
+ recipient: Address::ZERO,
387
+ amount: usdc(100.01),
388
+ merchant_allowlist_check: false,
389
+ };
390
+ assert!(matches!(policy.evaluate(&tx), PolicyDecision::Deny(_)));
391
+ }
392
+
393
+ #[test]
394
+ fn test_deny_recipient_not_in_allowlist() {
395
+ let merchant = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
396
+ let policy = SpendingPolicy::builder()
397
+ .allow_merchant(merchant)
398
+ .fail_closed(true)
399
+ .build();
400
+ let other: Address = "0x0000000000000000000000000000000000000001"
401
+ .parse()
402
+ .expect("valid");
403
+ let tx = ProposedTx {
404
+ recipient: other,
405
+ amount: usdc(10.0),
406
+ merchant_allowlist_check: true,
407
+ };
408
+ assert!(matches!(policy.evaluate(&tx), PolicyDecision::Deny(_)));
409
+ }
410
+
411
+ #[test]
412
+ fn test_deny_exceeds_rolling_cap() {
413
+ let mut policy = SpendingPolicy::builder()
414
+ .rolling_cap(RollingCap {
415
+ amount: usdc(500.0),
416
+ window_secs: 3600,
417
+ })
418
+ .fail_closed(true)
419
+ .build();
420
+
421
+ // Consume most of the cap
422
+ policy.record_spend(usdc(400.0));
423
+
424
+ let tx = ProposedTx {
425
+ recipient: Address::ZERO,
426
+ amount: usdc(200.0), // 400 + 200 = 600 > 500
427
+ merchant_allowlist_check: false,
428
+ };
429
+ assert!(matches!(policy.evaluate(&tx), PolicyDecision::Deny(_)));
430
+ }
431
+
432
+ // ── fail_closed = false → RequireHumanApproval ─────────────────────────
433
+
434
+ #[test]
435
+ fn test_fail_open_escalates_to_human_approval() {
436
+ let policy = SpendingPolicy::builder()
437
+ .max_per_tx(usdc(100.0))
438
+ .fail_closed(false)
439
+ .build();
440
+ let tx = ProposedTx {
441
+ recipient: Address::ZERO,
442
+ amount: usdc(200.0),
443
+ merchant_allowlist_check: false,
444
+ };
445
+ assert!(matches!(
446
+ policy.evaluate(&tx),
447
+ PolicyDecision::RequireHumanApproval(_)
448
+ ));
449
+ }
450
+
451
+ // ── Rolling cap expiry ────────────────────────────────────────────────
452
+
453
+ #[test]
454
+ fn test_rolling_cap_expired_records_ignored() {
455
+ let mut policy = SpendingPolicy::builder()
456
+ .rolling_cap(RollingCap {
457
+ amount: usdc(500.0),
458
+ window_secs: 1, // 1-second window
459
+ })
460
+ .fail_closed(true)
461
+ .build();
462
+
463
+ // Inject a spend record at epoch=0 (definitely outside any 1-second window).
464
+ policy.spend_history.push(SpendRecord {
465
+ amount: usdc(490.0),
466
+ timestamp_secs: 0,
467
+ });
468
+
469
+ let tx = ProposedTx {
470
+ recipient: Address::ZERO,
471
+ amount: usdc(200.0),
472
+ merchant_allowlist_check: false,
473
+ };
474
+ assert_eq!(policy.evaluate(&tx), PolicyDecision::Approve);
475
+ }
476
+
477
+ #[test]
478
+ fn test_rolling_cap_within_window_blocks_tx() {
479
+ let mut policy = SpendingPolicy::builder()
480
+ .rolling_cap(RollingCap {
481
+ amount: usdc(500.0),
482
+ window_secs: 3600,
483
+ })
484
+ .fail_closed(true)
485
+ .build();
486
+
487
+ // Record a spend at current time — it will be within the window.
488
+ policy.record_spend(usdc(450.0));
489
+
490
+ let tx = ProposedTx {
491
+ recipient: Address::ZERO,
492
+ amount: usdc(100.0), // 450 + 100 = 550 > 500
493
+ merchant_allowlist_check: false,
494
+ };
495
+ assert!(matches!(policy.evaluate(&tx), PolicyDecision::Deny(_)));
496
+ }
497
+
498
+ // ── Builder defaults ──────────────────────────────────────────────────
499
+
500
+ #[test]
501
+ fn test_builder_default_fail_closed_is_false() {
502
+ // Default builder has fail_closed = false (bool default).
503
+ let policy = PolicyBuilder::default().build();
504
+ assert!(!policy.fail_closed);
505
+ }
506
+
507
+ #[test]
508
+ fn test_builder_explicit_fail_closed_true() {
509
+ let policy = SpendingPolicy::builder().fail_closed(true).build();
510
+ assert!(policy.fail_closed);
511
+ }
512
+
513
+ #[test]
514
+ fn test_record_spend_accumulates() {
515
+ let mut policy = SpendingPolicy::builder()
516
+ .rolling_cap(RollingCap {
517
+ amount: usdc(1000.0),
518
+ window_secs: 3600,
519
+ })
520
+ .fail_closed(true)
521
+ .build();
522
+
523
+ policy.record_spend(usdc(300.0));
524
+ policy.record_spend(usdc(300.0));
525
+ policy.record_spend(usdc(300.0));
526
+
527
+ let tx = ProposedTx {
528
+ recipient: Address::ZERO,
529
+ amount: usdc(200.0), // 900 + 200 = 1100 > 1000
530
+ merchant_allowlist_check: false,
531
+ };
532
+ assert!(matches!(policy.evaluate(&tx), PolicyDecision::Deny(_)));
533
+ }
534
+
535
+ #[test]
536
+ fn test_prune_history_removes_stale_records() {
537
+ let mut policy = SpendingPolicy::builder()
538
+ .rolling_cap(RollingCap {
539
+ amount: usdc(500.0),
540
+ window_secs: 1,
541
+ })
542
+ .fail_closed(true)
543
+ .build();
544
+
545
+ // Inject an expired record (timestamp = 0)
546
+ policy.spend_history.push(SpendRecord {
547
+ amount: usdc(490.0),
548
+ timestamp_secs: 0,
549
+ });
550
+ assert_eq!(policy.spend_history.len(), 1);
551
+
552
+ policy.prune_history();
553
+ assert_eq!(policy.spend_history.len(), 0);
554
+ }
555
+
556
+ #[test]
557
+ fn test_allowlist_empty_means_any_recipient_allowed() {
558
+ // No merchants registered → allowlist is not enforced even when check=true.
559
+ let policy = SpendingPolicy::builder().fail_closed(true).build();
560
+ let other: Address = "0x0000000000000000000000000000000000000001"
561
+ .parse()
562
+ .expect("valid");
563
+ let tx = ProposedTx {
564
+ recipient: other,
565
+ amount: usdc(10.0),
566
+ merchant_allowlist_check: true,
567
+ };
568
+ assert_eq!(policy.evaluate(&tx), PolicyDecision::Approve);
569
+ }
570
+
571
+ #[test]
572
+ fn test_multiple_merchants_in_allowlist() {
573
+ let m1 = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
574
+ let m2 = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8");
575
+ let policy = SpendingPolicy::builder()
576
+ .allow_merchant(m1)
577
+ .allow_merchant(m2)
578
+ .fail_closed(true)
579
+ .build();
580
+
581
+ let tx1 = ProposedTx {
582
+ recipient: m1,
583
+ amount: usdc(10.0),
584
+ merchant_allowlist_check: true,
585
+ };
586
+ let tx2 = ProposedTx {
587
+ recipient: m2,
588
+ amount: usdc(10.0),
589
+ merchant_allowlist_check: true,
590
+ };
591
+ assert_eq!(policy.evaluate(&tx1), PolicyDecision::Approve);
592
+ assert_eq!(policy.evaluate(&tx2), PolicyDecision::Approve);
593
+ }
594
+
595
+ #[test]
596
+ fn test_rolling_cap_exact_limit_is_approved() {
597
+ let mut policy = SpendingPolicy::builder()
598
+ .rolling_cap(RollingCap {
599
+ amount: usdc(500.0),
600
+ window_secs: 3600,
601
+ })
602
+ .fail_closed(true)
603
+ .build();
604
+
605
+ policy.record_spend(usdc(300.0));
606
+
607
+ let tx = ProposedTx {
608
+ recipient: Address::ZERO,
609
+ amount: usdc(200.0), // 300 + 200 = 500 == cap (not greater than)
610
+ merchant_allowlist_check: false,
611
+ };
612
+ assert_eq!(policy.evaluate(&tx), PolicyDecision::Approve);
613
+ }
614
+ }
@@ -0,0 +1,22 @@
1
+ [package]
2
+ name = "clawpowers-security"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+
7
+ [features]
8
+ default = ["native"]
9
+ native = ["clawpowers-canonical/native"]
10
+ wasm = ["clawpowers-canonical/wasm"]
11
+
12
+ [dependencies]
13
+ serde = { workspace = true }
14
+ serde_json = { workspace = true }
15
+ thiserror = { workspace = true }
16
+ tracing = { workspace = true }
17
+ chrono = { workspace = true }
18
+ uuid = { workspace = true }
19
+ clawpowers-canonical = { path = "../canonical", default-features = false }
20
+
21
+ [dev-dependencies]
22
+ rusqlite = { workspace = true }