@hivemind-os/collective-core 0.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 (145) hide show
  1. package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
  2. package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
  3. package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
  4. package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
  5. package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
  6. package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
  7. package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
  8. package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
  9. package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
  10. package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
  11. package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
  12. package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
  13. package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
  14. package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
  15. package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
  16. package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
  17. package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
  18. package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
  19. package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
  20. package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
  21. package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
  22. package/.turbo/turbo-build.log +14 -0
  23. package/dist/index.d.ts +1675 -0
  24. package/dist/index.js +8006 -0
  25. package/dist/index.js.map +1 -0
  26. package/package.json +41 -0
  27. package/src/auth/device-flow.ts +108 -0
  28. package/src/auth/ed25519-provider.ts +43 -0
  29. package/src/auth/errors.ts +82 -0
  30. package/src/auth/evm-key.ts +55 -0
  31. package/src/auth/index.ts +8 -0
  32. package/src/auth/session-state.ts +25 -0
  33. package/src/auth/session-store.ts +510 -0
  34. package/src/auth/types.ts +81 -0
  35. package/src/auth/zklogin-provider.ts +902 -0
  36. package/src/blobstore/WALRUS_FINDINGS.md +284 -0
  37. package/src/blobstore/encrypted-store.ts +56 -0
  38. package/src/blobstore/fs-store.ts +91 -0
  39. package/src/blobstore/hybrid-store.ts +144 -0
  40. package/src/blobstore/index.ts +5 -0
  41. package/src/blobstore/interface.ts +33 -0
  42. package/src/blobstore/walrus-spike.ts +345 -0
  43. package/src/blobstore/walrus-store.ts +551 -0
  44. package/src/cache/agent-cache.ts +403 -0
  45. package/src/cache/index.ts +1 -0
  46. package/src/crypto/encryption.ts +152 -0
  47. package/src/crypto/index.ts +2 -0
  48. package/src/crypto/x25519.ts +41 -0
  49. package/src/dispute/client.ts +191 -0
  50. package/src/dispute/index.ts +1 -0
  51. package/src/events/index.ts +2 -0
  52. package/src/events/parser.ts +291 -0
  53. package/src/events/subscription.ts +131 -0
  54. package/src/evm/constants.ts +6 -0
  55. package/src/evm/index.ts +2 -0
  56. package/src/evm/wallet.ts +136 -0
  57. package/src/identity/did.ts +36 -0
  58. package/src/identity/index.ts +4 -0
  59. package/src/identity/keypair.ts +199 -0
  60. package/src/identity/signing.ts +28 -0
  61. package/src/index.ts +22 -0
  62. package/src/internal/parsing.ts +416 -0
  63. package/src/marketplace/client.ts +349 -0
  64. package/src/marketplace/index.ts +1 -0
  65. package/src/metering/hash-chain.ts +94 -0
  66. package/src/metering/index.ts +4 -0
  67. package/src/metering/meter.ts +80 -0
  68. package/src/metering/streaming.ts +196 -0
  69. package/src/metering/verification.ts +104 -0
  70. package/src/payment/index.ts +1 -0
  71. package/src/payment/rail-selector.ts +41 -0
  72. package/src/registry/client.ts +328 -0
  73. package/src/registry/index.ts +1 -0
  74. package/src/relay/consumer-client.ts +497 -0
  75. package/src/relay/index.ts +1 -0
  76. package/src/relay-registry/client.ts +295 -0
  77. package/src/relay-registry/discovery.ts +109 -0
  78. package/src/relay-registry/index.ts +2 -0
  79. package/src/reputation/anchor-client.ts +126 -0
  80. package/src/reputation/event-publisher.ts +67 -0
  81. package/src/reputation/index.ts +5 -0
  82. package/src/reputation/merkle.ts +79 -0
  83. package/src/reputation/score-calculator.ts +133 -0
  84. package/src/reputation/serialization.ts +37 -0
  85. package/src/reputation/store.ts +165 -0
  86. package/src/reputation/validation.ts +135 -0
  87. package/src/routing/circuit-breaker.ts +111 -0
  88. package/src/routing/fan-out.ts +266 -0
  89. package/src/routing/index.ts +4 -0
  90. package/src/routing/performance.ts +244 -0
  91. package/src/routing/selector.ts +225 -0
  92. package/src/spending/index.ts +1 -0
  93. package/src/spending/policy.ts +271 -0
  94. package/src/staking/client.ts +319 -0
  95. package/src/staking/index.ts +1 -0
  96. package/src/sui/client.ts +214 -0
  97. package/src/sui/index.ts +2 -0
  98. package/src/sui/tx-helpers.ts +1070 -0
  99. package/src/task/client.ts +215 -0
  100. package/src/task/index.ts +1 -0
  101. package/src/x402/client.ts +295 -0
  102. package/src/x402/index.ts +1 -0
  103. package/tests/auth/device-flow.test.ts +62 -0
  104. package/tests/auth/ed25519-provider.test.ts +24 -0
  105. package/tests/auth/evm-key.test.ts +31 -0
  106. package/tests/auth/session-store.test.ts +201 -0
  107. package/tests/auth/zklogin-provider.test.ts +366 -0
  108. package/tests/blobstore/encrypted-store.test.ts +78 -0
  109. package/tests/blobstore.test.ts +91 -0
  110. package/tests/cache.test.ts +124 -0
  111. package/tests/crypto/encryption.test.ts +70 -0
  112. package/tests/crypto/x25519.test.ts +47 -0
  113. package/tests/dispute/client.test.ts +238 -0
  114. package/tests/events.test.ts +202 -0
  115. package/tests/evm/wallet.test.ts +101 -0
  116. package/tests/hybrid-store.test.ts +121 -0
  117. package/tests/identity.test.ts +161 -0
  118. package/tests/marketplace.test.ts +308 -0
  119. package/tests/metering/hash-chain.test.ts +32 -0
  120. package/tests/metering/meter.test.ts +23 -0
  121. package/tests/metering/streaming.test.ts +52 -0
  122. package/tests/metering/verification.test.ts +27 -0
  123. package/tests/payment/rail-selector.test.ts +95 -0
  124. package/tests/registry.test.ts +183 -0
  125. package/tests/relay-consumer-client.test.ts +119 -0
  126. package/tests/relay-registry/client.test.ts +261 -0
  127. package/tests/reputation/event-publisher.test.ts +70 -0
  128. package/tests/reputation/merkle.test.ts +44 -0
  129. package/tests/reputation/score-calculator.test.ts +104 -0
  130. package/tests/reputation/store.test.ts +94 -0
  131. package/tests/routing/circuit-breaker.test.ts +45 -0
  132. package/tests/routing/fan-out.test.ts +123 -0
  133. package/tests/routing/performance.test.ts +49 -0
  134. package/tests/routing/selector.test.ts +114 -0
  135. package/tests/spending.test.ts +133 -0
  136. package/tests/staking/client.test.ts +286 -0
  137. package/tests/sui-client.test.ts +85 -0
  138. package/tests/task.test.ts +249 -0
  139. package/tests/tx-helpers.test.ts +70 -0
  140. package/tests/walrus-spike.test.ts +100 -0
  141. package/tests/walrus-store.test.ts +196 -0
  142. package/tests/x402/client.test.ts +116 -0
  143. package/tsconfig.json +9 -0
  144. package/tsup.config.ts +11 -0
  145. package/vitest.config.ts +8 -0
