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.
- package/CHANGELOG.md +126 -0
- package/COMPATIBILITY.md +13 -0
- package/KNOWN_LIMITATIONS.md +19 -0
- package/LICENSE +44 -0
- package/LICENSING.md +10 -0
- package/README.md +378 -210
- package/SECURITY.md +52 -0
- package/dist/index.d.ts +1477 -0
- package/dist/index.js +3464 -0
- package/dist/index.js.map +1 -0
- package/native/Cargo.lock +4863 -0
- package/native/Cargo.toml +73 -0
- package/native/crates/canonical/Cargo.toml +24 -0
- package/native/crates/canonical/src/lib.rs +673 -0
- package/native/crates/compression/Cargo.toml +20 -0
- package/native/crates/compression/benches/compression_bench.rs +42 -0
- package/native/crates/compression/src/lib.rs +393 -0
- package/native/crates/evm-eth/Cargo.toml +13 -0
- package/native/crates/evm-eth/src/lib.rs +105 -0
- package/native/crates/fee/Cargo.toml +15 -0
- package/native/crates/fee/src/lib.rs +281 -0
- package/native/crates/index/Cargo.toml +16 -0
- package/native/crates/index/src/lib.rs +277 -0
- package/native/crates/policy/Cargo.toml +17 -0
- package/native/crates/policy/src/lib.rs +614 -0
- package/native/crates/security/Cargo.toml +22 -0
- package/native/crates/security/src/lib.rs +478 -0
- package/native/crates/tokens/Cargo.toml +13 -0
- package/native/crates/tokens/src/lib.rs +534 -0
- package/native/crates/verification/Cargo.toml +23 -0
- package/native/crates/verification/src/lib.rs +333 -0
- package/native/crates/wallet/Cargo.toml +20 -0
- package/native/crates/wallet/src/lib.rs +261 -0
- package/native/crates/x402/Cargo.toml +30 -0
- package/native/crates/x402/src/lib.rs +423 -0
- package/native/ffi/Cargo.toml +34 -0
- package/native/ffi/build.rs +4 -0
- package/native/ffi/index.node +0 -0
- package/native/ffi/src/lib.rs +352 -0
- package/native/ffi/tests/integration.rs +354 -0
- package/native/pyo3/Cargo.toml +26 -0
- package/native/pyo3/pyproject.toml +16 -0
- package/native/pyo3/src/lib.rs +407 -0
- package/native/pyo3/tests/test_smoke.py +180 -0
- package/native/wasm/Cargo.toml +44 -0
- package/native/wasm/pkg/.gitignore +6 -0
- package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -0
- package/native/wasm/pkg/clawpowers_wasm.js +872 -0
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm +0 -0
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -0
- package/native/wasm/pkg/package.json +17 -0
- package/native/wasm/pkg-node/.gitignore +6 -0
- package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -0
- package/native/wasm/pkg-node/clawpowers_wasm.js +798 -0
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm +0 -0
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -0
- package/native/wasm/pkg-node/package.json +13 -0
- package/native/wasm/src/lib.rs +433 -0
- package/package.json +71 -44
- package/src/skills/catalog.ts +435 -0
- package/src/skills/executor.ts +56 -0
- package/src/skills/index.ts +3 -0
- package/src/skills/itp/SKILL.md +112 -0
- package/src/skills/loader.ts +193 -0
- package/.claude-plugin/manifest.json +0 -19
- package/.codex/INSTALL.md +0 -36
- package/.cursor-plugin/manifest.json +0 -21
- package/.opencode/INSTALL.md +0 -52
- package/ARCHITECTURE.md +0 -69
- package/bin/clawpowers.js +0 -625
- package/bin/clawpowers.sh +0 -91
- package/docs/demo/clawpowers-demo.cast +0 -197
- package/docs/demo/clawpowers-demo.gif +0 -0
- package/docs/launch-images/25-skills-breakdown.jpg +0 -0
- package/docs/launch-images/clawpowers-vs-superpowers.jpg +0 -0
- package/docs/launch-images/economic-code-optimization.jpg +0 -0
- package/docs/launch-images/native-vs-bridge-2.jpg +0 -0
- package/docs/launch-images/native-vs-bridge.jpg +0 -0
- package/docs/launch-images/post1-hero-lobster.jpg +0 -0
- package/docs/launch-images/post2-dashboard.jpg +0 -0
- package/docs/launch-images/post3-superpowers.jpg +0 -0
- package/docs/launch-images/post4-before-after.jpg +0 -0
- package/docs/launch-images/post5-install-now.jpg +0 -0
- package/docs/launch-images/ultimate-stack.jpg +0 -0
- package/docs/launch-posts.md +0 -76
- package/docs/quickstart-first-transaction.md +0 -204
- package/gemini-extension.json +0 -32
- package/hooks/session-start +0 -205
- package/hooks/session-start.cmd +0 -43
- package/hooks/session-start.js +0 -163
- package/runtime/demo/README.md +0 -78
- package/runtime/demo/x402-mock-server.js +0 -230
- package/runtime/feedback/analyze.js +0 -621
- package/runtime/feedback/analyze.sh +0 -546
- package/runtime/init.js +0 -210
- package/runtime/init.sh +0 -178
- package/runtime/metrics/collector.js +0 -361
- package/runtime/metrics/collector.sh +0 -308
- package/runtime/payments/ledger.js +0 -305
- package/runtime/payments/ledger.sh +0 -262
- package/runtime/payments/pipeline.js +0 -455
- package/runtime/persistence/store.js +0 -433
- package/runtime/persistence/store.sh +0 -303
- package/skill.json +0 -106
- package/skills/agent-bounties/SKILL.md +0 -553
- package/skills/agent-payments/SKILL.md +0 -479
- package/skills/brainstorming/SKILL.md +0 -233
- package/skills/content-pipeline/SKILL.md +0 -282
- package/skills/cross-project-knowledge/SKILL.md +0 -345
- package/skills/dispatching-parallel-agents/SKILL.md +0 -305
- package/skills/economic-code-optimization/SKILL.md +0 -265
- package/skills/executing-plans/SKILL.md +0 -255
- package/skills/finishing-a-development-branch/SKILL.md +0 -260
- package/skills/formal-verification-lite/SKILL.md +0 -441
- package/skills/learn-how-to-learn/SKILL.md +0 -235
- package/skills/market-intelligence/SKILL.md +0 -323
- package/skills/meta-skill-evolution/SKILL.md +0 -325
- package/skills/prospecting/SKILL.md +0 -454
- package/skills/receiving-code-review/SKILL.md +0 -225
- package/skills/requesting-code-review/SKILL.md +0 -206
- package/skills/security-audit/SKILL.md +0 -353
- package/skills/self-healing-code/SKILL.md +0 -369
- package/skills/subagent-driven-development/SKILL.md +0 -244
- package/skills/systematic-debugging/SKILL.md +0 -355
- package/skills/test-driven-development/SKILL.md +0 -416
- package/skills/using-clawpowers/SKILL.md +0 -160
- package/skills/using-git-worktrees/SKILL.md +0 -261
- package/skills/validator/SKILL.md +0 -281
- package/skills/verification-before-completion/SKILL.md +0 -254
- package/skills/writing-plans/SKILL.md +0 -276
- 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 }
|