@x402sentinel/x402 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Valeo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,388 @@
1
+ # Valeo Sentinel
2
+
3
+ **Enterprise audit & compliance for x402 payments. One line to add. Zero config to start.**
4
+
5
+ ---
6
+
7
+ ## The Problem
8
+
9
+ AI agents are spending real money autonomously. The [x402 protocol](https://github.com/coinbase/x402) enables internet-native payments, but provides no built-in audit trail, budget controls, or compliance tooling.
10
+
11
+ - 75% of enterprise leaders say compliance is a blocker for autonomous agent payments (KPMG 2025)
12
+ - 61% report fragmented payment logs across agent fleets
13
+ - Zero visibility into *which agent* spent *how much* on *what endpoint* and *who approved it*
14
+
15
+ Sentinel fixes this with a single line of code.
16
+
17
+ ---
18
+
19
+ ## Quick Start
20
+
21
+ ### Before (plain x402)
22
+
23
+ ```ts
24
+ import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
25
+ import { registerExactEvmScheme } from "@x402/evm/exact/client";
26
+
27
+ const client = new x402Client();
28
+ registerExactEvmScheme(client, { signer });
29
+ const fetchWithPayment = wrapFetchWithPayment(fetch, client);
30
+
31
+ const response = await fetchWithPayment("https://api.example.com/paid");
32
+ ```
33
+
34
+ ### After (with Sentinel)
35
+
36
+ ```ts
37
+ import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
38
+ import { registerExactEvmScheme } from "@x402/evm/exact/client";
39
+ import { wrapWithSentinel, standardPolicy } from "@x402sentinel/x402"; // <-- add
40
+
41
+ const client = new x402Client();
42
+ registerExactEvmScheme(client, { signer });
43
+ const fetchWithPayment = wrapFetchWithPayment(fetch, client);
44
+
45
+ const secureFetch = wrapWithSentinel(fetchWithPayment, { // <-- wrap
46
+ agentId: "agent-weather-001",
47
+ budget: standardPolicy(),
48
+ });
49
+
50
+ const response = await secureFetch("https://api.example.com/paid"); // same API
51
+ ```
52
+
53
+ That's it. Same `fetch` interface. Budget enforcement + audit trail included.
54
+
55
+ ### Install
56
+
57
+ ```bash
58
+ npm install @x402sentinel/x402
59
+ # or
60
+ pnpm add @x402sentinel/x402
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Features
66
+
67
+ | Feature | Description |
68
+ |---------|-------------|
69
+ | **Budget Enforcement** | Per-call, hourly, daily, and lifetime spend limits. Blocks before payment. |
70
+ | **Spike Detection** | Flags payments that exceed N× the rolling average |
71
+ | **Audit Trails** | Every payment logged with agent ID, team, endpoint, tx hash, timing |
72
+ | **Endpoint Filtering** | Allowlist/blocklist URL patterns |
73
+ | **Approval Workflows** | Require human approval above a threshold |
74
+ | **Storage Backends** | In-memory (default), JSONL file, or remote API |
75
+ | **Dashboard Queries** | Query spend by agent, team, endpoint, time range |
76
+ | **CSV/JSON Export** | Export audit data for compliance reviews |
77
+ | **Zero Dependencies** | No runtime deps beyond x402 peer deps |
78
+ | **Drop-in Wrapper** | One line change. Remove Sentinel, code works identically. |
79
+
80
+ ---
81
+
82
+ ## Configuration
83
+
84
+ ```ts
85
+ const secureFetch = wrapWithSentinel(fetchWithPayment, {
86
+ // Required
87
+ agentId: "agent-weather-001",
88
+
89
+ // Optional identity
90
+ team: "data-ops",
91
+ humanSponsor: "alice@company.com",
92
+
93
+ // Budget policy
94
+ budget: {
95
+ maxPerCall: "1.00", // max USDC per single payment
96
+ maxPerHour: "25.00", // hourly rolling cap
97
+ maxPerDay: "200.00", // daily rolling cap
98
+ maxTotal: "10000.00", // lifetime cap
99
+ spikeThreshold: 3.0, // flag if > 3× rolling average
100
+ allowedEndpoints: ["https://api.trusted.com/*"],
101
+ blockedEndpoints: ["https://api.sketchy.com/*"],
102
+ requireApproval: {
103
+ above: "50.00",
104
+ handler: async (ctx) => await askSlackForApproval(ctx),
105
+ },
106
+ },
107
+
108
+ // Audit settings
109
+ audit: {
110
+ enabled: true,
111
+ storage: new MemoryStorage(), // or FileStorage, ApiStorage
112
+ redactFields: ["secret_key"],
113
+ enrichment: {
114
+ staticTags: ["production"],
115
+ tagRules: [
116
+ { pattern: ".*openai.*", tags: ["llm", "openai"] },
117
+ ],
118
+ },
119
+ },
120
+
121
+ // Lifecycle hooks
122
+ hooks: {
123
+ afterPayment: async (record) => { /* log to DataDog */ },
124
+ onBudgetExceeded: async (violation) => { /* page on-call */ },
125
+ onAnomaly: async (anomaly) => { /* alert Slack */ },
126
+ },
127
+
128
+ // Custom metadata on every record
129
+ metadata: { environment: "production", cost_center: "ENG-2024" },
130
+ });
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Budget Policies
136
+
137
+ ### Presets
138
+
139
+ ```ts
140
+ import {
141
+ conservativePolicy, // $0.10/call, $5/hr, $50/day
142
+ standardPolicy, // $1.00/call, $25/hr, $200/day
143
+ liberalPolicy, // $10/call, $100/hr, $1000/day
144
+ unlimitedPolicy, // no limits (audit only)
145
+ customPolicy, // override any defaults
146
+ } from "@x402sentinel/x402";
147
+ ```
148
+
149
+ | Preset | Per Call | Per Hour | Per Day |
150
+ |--------|---------|---------|---------|
151
+ | `conservativePolicy()` | $0.10 | $5.00 | $50.00 |
152
+ | `standardPolicy()` | $1.00 | $25.00 | $200.00 |
153
+ | `liberalPolicy()` | $10.00 | $100.00 | $1,000.00 |
154
+ | `unlimitedPolicy()` | -- | -- | -- |
155
+
156
+ ### Custom Policy
157
+
158
+ ```ts
159
+ const policy = customPolicy({
160
+ maxPerCall: "5.00",
161
+ maxPerHour: "50.00",
162
+ spikeThreshold: 5.0,
163
+ blockedEndpoints: ["https://*.competitors.com/*"],
164
+ });
165
+ ```
166
+
167
+ ### Error Handling
168
+
169
+ ```ts
170
+ import { SentinelBudgetError } from "@x402sentinel/x402";
171
+
172
+ try {
173
+ await secureFetch("https://api.example.com/expensive");
174
+ } catch (err) {
175
+ if (err instanceof SentinelBudgetError) {
176
+ console.log(err.message);
177
+ // "Budget exceeded: $2.50 spent of $5.00 hourly limit on agent-weather-001"
178
+ console.log(err.violation.type); // "hourly"
179
+ console.log(err.violation.limit); // "5.00"
180
+ }
181
+ }
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Audit Records
187
+
188
+ Every payment produces an `AuditRecord` with:
189
+
190
+ ```ts
191
+ {
192
+ id: "a1b2c3d4e5f6g7h8", // deterministic hash
193
+ agent_id: "agent-weather-001",
194
+ team: "data-ops",
195
+ human_sponsor: "alice@company.com",
196
+ amount: "0.50", // human-readable USDC
197
+ amount_raw: "500000", // base units
198
+ asset: "USDC",
199
+ network: "eip155:8453",
200
+ tx_hash: "0xabc...",
201
+ endpoint: "https://api.weather.com/v1/current",
202
+ method: "GET",
203
+ status_code: 200,
204
+ response_time_ms: 150,
205
+ policy_evaluation: "allowed",
206
+ budget_remaining: "24.50",
207
+ created_at: 1700000000000,
208
+ tags: ["production", "weather"],
209
+ metadata: { cost_center: "ENG-2024" },
210
+ }
211
+ ```
212
+
213
+ ### Storage Backends
214
+
215
+ ```ts
216
+ import { MemoryStorage, FileStorage, ApiStorage } from "@x402sentinel/x402";
217
+
218
+ // In-memory (default) — 10k records, FIFO eviction
219
+ const memory = new MemoryStorage(10_000);
220
+
221
+ // JSONL file — persistent, append-only
222
+ const file = new FileStorage(".valeo/audit.jsonl");
223
+
224
+ // Remote API — batched writes to api.valeo.money
225
+ const api = new ApiStorage({ apiKey: "val_..." });
226
+ ```
227
+
228
+ ### Querying
229
+
230
+ ```ts
231
+ import { AuditLogger } from "@x402sentinel/x402";
232
+
233
+ const logger = new AuditLogger({ storage: memory });
234
+
235
+ const records = await logger.query({
236
+ agentId: "agent-weather-001",
237
+ startTime: Date.now() - 86400000,
238
+ status: ["allowed", "flagged"],
239
+ limit: 100,
240
+ });
241
+
242
+ const summary = await logger.summarize({ team: "data-ops" });
243
+ console.log(summary.total_spend); // "$1,234.56"
244
+
245
+ const csv = await logger.exportCSV();
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Dashboard
251
+
252
+ ```ts
253
+ import { SentinelDashboard } from "@x402sentinel/x402/dashboard";
254
+
255
+ const dashboard = new SentinelDashboard({ storage: myStorage });
256
+
257
+ // Spend reports
258
+ const report = await dashboard.getSpend({
259
+ agentId: "bot-1",
260
+ range: "last_day",
261
+ });
262
+ console.log(report.totalSpend, report.count);
263
+
264
+ // Agent summaries
265
+ const agents = await dashboard.getAgents();
266
+
267
+ // Alerts (violations + anomalies)
268
+ const alerts = await dashboard.getAlerts();
269
+ ```
270
+
271
+ Dashboard queries run locally against your storage backend. No remote API required.
272
+
273
+ ---
274
+
275
+ ## Integration Guide
276
+
277
+ Sentinel wraps **any** x402-compatible fetch. It works with:
278
+
279
+ - `@x402/fetch` + `x402Client` (current)
280
+ - `x402-fetch` + viem wallet (legacy)
281
+ - Any function with the `fetch` signature that handles x402 internally
282
+
283
+ The wrapper never touches the response body. It reads only headers (`PAYMENT-RESPONSE`, `PAYMENT-REQUIRED`) for audit data.
284
+
285
+ ---
286
+
287
+ ## API Reference
288
+
289
+ ### `wrapWithSentinel(fetch, config): typeof fetch`
290
+
291
+ Wraps an x402 fetch function with Sentinel instrumentation. Returns a drop-in replacement.
292
+
293
+ ### `BudgetManager`
294
+
295
+ ```ts
296
+ class BudgetManager {
297
+ constructor(policy: BudgetPolicy);
298
+ evaluate(context: PaymentContext): BudgetEvaluation;
299
+ record(amount: string, endpoint: string): void;
300
+ getState(): BudgetState;
301
+ reset(scope: 'hourly' | 'daily' | 'total'): void;
302
+ serialize(): string;
303
+ static deserialize(data: string, policy: BudgetPolicy): BudgetManager;
304
+ }
305
+ ```
306
+
307
+ ### `AuditLogger`
308
+
309
+ ```ts
310
+ class AuditLogger {
311
+ constructor(config?: AuditConfig);
312
+ log(record): Promise<AuditRecord>;
313
+ logBlocked(context, violation): Promise<AuditRecord>;
314
+ query(query: AuditQuery): Promise<AuditRecord[]>;
315
+ summarize(query?): Promise<AuditSummary>;
316
+ exportCSV(query?): Promise<string>;
317
+ exportJSON(query?): Promise<string>;
318
+ flush(): Promise<void>;
319
+ }
320
+ ```
321
+
322
+ ### Error Classes
323
+
324
+ | Class | When | Fatal? |
325
+ |-------|------|--------|
326
+ | `SentinelBudgetError` | Budget limit exceeded | Yes (blocks payment) |
327
+ | `SentinelAuditError` | Audit write failed | No (never blocks) |
328
+ | `SentinelConfigError` | Invalid configuration | Yes (at init) |
329
+
330
+ ---
331
+
332
+ ## Full Stack Product
333
+
334
+ Sentinel is an SDK + Dashboard + API + Docs in a single monorepo:
335
+
336
+ ```
337
+ ├── / SDK (@x402sentinel/x402)
338
+ ├── app/ Next.js 15 application
339
+ │ ├── /login API key authentication
340
+ │ ├── /dashboard Real-time analytics dashboard
341
+ │ ├── /docs Full documentation site
342
+ │ └── /api/v1/* REST API (18 endpoints)
343
+ ├── Dockerfile Production Docker image
344
+ └── docker-compose.yml One-command deployment
345
+ ```
346
+
347
+ ### Quick Deploy
348
+
349
+ ```bash
350
+ docker compose up --build -d
351
+ ```
352
+
353
+ ### Development
354
+
355
+ ```bash
356
+ pnpm install
357
+ make seed # Populate demo data (500 payments)
358
+ make dev # Start dashboard at localhost:3000
359
+ ```
360
+
361
+ Login with demo API key: `sk_sentinel_demo_000`
362
+
363
+ ### Available Make Commands
364
+
365
+ | Command | Description |
366
+ |---------|-------------|
367
+ | `make dev` | Start Next.js dev server |
368
+ | `make build` | Build SDK + app |
369
+ | `make test` | Run SDK tests |
370
+ | `make seed` | Seed demo data |
371
+ | `make docker-up` | Build & start Docker |
372
+ | `make docker-down` | Stop Docker |
373
+
374
+ ---
375
+
376
+ ## Roadmap
377
+
378
+ - Multi-agent budget coordination (shared team budgets)
379
+ - Escrow and pre-commitment flows
380
+ - Treasury management integration
381
+ - On-chain agent identity verification
382
+ - Real-time dashboard at [app.valeo.money](https://app.valeo.money)
383
+
384
+ ---
385
+
386
+ ## License
387
+
388
+ MIT
@@ -0,0 +1,93 @@
1
+ // src/utils/money.ts
2
+ var USDC_DECIMALS = 6;
3
+ var USDC_SCALE = 10n ** BigInt(USDC_DECIMALS);
4
+ function parseUSDC(human) {
5
+ const trimmed = human.trim();
6
+ if (trimmed === "") {
7
+ throw new Error("Cannot parse empty string as USDC amount");
8
+ }
9
+ const negative = trimmed.startsWith("-");
10
+ if (negative) {
11
+ throw new Error(
12
+ `Negative USDC amounts are not allowed: "${human}"`
13
+ );
14
+ }
15
+ const dotIndex = trimmed.indexOf(".");
16
+ if (dotIndex === -1) {
17
+ return BigInt(trimmed) * USDC_SCALE;
18
+ }
19
+ const wholePart = trimmed.slice(0, dotIndex) || "0";
20
+ let fracPart = trimmed.slice(dotIndex + 1);
21
+ if (fracPart.length > USDC_DECIMALS) {
22
+ throw new Error(
23
+ `USDC amount "${human}" exceeds ${USDC_DECIMALS} decimal places`
24
+ );
25
+ }
26
+ fracPart = fracPart.padEnd(USDC_DECIMALS, "0");
27
+ return BigInt(wholePart) * USDC_SCALE + BigInt(fracPart);
28
+ }
29
+ function formatUSDC(raw) {
30
+ const isNeg = raw < 0n;
31
+ const abs = isNeg ? -raw : raw;
32
+ const whole = abs / USDC_SCALE;
33
+ const frac = abs % USDC_SCALE;
34
+ const fracStr = frac.toString().padStart(USDC_DECIMALS, "0");
35
+ return `${isNeg ? "-" : ""}${whole}.${fracStr}`;
36
+ }
37
+ function formatUSDCHuman(raw) {
38
+ const full = formatUSDC(raw);
39
+ const dotIndex = full.indexOf(".");
40
+ if (dotIndex === -1) return full;
41
+ let end = full.length;
42
+ while (end > dotIndex + 3 && full[end - 1] === "0") {
43
+ end--;
44
+ }
45
+ return full.slice(0, end);
46
+ }
47
+ function addUSDC(a, b) {
48
+ return formatUSDC(parseUSDC(a) + parseUSDC(b));
49
+ }
50
+ function compareUSDC(a, b) {
51
+ const diff = parseUSDC(a) - parseUSDC(b);
52
+ if (diff < 0n) return -1;
53
+ if (diff > 0n) return 1;
54
+ return 0;
55
+ }
56
+ function calculateAverage(amounts) {
57
+ if (amounts.length === 0) return formatUSDC(0n);
58
+ let sum = 0n;
59
+ for (const a of amounts) {
60
+ sum += parseUSDC(a);
61
+ }
62
+ return formatUSDC(sum / BigInt(amounts.length));
63
+ }
64
+
65
+ // src/utils/time.ts
66
+ var HOUR_MS = 60 * 60 * 1e3;
67
+ var DAY_MS = 24 * HOUR_MS;
68
+ function getHourStart(now) {
69
+ const ref = now ?? Date.now();
70
+ return ref - ref % HOUR_MS;
71
+ }
72
+ function getDayStart(now) {
73
+ const ref = now ?? Date.now();
74
+ return ref - ref % DAY_MS;
75
+ }
76
+ function resolveTimeRange(range, now) {
77
+ const ref = now ?? Date.now();
78
+ if (typeof range === "object") return range;
79
+ switch (range) {
80
+ case "last_hour":
81
+ return { start: ref - HOUR_MS, end: ref };
82
+ case "last_day":
83
+ return { start: ref - DAY_MS, end: ref };
84
+ case "last_week":
85
+ return { start: ref - 7 * DAY_MS, end: ref };
86
+ case "last_month":
87
+ return { start: ref - 30 * DAY_MS, end: ref };
88
+ }
89
+ }
90
+
91
+ export { addUSDC, calculateAverage, compareUSDC, formatUSDC, formatUSDCHuman, getDayStart, getHourStart, parseUSDC, resolveTimeRange };
92
+ //# sourceMappingURL=chunk-25PZCEL2.js.map
93
+ //# sourceMappingURL=chunk-25PZCEL2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/utils/money.ts","../src/utils/time.ts"],"names":[],"mappings":"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","file":"chunk-25PZCEL2.js","sourcesContent":["/** USDC has 6 decimal places: 1 USDC = 1_000_000 base units */\nexport const USDC_DECIMALS = 6;\nconst USDC_SCALE = 10n ** BigInt(USDC_DECIMALS);\n\n/**\n * Parse a human-readable USDC string (e.g., \"1.50\") into base units as bigint.\n * Handles integers, decimals with up to 6 places, and leading/trailing zeros.\n */\nexport function parseUSDC(human: string): bigint {\n const trimmed = human.trim();\n if (trimmed === \"\") {\n throw new Error(\"Cannot parse empty string as USDC amount\");\n }\n\n const negative = trimmed.startsWith(\"-\");\n if (negative) {\n throw new Error(\n `Negative USDC amounts are not allowed: \"${human}\"`\n );\n }\n\n const dotIndex = trimmed.indexOf(\".\");\n if (dotIndex === -1) {\n return BigInt(trimmed) * USDC_SCALE;\n }\n\n const wholePart = trimmed.slice(0, dotIndex) || \"0\";\n let fracPart = trimmed.slice(dotIndex + 1);\n\n if (fracPart.length > USDC_DECIMALS) {\n throw new Error(\n `USDC amount \"${human}\" exceeds ${USDC_DECIMALS} decimal places`\n );\n }\n\n fracPart = fracPart.padEnd(USDC_DECIMALS, \"0\");\n return BigInt(wholePart) * USDC_SCALE + BigInt(fracPart);\n}\n\n/** Format base units to a full 6-decimal USDC string (e.g., 1500000n -> \"1.500000\") */\nexport function formatUSDC(raw: bigint): string {\n const isNeg = raw < 0n;\n const abs = isNeg ? -raw : raw;\n const whole = abs / USDC_SCALE;\n const frac = abs % USDC_SCALE;\n const fracStr = frac.toString().padStart(USDC_DECIMALS, \"0\");\n return `${isNeg ? \"-\" : \"\"}${whole}.${fracStr}`;\n}\n\n/** Format base units to a trimmed human-readable string (e.g., 1500000n -> \"1.50\") */\nexport function formatUSDCHuman(raw: bigint): string {\n const full = formatUSDC(raw);\n const dotIndex = full.indexOf(\".\");\n if (dotIndex === -1) return full;\n\n let end = full.length;\n while (end > dotIndex + 3 && full[end - 1] === \"0\") {\n end--;\n }\n return full.slice(0, end);\n}\n\n/** Add two human-readable USDC amounts, return human-readable result */\nexport function addUSDC(a: string, b: string): string {\n return formatUSDC(parseUSDC(a) + parseUSDC(b));\n}\n\n/** Subtract b from a (both human-readable USDC), return human-readable result */\nexport function subtractUSDC(a: string, b: string): string {\n return formatUSDC(parseUSDC(a) - parseUSDC(b));\n}\n\n/** Compare two human-readable USDC amounts. Returns -1, 0, or 1. */\nexport function compareUSDC(a: string, b: string): -1 | 0 | 1 {\n const diff = parseUSDC(a) - parseUSDC(b);\n if (diff < 0n) return -1;\n if (diff > 0n) return 1;\n return 0;\n}\n\n/** Check if spent exceeds limit (both human-readable USDC strings) */\nexport function isOverBudget(spent: string, limit: string): boolean {\n return parseUSDC(spent) > parseUSDC(limit);\n}\n\n/** Calculate the average of human-readable USDC amounts. Returns \"0.000000\" for empty array. */\nexport function calculateAverage(amounts: string[]): string {\n if (amounts.length === 0) return formatUSDC(0n);\n let sum = 0n;\n for (const a of amounts) {\n sum += parseUSDC(a);\n }\n return formatUSDC(sum / BigInt(amounts.length));\n}\n","const HOUR_MS = 60 * 60 * 1000;\nconst DAY_MS = 24 * HOUR_MS;\n\n/** Check if a timestamp falls within `windowMs` milliseconds of now */\nexport function isWithinWindow(timestamp: number, windowMs: number, now?: number): boolean {\n const ref = now ?? Date.now();\n return ref - timestamp < windowMs;\n}\n\n/** Get the start of the current hour as a unix ms timestamp */\nexport function getHourStart(now?: number): number {\n const ref = now ?? Date.now();\n return ref - (ref % HOUR_MS);\n}\n\n/** Get the start of the current day (UTC) as a unix ms timestamp */\nexport function getDayStart(now?: number): number {\n const ref = now ?? Date.now();\n return ref - (ref % DAY_MS);\n}\n\n/** Format a unix ms timestamp as an ISO 8601 string */\nexport function formatTimestamp(ts: number): string {\n return new Date(ts).toISOString();\n}\n\n/** Resolve a named time range to epoch ms boundaries */\nexport function resolveTimeRange(\n range: \"last_hour\" | \"last_day\" | \"last_week\" | \"last_month\" | { start: number; end: number },\n now?: number,\n): { start: number; end: number } {\n const ref = now ?? Date.now();\n if (typeof range === \"object\") return range;\n switch (range) {\n case \"last_hour\":\n return { start: ref - HOUR_MS, end: ref };\n case \"last_day\":\n return { start: ref - DAY_MS, end: ref };\n case \"last_week\":\n return { start: ref - 7 * DAY_MS, end: ref };\n case \"last_month\":\n return { start: ref - 30 * DAY_MS, end: ref };\n }\n}\n"]}
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ // src/utils/money.ts
4
+ var USDC_DECIMALS = 6;
5
+ var USDC_SCALE = 10n ** BigInt(USDC_DECIMALS);
6
+ function parseUSDC(human) {
7
+ const trimmed = human.trim();
8
+ if (trimmed === "") {
9
+ throw new Error("Cannot parse empty string as USDC amount");
10
+ }
11
+ const negative = trimmed.startsWith("-");
12
+ if (negative) {
13
+ throw new Error(
14
+ `Negative USDC amounts are not allowed: "${human}"`
15
+ );
16
+ }
17
+ const dotIndex = trimmed.indexOf(".");
18
+ if (dotIndex === -1) {
19
+ return BigInt(trimmed) * USDC_SCALE;
20
+ }
21
+ const wholePart = trimmed.slice(0, dotIndex) || "0";
22
+ let fracPart = trimmed.slice(dotIndex + 1);
23
+ if (fracPart.length > USDC_DECIMALS) {
24
+ throw new Error(
25
+ `USDC amount "${human}" exceeds ${USDC_DECIMALS} decimal places`
26
+ );
27
+ }
28
+ fracPart = fracPart.padEnd(USDC_DECIMALS, "0");
29
+ return BigInt(wholePart) * USDC_SCALE + BigInt(fracPart);
30
+ }
31
+ function formatUSDC(raw) {
32
+ const isNeg = raw < 0n;
33
+ const abs = isNeg ? -raw : raw;
34
+ const whole = abs / USDC_SCALE;
35
+ const frac = abs % USDC_SCALE;
36
+ const fracStr = frac.toString().padStart(USDC_DECIMALS, "0");
37
+ return `${isNeg ? "-" : ""}${whole}.${fracStr}`;
38
+ }
39
+ function formatUSDCHuman(raw) {
40
+ const full = formatUSDC(raw);
41
+ const dotIndex = full.indexOf(".");
42
+ if (dotIndex === -1) return full;
43
+ let end = full.length;
44
+ while (end > dotIndex + 3 && full[end - 1] === "0") {
45
+ end--;
46
+ }
47
+ return full.slice(0, end);
48
+ }
49
+ function addUSDC(a, b) {
50
+ return formatUSDC(parseUSDC(a) + parseUSDC(b));
51
+ }
52
+ function compareUSDC(a, b) {
53
+ const diff = parseUSDC(a) - parseUSDC(b);
54
+ if (diff < 0n) return -1;
55
+ if (diff > 0n) return 1;
56
+ return 0;
57
+ }
58
+ function calculateAverage(amounts) {
59
+ if (amounts.length === 0) return formatUSDC(0n);
60
+ let sum = 0n;
61
+ for (const a of amounts) {
62
+ sum += parseUSDC(a);
63
+ }
64
+ return formatUSDC(sum / BigInt(amounts.length));
65
+ }
66
+
67
+ // src/utils/time.ts
68
+ var HOUR_MS = 60 * 60 * 1e3;
69
+ var DAY_MS = 24 * HOUR_MS;
70
+ function getHourStart(now) {
71
+ const ref = now ?? Date.now();
72
+ return ref - ref % HOUR_MS;
73
+ }
74
+ function getDayStart(now) {
75
+ const ref = now ?? Date.now();
76
+ return ref - ref % DAY_MS;
77
+ }
78
+ function resolveTimeRange(range, now) {
79
+ const ref = now ?? Date.now();
80
+ if (typeof range === "object") return range;
81
+ switch (range) {
82
+ case "last_hour":
83
+ return { start: ref - HOUR_MS, end: ref };
84
+ case "last_day":
85
+ return { start: ref - DAY_MS, end: ref };
86
+ case "last_week":
87
+ return { start: ref - 7 * DAY_MS, end: ref };
88
+ case "last_month":
89
+ return { start: ref - 30 * DAY_MS, end: ref };
90
+ }
91
+ }
92
+
93
+ exports.addUSDC = addUSDC;
94
+ exports.calculateAverage = calculateAverage;
95
+ exports.compareUSDC = compareUSDC;
96
+ exports.formatUSDC = formatUSDC;
97
+ exports.formatUSDCHuman = formatUSDCHuman;
98
+ exports.getDayStart = getDayStart;
99
+ exports.getHourStart = getHourStart;
100
+ exports.parseUSDC = parseUSDC;
101
+ exports.resolveTimeRange = resolveTimeRange;
102
+ //# sourceMappingURL=chunk-7H4FRU7K.cjs.map
103
+ //# sourceMappingURL=chunk-7H4FRU7K.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/utils/money.ts","../src/utils/time.ts"],"names":[],"mappings":"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","file":"chunk-7H4FRU7K.cjs","sourcesContent":["/** USDC has 6 decimal places: 1 USDC = 1_000_000 base units */\nexport const USDC_DECIMALS = 6;\nconst USDC_SCALE = 10n ** BigInt(USDC_DECIMALS);\n\n/**\n * Parse a human-readable USDC string (e.g., \"1.50\") into base units as bigint.\n * Handles integers, decimals with up to 6 places, and leading/trailing zeros.\n */\nexport function parseUSDC(human: string): bigint {\n const trimmed = human.trim();\n if (trimmed === \"\") {\n throw new Error(\"Cannot parse empty string as USDC amount\");\n }\n\n const negative = trimmed.startsWith(\"-\");\n if (negative) {\n throw new Error(\n `Negative USDC amounts are not allowed: \"${human}\"`\n );\n }\n\n const dotIndex = trimmed.indexOf(\".\");\n if (dotIndex === -1) {\n return BigInt(trimmed) * USDC_SCALE;\n }\n\n const wholePart = trimmed.slice(0, dotIndex) || \"0\";\n let fracPart = trimmed.slice(dotIndex + 1);\n\n if (fracPart.length > USDC_DECIMALS) {\n throw new Error(\n `USDC amount \"${human}\" exceeds ${USDC_DECIMALS} decimal places`\n );\n }\n\n fracPart = fracPart.padEnd(USDC_DECIMALS, \"0\");\n return BigInt(wholePart) * USDC_SCALE + BigInt(fracPart);\n}\n\n/** Format base units to a full 6-decimal USDC string (e.g., 1500000n -> \"1.500000\") */\nexport function formatUSDC(raw: bigint): string {\n const isNeg = raw < 0n;\n const abs = isNeg ? -raw : raw;\n const whole = abs / USDC_SCALE;\n const frac = abs % USDC_SCALE;\n const fracStr = frac.toString().padStart(USDC_DECIMALS, \"0\");\n return `${isNeg ? \"-\" : \"\"}${whole}.${fracStr}`;\n}\n\n/** Format base units to a trimmed human-readable string (e.g., 1500000n -> \"1.50\") */\nexport function formatUSDCHuman(raw: bigint): string {\n const full = formatUSDC(raw);\n const dotIndex = full.indexOf(\".\");\n if (dotIndex === -1) return full;\n\n let end = full.length;\n while (end > dotIndex + 3 && full[end - 1] === \"0\") {\n end--;\n }\n return full.slice(0, end);\n}\n\n/** Add two human-readable USDC amounts, return human-readable result */\nexport function addUSDC(a: string, b: string): string {\n return formatUSDC(parseUSDC(a) + parseUSDC(b));\n}\n\n/** Subtract b from a (both human-readable USDC), return human-readable result */\nexport function subtractUSDC(a: string, b: string): string {\n return formatUSDC(parseUSDC(a) - parseUSDC(b));\n}\n\n/** Compare two human-readable USDC amounts. Returns -1, 0, or 1. */\nexport function compareUSDC(a: string, b: string): -1 | 0 | 1 {\n const diff = parseUSDC(a) - parseUSDC(b);\n if (diff < 0n) return -1;\n if (diff > 0n) return 1;\n return 0;\n}\n\n/** Check if spent exceeds limit (both human-readable USDC strings) */\nexport function isOverBudget(spent: string, limit: string): boolean {\n return parseUSDC(spent) > parseUSDC(limit);\n}\n\n/** Calculate the average of human-readable USDC amounts. Returns \"0.000000\" for empty array. */\nexport function calculateAverage(amounts: string[]): string {\n if (amounts.length === 0) return formatUSDC(0n);\n let sum = 0n;\n for (const a of amounts) {\n sum += parseUSDC(a);\n }\n return formatUSDC(sum / BigInt(amounts.length));\n}\n","const HOUR_MS = 60 * 60 * 1000;\nconst DAY_MS = 24 * HOUR_MS;\n\n/** Check if a timestamp falls within `windowMs` milliseconds of now */\nexport function isWithinWindow(timestamp: number, windowMs: number, now?: number): boolean {\n const ref = now ?? Date.now();\n return ref - timestamp < windowMs;\n}\n\n/** Get the start of the current hour as a unix ms timestamp */\nexport function getHourStart(now?: number): number {\n const ref = now ?? Date.now();\n return ref - (ref % HOUR_MS);\n}\n\n/** Get the start of the current day (UTC) as a unix ms timestamp */\nexport function getDayStart(now?: number): number {\n const ref = now ?? Date.now();\n return ref - (ref % DAY_MS);\n}\n\n/** Format a unix ms timestamp as an ISO 8601 string */\nexport function formatTimestamp(ts: number): string {\n return new Date(ts).toISOString();\n}\n\n/** Resolve a named time range to epoch ms boundaries */\nexport function resolveTimeRange(\n range: \"last_hour\" | \"last_day\" | \"last_week\" | \"last_month\" | { start: number; end: number },\n now?: number,\n): { start: number; end: number } {\n const ref = now ?? Date.now();\n if (typeof range === \"object\") return range;\n switch (range) {\n case \"last_hour\":\n return { start: ref - HOUR_MS, end: ref };\n case \"last_day\":\n return { start: ref - DAY_MS, end: ref };\n case \"last_week\":\n return { start: ref - 7 * DAY_MS, end: ref };\n case \"last_month\":\n return { start: ref - 30 * DAY_MS, end: ref };\n }\n}\n"]}