@@ -0,0 +1,225 @@
1
+ import { type AgentCard, type ProviderScore, ProviderSelectionStrategy, type ReputationScore } from '@hivemind-os/collective-types';
2
+
3
+ import { ReputationScoreCalculator } from '../reputation/score-calculator.js';
4
+
5
+ import type { CircuitBreaker } from './circuit-breaker.js';
6
+ import type { PerformanceTracker } from './performance.js';
7
+
8
+ export interface ProviderSelectionWeights {
9
+ reputation: number;
10
+ price: number;
11
+ speed: number;
12
+ }
13
+
14
+ export interface ProviderSelectorOptions {
15
+ circuitBreaker?: Pick<CircuitBreaker, 'isOpen'>;
16
+ performanceTracker?: Pick<PerformanceTracker, 'getEstimatedLatency'>;
17
+ reputationCalculator?: ReputationScoreCalculator;
18
+ }
19
+
20
+ const DEFAULT_WEIGHTS: ProviderSelectionWeights = {
21
+ reputation: 0.45,
22
+ price: 0.35,
23
+ speed: 0.2,
24
+ };
25
+
26
+ export class ProviderSelector {
27
+ private readonly circuitBreaker?: Pick<CircuitBreaker, 'isOpen'>;
28
+ private readonly performanceTracker?: Pick<PerformanceTracker, 'getEstimatedLatency'>;
29
+ private readonly reputationCalculator: ReputationScoreCalculator;
30
+ private roundRobinOffset = 0;
31
+
32
+ constructor(options: ProviderSelectorOptions = {}) {
33
+ this.circuitBreaker = options.circuitBreaker;
34
+ this.performanceTracker = options.performanceTracker;
35
+ this.reputationCalculator = options.reputationCalculator ?? new ReputationScoreCalculator();
36
+ }
37
+
38
+ rankProviders(agents: AgentCard[], capability: string, weights: Partial<ProviderSelectionWeights> = {}): ProviderScore[] {
39
+ const normalizedCapability = capability.trim();
40
+ if (!normalizedCapability) {
41
+ return [];
42
+ }
43
+
44
+ const candidates = agents
45
+ .map((agent) => this.toProviderScore(agent, normalizedCapability))
46
+ .filter((entry): entry is ProviderScore => entry !== null);
47
+
48
+ return this.applyCompositeScores(candidates, weights).sort((left, right) => right.compositeScore - left.compositeScore);
49
+ }
50
+
51
+ selectProviders(
52
+ agents: AgentCard[],
53
+ capability: string,
54
+ strategy: ProviderSelectionStrategy,
55
+ count: number,
56
+ weights: Partial<ProviderSelectionWeights> = {},
57
+ ): ProviderScore[] {
58
+ const ranked = this.rankProviders(agents, capability, weights);
59
+ const available = ranked.filter((entry) => !entry.circuitBreakerOpen);
60
+ const selectedCount = Math.max(1, Math.floor(count));
61
+
62
+ switch (strategy) {
63
+ case ProviderSelectionStrategy.CHEAPEST:
64
+ return this.selectCheapest(available, selectedCount);
65
+ case ProviderSelectionStrategy.FASTEST:
66
+ return this.selectFastest(available, selectedCount);
67
+ case ProviderSelectionStrategy.HIGHEST_REPUTATION:
68
+ return this.selectHighestReputation(available, selectedCount);
69
+ case ProviderSelectionStrategy.ROUND_ROBIN:
70
+ return this.selectRoundRobin(available, selectedCount);
71
+ case ProviderSelectionStrategy.WEIGHTED:
72
+ default:
73
+ return this.selectWeighted(available, selectedCount, weights);
74
+ }
75
+ }
76
+
77
+ selectCheapest(providers: ProviderScore[], count: number): ProviderScore[] {
78
+ return [...providers]
79
+ .sort((left, right) => compareBigInt(left.price, right.price) || compareNumber(right.reputation, left.reputation))
80
+ .slice(0, count);
81
+ }
82
+
83
+ selectFastest(providers: ProviderScore[], count: number): ProviderScore[] {
84
+ return [...providers]
85
+ .sort(
86
+ (left, right) => compareNumber(left.estimatedLatency ?? Number.POSITIVE_INFINITY, right.estimatedLatency ?? Number.POSITIVE_INFINITY)
87
+ || compareNumber(right.reputation, left.reputation),
88
+ )
89
+ .slice(0, count);
90
+ }
91
+
92
+ selectHighestReputation(providers: ProviderScore[], count: number): ProviderScore[] {
93
+ return [...providers]
94
+ .sort((left, right) => compareNumber(right.reputation, left.reputation) || compareBigInt(left.price, right.price))
95
+ .slice(0, count);
96
+ }
97
+
98
+ selectRoundRobin(providers: ProviderScore[], count: number): ProviderScore[] {
99
+ if (providers.length === 0) {
100
+ return [];
101
+ }
102
+
103
+ const start = this.roundRobinOffset % providers.length;
104
+ const rotated = providers.slice(start).concat(providers.slice(0, start));
105
+ this.roundRobinOffset = (this.roundRobinOffset + count) % providers.length;
106
+ return rotated.slice(0, Math.min(count, providers.length));
107
+ }
108
+
109
+ selectWeighted(
110
+ providers: ProviderScore[],
111
+ count: number,
112
+ weights: Partial<ProviderSelectionWeights> = {},
113
+ ): ProviderScore[] {
114
+ return this.applyCompositeScores(providers, weights)
115
+ .sort((left, right) => compareNumber(right.compositeScore, left.compositeScore) || compareBigInt(left.price, right.price))
116
+ .slice(0, count);
117
+ }
118
+
119
+ private toProviderScore(agent: AgentCard, capability: string): ProviderScore | null {
120
+ const matchedCapability = agent.capabilities.find((entry) => capabilityNameEquals(entry.name, capability));
121
+ if (!matchedCapability) {
122
+ return null;
123
+ }
124
+
125
+ const reputationScore = this.reputationCalculator.computeScore(agent, []);
126
+ const scopedScore = getScopedReputation(reputationScore, capability);
127
+ const reputation = scopedScore.successRate * 100 + Math.min(scopedScore.taskCount, 100) * 0.25 - reputationScore.totalDisputes;
128
+ const estimatedLatency = this.performanceTracker?.getEstimatedLatency(agent.did, matchedCapability.name)
129
+ ?? scopedScore.averageLatencyMs
130
+ ?? heuristicLatency(reputation);
131
+
132
+ return {
133
+ did: agent.did,
134
+ price: matchedCapability.pricing.amount,
135
+ reputation,
136
+ estimatedLatency,
137
+ circuitBreakerOpen: this.circuitBreaker?.isOpen(agent.did) ?? false,
138
+ compositeScore: 0,
139
+ };
140
+ }
141
+
142
+ private applyCompositeScores(
143
+ providers: ProviderScore[],
144
+ weights: Partial<ProviderSelectionWeights>,
145
+ ): ProviderScore[] {
146
+ const normalizedWeights = { ...DEFAULT_WEIGHTS, ...weights };
147
+ const reputations = providers.map((entry) => entry.reputation);
148
+ const prices = providers.map((entry) => entry.price);
149
+ const latencies = providers.map((entry) => entry.estimatedLatency ?? heuristicLatency(entry.reputation));
150
+
151
+ return providers.map((provider) => {
152
+ const normalizedReputation = normalizeNumber(provider.reputation, reputations);
153
+ const normalizedPrice = normalizeBigInt(provider.price, prices);
154
+ const normalizedSpeed = 1 - normalizeNumber(provider.estimatedLatency ?? heuristicLatency(provider.reputation), latencies);
155
+ return {
156
+ ...provider,
157
+ compositeScore:
158
+ normalizedWeights.reputation * normalizedReputation
159
+ - normalizedWeights.price * normalizedPrice
160
+ + normalizedWeights.speed * normalizedSpeed,
161
+ };
162
+ });
163
+ }
164
+ }
165
+
166
+ function getScopedReputation(score: ReputationScore, capability: string): { successRate: number; taskCount: number; averageLatencyMs?: number } {
167
+ const scoped = score.capabilityScores[capability]
168
+ ?? Object.entries(score.capabilityScores).find(([entry]) => capabilityNameEquals(entry, capability))?.[1];
169
+ if (scoped) {
170
+ return {
171
+ successRate: scoped.successRate,
172
+ taskCount: scoped.taskCount,
173
+ averageLatencyMs: scoped.averageLatencyMs || undefined,
174
+ };
175
+ }
176
+
177
+ return {
178
+ successRate: score.successRate,
179
+ taskCount: score.totalTasks,
180
+ averageLatencyMs: score.averageLatencyMs || undefined,
181
+ };
182
+ }
183
+
184
+ function heuristicLatency(reputation: number): number {
185
+ return Math.max(100, Math.round(1_500 - Math.max(0, reputation) * 10));
186
+ }
187
+
188
+ function capabilityNameEquals(left: string, right: string): boolean {
189
+ return left.toLowerCase() === right.toLowerCase();
190
+ }
191
+
192
+ function compareBigInt(left: bigint, right: bigint): number {
193
+ if (left === right) {
194
+ return 0;
195
+ }
196
+ return left < right ? -1 : 1;
197
+ }
198
+
199
+ function compareNumber(left: number, right: number): number {
200
+ if (left === right) {
201
+ return 0;
202
+ }
203
+ return left < right ? -1 : 1;
204
+ }
205
+
206
+ function normalizeNumber(value: number, values: number[]): number {
207
+ const min = Math.min(...values);
208
+ const max = Math.max(...values);
209
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max === min) {
210
+ return 0.5;
211
+ }
212
+
213
+ return (value - min) / (max - min);
214
+ }
215
+
216
+ function normalizeBigInt(value: bigint, values: bigint[]): number {
217
+ const min = values.reduce((current, entry) => (entry < current ? entry : current), values[0] ?? 0n);
218
+ const max = values.reduce((current, entry) => (entry > current ? entry : current), values[0] ?? 0n);
219
+ if (max === min) {
220
+ return 0.5;
221
+ }
222
+
223
+ const scale = 1_000_000n;
224
+ return Number(((value - min) * scale) / (max - min)) / Number(scale);
225
+ }
@@ -0,0 +1 @@
1
+ export * from './policy.js';
@@ -0,0 +1,271 @@
1
+ import Database from 'better-sqlite3';
2
+
3
+ import type { PaymentRail, SpendingPolicy } from '@hivemind-os/collective-types';
4
+
5
+ export interface SpendingPolicyDecision {
6
+ approved: boolean;
7
+ reason?: string;
8
+ }
9
+
10
+ export interface SpendingPolicyConfig extends SpendingPolicy {
11
+ perApp?: Record<string, { limits: SpendingPolicy['limits'] }>;
12
+ }
13
+
14
+ interface SpendingLogRow {
15
+ total: bigint | number | null;
16
+ }
17
+
18
+ interface SpendingLogColumn {
19
+ name: string;
20
+ }
21
+
22
+ interface SpendingAmount {
23
+ amount?: bigint;
24
+ amountMist?: bigint;
25
+ currency?: string;
26
+ }
27
+
28
+ export interface SpendingLogEntry {
29
+ id: number;
30
+ amountBaseUnits: bigint;
31
+ rail: PaymentRail;
32
+ currency?: string;
33
+ taskId?: string;
34
+ appId?: string;
35
+ timestamp: number;
36
+ }
37
+
38
+ export class SpendingPolicyEngine {
39
+ private readonly db: Database.Database;
40
+ private policy: SpendingPolicyConfig;
41
+
42
+ constructor(params: { policy: SpendingPolicyConfig; dbPath: string }) {
43
+ this.policy = params.policy;
44
+ this.db = new Database(params.dbPath);
45
+ this.db.defaultSafeIntegers(true);
46
+ this.db.exec(`
47
+ CREATE TABLE IF NOT EXISTS spending_log (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ amount_base_units INTEGER NOT NULL,
50
+ rail TEXT NOT NULL,
51
+ currency TEXT,
52
+ task_id TEXT,
53
+ app_id TEXT,
54
+ timestamp INTEGER NOT NULL
55
+ );
56
+ `);
57
+ this.migrateSpendingLogSchema();
58
+ }
59
+
60
+ evaluate(request: SpendingAmount & { rail: PaymentRail; appId?: string; originAppName?: string }): SpendingPolicyDecision {
61
+ const amount = resolveAmount(request);
62
+ const normalizedCurrency = normalizeCurrency(request.currency);
63
+ const originAppName = request.originAppName;
64
+ const normalizedRequest = {
65
+ amount,
66
+ currency: normalizedCurrency,
67
+ rail: request.rail,
68
+ appId: request.appId,
69
+ };
70
+ const perAppPolicy = originAppName ? this.policy.perApp?.[originAppName] : undefined;
71
+ if (perAppPolicy) {
72
+ const perAppDecision = this.evaluateLimits(perAppPolicy.limits, normalizedRequest, originAppName);
73
+ if (!perAppDecision.approved) {
74
+ return perAppDecision;
75
+ }
76
+ }
77
+
78
+ if (this.policy.denylist?.includes(request.appId ?? '')) {
79
+ return { approved: false, reason: 'App is denylisted.' };
80
+ }
81
+
82
+ if (this.policy.allowlist && !this.policy.allowlist.includes(request.appId ?? '')) {
83
+ return { approved: false, reason: 'App is not allowlisted.' };
84
+ }
85
+
86
+ if (this.policy.requireConfirmationAbove !== undefined && amount > this.policy.requireConfirmationAbove) {
87
+ return { approved: false, reason: 'Amount requires confirmation.' };
88
+ }
89
+
90
+ return this.evaluateLimits(this.policy.limits, normalizedRequest);
91
+ }
92
+
93
+ record(entry: SpendingAmount & { rail: PaymentRail; taskId: string; appId?: string; originAppName?: string }): void {
94
+ const amount = resolveAmount(entry);
95
+ this.db
96
+ .prepare(
97
+ `INSERT INTO spending_log (amount_base_units, rail, currency, task_id, app_id, timestamp)
98
+ VALUES (?, ?, ?, ?, ?, ?)`,
99
+ )
100
+ .run(
101
+ amount,
102
+ entry.rail,
103
+ normalizeCurrency(entry.currency) ?? null,
104
+ entry.taskId,
105
+ entry.originAppName ?? entry.appId ?? null,
106
+ Date.now(),
107
+ );
108
+ }
109
+
110
+ getSpent(interval: 'hour' | 'day' | 'month', rail?: PaymentRail, appId?: string, currency?: string): bigint {
111
+ return this.getSpentForLimit(interval, rail, appId, normalizeCurrency(currency));
112
+ }
113
+
114
+ /** Return the most recent spending log entries, newest first. */
115
+ getRecentEntries(limit = 50): SpendingLogEntry[] {
116
+ const rows = this.db
117
+ .prepare(
118
+ `SELECT id, amount_base_units, rail, currency, task_id, app_id, timestamp
119
+ FROM spending_log ORDER BY timestamp DESC LIMIT ?`,
120
+ )
121
+ .all(limit) as Array<{
122
+ id: number;
123
+ amount_base_units: bigint;
124
+ rail: string;
125
+ currency: string | null;
126
+ task_id: string | null;
127
+ app_id: string | null;
128
+ timestamp: bigint;
129
+ }>;
130
+ return rows.map((row) => ({
131
+ id: row.id,
132
+ amountBaseUnits: row.amount_base_units,
133
+ rail: row.rail as PaymentRail,
134
+ currency: row.currency ?? undefined,
135
+ taskId: row.task_id ?? undefined,
136
+ appId: row.app_id ?? undefined,
137
+ timestamp: Number(row.timestamp),
138
+ }));
139
+ }
140
+
141
+ updatePolicy(policy: SpendingPolicyConfig): void {
142
+ this.policy = policy;
143
+ }
144
+
145
+ close(): void {
146
+ this.db.close();
147
+ }
148
+
149
+ private evaluateLimits(
150
+ limits: SpendingPolicy['limits'],
151
+ request: {
152
+ amount: bigint;
153
+ currency?: string;
154
+ rail: PaymentRail;
155
+ appId?: string;
156
+ },
157
+ appScope?: string,
158
+ ): SpendingPolicyDecision {
159
+ for (const limit of limits) {
160
+ if (limit.rail && limit.rail !== request.rail) {
161
+ continue;
162
+ }
163
+
164
+ const limitCurrency = normalizeCurrency(limit.currency);
165
+ if (limitCurrency && limitCurrency !== request.currency) {
166
+ continue;
167
+ }
168
+
169
+ const scope = appScope ?? limit.scope;
170
+ if (!appScope && limit.scope && limit.scope !== request.appId) {
171
+ continue;
172
+ }
173
+
174
+ if (limit.interval === 'transaction' && request.amount > limit.amount) {
175
+ return { approved: false, reason: `Transaction limit exceeded for ${limit.interval}.` };
176
+ }
177
+
178
+ if (limit.interval === 'transaction') {
179
+ continue;
180
+ }
181
+
182
+ const spent = this.getSpentForLimit(limit.interval, limit.rail ?? request.rail, scope, limitCurrency ?? request.currency);
183
+ if (spent + request.amount > limit.amount) {
184
+ return { approved: false, reason: `Spending limit exceeded for ${limit.interval}.` };
185
+ }
186
+ }
187
+
188
+ return { approved: true };
189
+ }
190
+
191
+ private getSpentForLimit(
192
+ interval: 'hour' | 'day' | 'month' | 'lifetime',
193
+ rail?: PaymentRail,
194
+ scope?: string,
195
+ currency?: string,
196
+ ): bigint {
197
+ const startTime = interval === 'lifetime' ? 0 : getIntervalStart(interval);
198
+ let query = 'SELECT COALESCE(SUM(amount_base_units), 0) AS total FROM spending_log WHERE timestamp >= ?';
199
+ const params: Array<bigint | number | string> = [startTime];
200
+
201
+ if (rail) {
202
+ query += ' AND rail = ?';
203
+ params.push(rail);
204
+ }
205
+
206
+ if (scope) {
207
+ query += ' AND app_id = ?';
208
+ params.push(scope);
209
+ }
210
+
211
+ if (currency) {
212
+ query += ' AND currency = ?';
213
+ params.push(currency);
214
+ }
215
+
216
+ const row = this.db.prepare(query).get(...params) as SpendingLogRow | undefined;
217
+ const total = row?.total ?? 0n;
218
+ return typeof total === 'bigint' ? total : BigInt(total);
219
+ }
220
+
221
+ private migrateSpendingLogSchema(): void {
222
+ const columns = this.db.prepare('PRAGMA table_info(spending_log)').all() as SpendingLogColumn[];
223
+ const columnNames = new Set(columns.map((column) => column.name));
224
+
225
+ if (columnNames.has('amount_mist') && !columnNames.has('amount_base_units')) {
226
+ this.db.exec('ALTER TABLE spending_log RENAME COLUMN amount_mist TO amount_base_units');
227
+ }
228
+
229
+ if (!columnNames.has('currency')) {
230
+ this.db.exec('ALTER TABLE spending_log ADD COLUMN currency TEXT');
231
+ }
232
+ }
233
+ }
234
+
235
+ function getIntervalStart(interval: 'hour' | 'day' | 'month'): number {
236
+ const now = new Date(Date.now());
237
+
238
+ if (interval === 'hour') {
239
+ now.setMinutes(0, 0, 0);
240
+ return now.getTime();
241
+ }
242
+
243
+ if (interval === 'day') {
244
+ now.setHours(0, 0, 0, 0);
245
+ return now.getTime();
246
+ }
247
+
248
+ now.setDate(1);
249
+ now.setHours(0, 0, 0, 0);
250
+ return now.getTime();
251
+ }
252
+
253
+ function resolveAmount(value: SpendingAmount): bigint {
254
+ if (value.amount !== undefined && value.amountMist !== undefined && value.amount !== value.amountMist) {
255
+ throw new Error('amount and amountMist must match when both are provided.');
256
+ }
257
+
258
+ if (value.amount !== undefined) {
259
+ return value.amount;
260
+ }
261
+
262
+ if (value.amountMist !== undefined) {
263
+ return value.amountMist;
264
+ }
265
+
266
+ throw new Error('A spending amount is required.');
267
+ }
268
+
269
+ function normalizeCurrency(value: string | undefined): string | undefined {
270
+ return value?.trim() ? value.trim().toUpperCase() : undefined;
271
+ }