@framers/agentos-ext-wallet 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.
- package/LICENSE +98 -0
- package/dist/AgentWalletManager.d.ts +68 -0
- package/dist/AgentWalletManager.d.ts.map +1 -0
- package/dist/AgentWalletManager.js +238 -0
- package/dist/AgentWalletManager.js.map +1 -0
- package/dist/SpendingPolicyEnforcer.d.ts +33 -0
- package/dist/SpendingPolicyEnforcer.d.ts.map +1 -0
- package/dist/SpendingPolicyEnforcer.js +128 -0
- package/dist/SpendingPolicyEnforcer.js.map +1 -0
- package/dist/cards/CardManager.d.ts +71 -0
- package/dist/cards/CardManager.d.ts.map +1 -0
- package/dist/cards/CardManager.js +212 -0
- package/dist/cards/CardManager.js.map +1 -0
- package/dist/cards/LithicCardAdapter.d.ts +40 -0
- package/dist/cards/LithicCardAdapter.d.ts.map +1 -0
- package/dist/cards/LithicCardAdapter.js +116 -0
- package/dist/cards/LithicCardAdapter.js.map +1 -0
- package/dist/cards/MccCategoryMap.d.ts +36 -0
- package/dist/cards/MccCategoryMap.d.ts.map +1 -0
- package/dist/cards/MccCategoryMap.js +113 -0
- package/dist/cards/MccCategoryMap.js.map +1 -0
- package/dist/cards/types.d.ts +131 -0
- package/dist/cards/types.d.ts.map +1 -0
- package/dist/cards/types.js +12 -0
- package/dist/cards/types.js.map +1 -0
- package/dist/chains/EvmWalletAdapter.d.ts +30 -0
- package/dist/chains/EvmWalletAdapter.d.ts.map +1 -0
- package/dist/chains/EvmWalletAdapter.js +135 -0
- package/dist/chains/EvmWalletAdapter.js.map +1 -0
- package/dist/chains/SolanaWalletAdapter.d.ts +32 -0
- package/dist/chains/SolanaWalletAdapter.d.ts.map +1 -0
- package/dist/chains/SolanaWalletAdapter.js +128 -0
- package/dist/chains/SolanaWalletAdapter.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +228 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/CardSpendingSummaryTool.d.ts +14 -0
- package/dist/tools/CardSpendingSummaryTool.d.ts.map +1 -0
- package/dist/tools/CardSpendingSummaryTool.js +40 -0
- package/dist/tools/CardSpendingSummaryTool.js.map +1 -0
- package/dist/tools/CardStatusTool.d.ts +21 -0
- package/dist/tools/CardStatusTool.d.ts.map +1 -0
- package/dist/tools/CardStatusTool.js +48 -0
- package/dist/tools/CardStatusTool.js.map +1 -0
- package/dist/tools/CheckBalanceTool.d.ts +23 -0
- package/dist/tools/CheckBalanceTool.d.ts.map +1 -0
- package/dist/tools/CheckBalanceTool.js +116 -0
- package/dist/tools/CheckBalanceTool.js.map +1 -0
- package/dist/tools/FreezeCardTool.d.ts +15 -0
- package/dist/tools/FreezeCardTool.d.ts.map +1 -0
- package/dist/tools/FreezeCardTool.js +39 -0
- package/dist/tools/FreezeCardTool.js.map +1 -0
- package/dist/tools/IssueCardTool.d.ts +22 -0
- package/dist/tools/IssueCardTool.d.ts.map +1 -0
- package/dist/tools/IssueCardTool.js +55 -0
- package/dist/tools/IssueCardTool.js.map +1 -0
- package/dist/tools/PayWithCardTool.d.ts +29 -0
- package/dist/tools/PayWithCardTool.d.ts.map +1 -0
- package/dist/tools/PayWithCardTool.js +79 -0
- package/dist/tools/PayWithCardTool.js.map +1 -0
- package/dist/tools/SendCryptoTool.d.ts +33 -0
- package/dist/tools/SendCryptoTool.d.ts.map +1 -0
- package/dist/tools/SendCryptoTool.js +136 -0
- package/dist/tools/SendCryptoTool.js.map +1 -0
- package/dist/tools/UnfreezeCardTool.d.ts +15 -0
- package/dist/tools/UnfreezeCardTool.d.ts.map +1 -0
- package/dist/tools/UnfreezeCardTool.js +39 -0
- package/dist/tools/UnfreezeCardTool.js.map +1 -0
- package/dist/tools/WalletHistoryTool.d.ts +35 -0
- package/dist/tools/WalletHistoryTool.d.ts.map +1 -0
- package/dist/tools/WalletHistoryTool.js +75 -0
- package/dist/tools/WalletHistoryTool.js.map +1 -0
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/manifest.json +98 -0
- package/package.json +78 -0
- package/src/AgentWalletManager.ts +324 -0
- package/src/SpendingPolicyEnforcer.ts +169 -0
- package/src/cards/CardManager.ts +274 -0
- package/src/cards/LithicCardAdapter.ts +159 -0
- package/src/cards/MccCategoryMap.ts +138 -0
- package/src/cards/types.ts +169 -0
- package/src/chains/EvmWalletAdapter.ts +170 -0
- package/src/chains/SolanaWalletAdapter.ts +184 -0
- package/src/index.ts +315 -0
- package/src/tools/CardSpendingSummaryTool.ts +52 -0
- package/src/tools/CardStatusTool.ts +68 -0
- package/src/tools/CheckBalanceTool.ts +145 -0
- package/src/tools/FreezeCardTool.ts +52 -0
- package/src/tools/IssueCardTool.ts +76 -0
- package/src/tools/PayWithCardTool.ts +109 -0
- package/src/tools/SendCryptoTool.ts +185 -0
- package/src/tools/UnfreezeCardTool.ts +52 -0
- package/src/tools/WalletHistoryTool.ts +113 -0
- package/src/types.ts +230 -0
- package/test/AgentWalletManager.spec.ts +301 -0
- package/test/CardManager.spec.ts +428 -0
- package/test/LithicCardAdapter.spec.ts +224 -0
- package/test/MccCategoryMap.spec.ts +153 -0
- package/test/SpendingPolicyEnforcer.spec.ts +254 -0
- package/test/card-integration.spec.ts +242 -0
- package/test/card-tools.spec.ts +285 -0
- package/test/integration.spec.ts +171 -0
- package/test/wallet-tools.spec.ts +275 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Pre-transaction guardrail that enforces spending limits,
|
|
4
|
+
* category budgets, address whitelists/blacklists, and approval thresholds.
|
|
5
|
+
*
|
|
6
|
+
* All amounts are in USD. The enforcer tracks running totals via a
|
|
7
|
+
* simple ledger interface — callers provide the persistence layer.
|
|
8
|
+
*
|
|
9
|
+
* @module wunderland/wallet/SpendingPolicyEnforcer
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
SpendCategory,
|
|
14
|
+
SpendCheckResult,
|
|
15
|
+
SpendingLedgerEntry,
|
|
16
|
+
SpendingPolicyConfig,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
/** Minimal interface for querying the spending ledger (DB-agnostic). */
|
|
20
|
+
export interface ISpendingLedgerStore {
|
|
21
|
+
/** Sum all spending for the agent in the given period key (e.g. '2026-03-05'). */
|
|
22
|
+
sumByPeriod(agentId: string, periodKey: string): Promise<number>;
|
|
23
|
+
/** Sum spending for a specific category in the given period key. */
|
|
24
|
+
sumByCategoryAndPeriod(agentId: string, category: SpendCategory, periodKey: string): Promise<number>;
|
|
25
|
+
/** Record a new ledger entry after a successful spend. */
|
|
26
|
+
insert(entry: SpendingLedgerEntry): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class SpendingPolicyEnforcer {
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly policy: SpendingPolicyConfig,
|
|
32
|
+
private readonly ledger: ISpendingLedgerStore,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check whether a proposed spend is allowed under the current policy.
|
|
37
|
+
*/
|
|
38
|
+
async canSpend(
|
|
39
|
+
agentId: string,
|
|
40
|
+
amountUsd: number,
|
|
41
|
+
category: SpendCategory,
|
|
42
|
+
recipientAddress?: string,
|
|
43
|
+
): Promise<SpendCheckResult> {
|
|
44
|
+
// 1. Blocked category check
|
|
45
|
+
if (this.policy.blockedCategories.includes(category)) {
|
|
46
|
+
return { allowed: false, reason: `Category "${category}" is blocked by spending policy.` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. Address blacklist check
|
|
50
|
+
if (recipientAddress && this.policy.blockedAddresses?.includes(recipientAddress)) {
|
|
51
|
+
return { allowed: false, reason: `Recipient address is blocked.` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Address whitelist check (if whitelist is set, only whitelisted addresses are allowed)
|
|
55
|
+
if (
|
|
56
|
+
recipientAddress
|
|
57
|
+
&& this.policy.allowedAddresses
|
|
58
|
+
&& this.policy.allowedAddresses.length > 0
|
|
59
|
+
&& !this.policy.allowedAddresses.includes(recipientAddress)
|
|
60
|
+
) {
|
|
61
|
+
return { allowed: false, reason: `Recipient address is not in the allowed list.` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4. Per-transaction limit
|
|
65
|
+
if (amountUsd > this.policy.perTransactionLimitUsd) {
|
|
66
|
+
return {
|
|
67
|
+
allowed: false,
|
|
68
|
+
reason: `Amount $${amountUsd.toFixed(2)} exceeds per-transaction limit of $${this.policy.perTransactionLimitUsd.toFixed(2)}.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const today = getDailyKey();
|
|
73
|
+
const month = getMonthlyKey();
|
|
74
|
+
|
|
75
|
+
// 5. Daily global limit
|
|
76
|
+
const dailySpent = await this.ledger.sumByPeriod(agentId, today);
|
|
77
|
+
const remainingDailyUsd = this.policy.dailyLimitUsd - dailySpent;
|
|
78
|
+
if (amountUsd > remainingDailyUsd) {
|
|
79
|
+
return {
|
|
80
|
+
allowed: false,
|
|
81
|
+
reason: `Daily limit reached. Spent $${dailySpent.toFixed(2)} of $${this.policy.dailyLimitUsd.toFixed(2)} today.`,
|
|
82
|
+
remainingDailyUsd: Math.max(0, remainingDailyUsd),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 6. Monthly global limit
|
|
87
|
+
const monthlySpent = await this.ledger.sumByPeriod(agentId, month);
|
|
88
|
+
const remainingMonthlyUsd = this.policy.monthlyLimitUsd - monthlySpent;
|
|
89
|
+
if (amountUsd > remainingMonthlyUsd) {
|
|
90
|
+
return {
|
|
91
|
+
allowed: false,
|
|
92
|
+
reason: `Monthly limit reached. Spent $${monthlySpent.toFixed(2)} of $${this.policy.monthlyLimitUsd.toFixed(2)} this month.`,
|
|
93
|
+
remainingMonthlyUsd: Math.max(0, remainingMonthlyUsd),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 7. Per-category budget check
|
|
98
|
+
const catBudget = this.policy.categoryBudgets.find((b) => b.category === category);
|
|
99
|
+
if (catBudget) {
|
|
100
|
+
const catDailySpent = await this.ledger.sumByCategoryAndPeriod(agentId, category, today);
|
|
101
|
+
if (amountUsd > catBudget.dailyLimitUsd - catDailySpent) {
|
|
102
|
+
return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
reason: `Daily "${category}" budget exceeded. Spent $${catDailySpent.toFixed(2)} of $${catBudget.dailyLimitUsd.toFixed(2)}.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const catMonthlySpent = await this.ledger.sumByCategoryAndPeriod(agentId, category, month);
|
|
108
|
+
if (amountUsd > catBudget.monthlyLimitUsd - catMonthlySpent) {
|
|
109
|
+
return {
|
|
110
|
+
allowed: false,
|
|
111
|
+
reason: `Monthly "${category}" budget exceeded. Spent $${catMonthlySpent.toFixed(2)} of $${catBudget.monthlyLimitUsd.toFixed(2)}.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 8. Human approval threshold
|
|
117
|
+
const requiresApproval = amountUsd > this.policy.requireApprovalAboveUsd;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
allowed: true,
|
|
121
|
+
requiresApproval,
|
|
122
|
+
remainingDailyUsd: remainingDailyUsd - amountUsd,
|
|
123
|
+
remainingMonthlyUsd: remainingMonthlyUsd - amountUsd,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Record a completed spend in the ledger (call after successful tx).
|
|
129
|
+
*/
|
|
130
|
+
async recordSpend(
|
|
131
|
+
agentId: string,
|
|
132
|
+
amountUsd: number,
|
|
133
|
+
category: SpendCategory,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const dailyKey = getDailyKey();
|
|
137
|
+
const monthlyKey = getMonthlyKey();
|
|
138
|
+
|
|
139
|
+
// Record both daily and monthly entries
|
|
140
|
+
await this.ledger.insert({
|
|
141
|
+
id: `spend-${agentId}-${now}`,
|
|
142
|
+
agentId,
|
|
143
|
+
category,
|
|
144
|
+
amountUsd,
|
|
145
|
+
periodKey: dailyKey,
|
|
146
|
+
createdAt: now,
|
|
147
|
+
});
|
|
148
|
+
await this.ledger.insert({
|
|
149
|
+
id: `spend-${agentId}-${now}-m`,
|
|
150
|
+
agentId,
|
|
151
|
+
category,
|
|
152
|
+
amountUsd,
|
|
153
|
+
periodKey: monthlyKey,
|
|
154
|
+
createdAt: now,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Date key helpers
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function getDailyKey(): string {
|
|
164
|
+
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getMonthlyKey(): string {
|
|
168
|
+
return new Date().toISOString().slice(0, 7); // YYYY-MM
|
|
169
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview High-level card lifecycle manager.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors AgentWalletManager's pattern: one card per agent, spending policy
|
|
6
|
+
* enforcement via the shared SpendingPolicyEnforcer, and in-memory/DB store
|
|
7
|
+
* abstraction via ICardStore.
|
|
8
|
+
*
|
|
9
|
+
* @module wallet/cards/CardManager
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
13
|
+
import type { SpendingPolicyEnforcer } from '../SpendingPolicyEnforcer.js';
|
|
14
|
+
import type { SpendCategory } from '../types.js';
|
|
15
|
+
import type { MccCategoryMap } from './MccCategoryMap.js';
|
|
16
|
+
import type {
|
|
17
|
+
AgentCardRecord,
|
|
18
|
+
CardConfig,
|
|
19
|
+
CardTransactionRecord,
|
|
20
|
+
ICardAdapter,
|
|
21
|
+
ICardStore,
|
|
22
|
+
LithicTransaction,
|
|
23
|
+
SpendingSummary,
|
|
24
|
+
} from './types.js';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// CardManager
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface CardManagerOptions {
|
|
31
|
+
adapter: ICardAdapter;
|
|
32
|
+
store: ICardStore;
|
|
33
|
+
policyEnforcer: SpendingPolicyEnforcer;
|
|
34
|
+
mccMap: MccCategoryMap;
|
|
35
|
+
config: CardConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CardManager {
|
|
39
|
+
private readonly adapter: ICardAdapter;
|
|
40
|
+
private readonly store: ICardStore;
|
|
41
|
+
private readonly policy: SpendingPolicyEnforcer;
|
|
42
|
+
private readonly mccMap: MccCategoryMap;
|
|
43
|
+
private readonly config: CardConfig;
|
|
44
|
+
|
|
45
|
+
constructor(opts: CardManagerOptions) {
|
|
46
|
+
this.adapter = opts.adapter;
|
|
47
|
+
this.store = opts.store;
|
|
48
|
+
this.policy = opts.policyEnforcer;
|
|
49
|
+
this.mccMap = opts.mccMap;
|
|
50
|
+
this.config = opts.config;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// -----------------------------------------------------------------------
|
|
54
|
+
// Card lifecycle
|
|
55
|
+
// -----------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Issue a new virtual card for the agent.
|
|
59
|
+
* One card per agent — throws if card already exists.
|
|
60
|
+
*/
|
|
61
|
+
async issueCard(
|
|
62
|
+
agentId: string,
|
|
63
|
+
opts?: { memo?: string; spendLimitUsd?: number },
|
|
64
|
+
): Promise<AgentCardRecord> {
|
|
65
|
+
const existing = await this.store.getCard(agentId);
|
|
66
|
+
if (existing) {
|
|
67
|
+
throw new Error(`Agent ${agentId} already has a card (last4: ${existing.last4})`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const spendLimit = opts?.spendLimitUsd ?? this.config.defaultSpendLimitUsd;
|
|
71
|
+
|
|
72
|
+
const lithicCard = await this.adapter.createCard({
|
|
73
|
+
type: 'VIRTUAL',
|
|
74
|
+
memo: opts?.memo || `Agent ${agentId} virtual card`,
|
|
75
|
+
spend_limit: Math.round(spendLimit * 100), // Lithic uses cents
|
|
76
|
+
spend_limit_duration: 'MONTHLY',
|
|
77
|
+
state: 'OPEN',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Set auth rules for blocked categories
|
|
81
|
+
const blockedMccs = this.getBlockedMccs();
|
|
82
|
+
if (blockedMccs.length > 0) {
|
|
83
|
+
await this.adapter.createAuthRule(lithicCard.token, {
|
|
84
|
+
blocked_mcc: blockedMccs,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const record: AgentCardRecord = {
|
|
89
|
+
id: uuidv4(),
|
|
90
|
+
agentId,
|
|
91
|
+
lithicCardToken: lithicCard.token,
|
|
92
|
+
last4: lithicCard.last_four,
|
|
93
|
+
cardType: lithicCard.type,
|
|
94
|
+
state: lithicCard.state,
|
|
95
|
+
spendLimitUsd: spendLimit,
|
|
96
|
+
spendLimitDuration: 'MONTHLY',
|
|
97
|
+
network: 'VISA',
|
|
98
|
+
memo: opts?.memo,
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
await this.store.saveCard(record);
|
|
103
|
+
return record;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Get the agent's card record, or null. */
|
|
107
|
+
async getCard(agentId: string): Promise<AgentCardRecord | null> {
|
|
108
|
+
return this.store.getCard(agentId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get sensitive card details (PAN, CVV, expiry) for online payments.
|
|
113
|
+
* Enforces spending policy before revealing details.
|
|
114
|
+
*/
|
|
115
|
+
async getCardDetails(
|
|
116
|
+
agentId: string,
|
|
117
|
+
estimatedAmountUsd?: number,
|
|
118
|
+
category?: SpendCategory,
|
|
119
|
+
): Promise<{ last4: string; expMonth: string; expYear: string; cvv: string; pan: string }> {
|
|
120
|
+
const card = await this.requireCard(agentId);
|
|
121
|
+
|
|
122
|
+
// If an estimated amount is provided, check spending policy first
|
|
123
|
+
if (estimatedAmountUsd !== undefined) {
|
|
124
|
+
const check = await this.policy.canSpend(agentId, estimatedAmountUsd, category || 'shopping');
|
|
125
|
+
if (!check.allowed) {
|
|
126
|
+
throw new CardSpendingBlockedError(check.reason || 'Spending policy violation');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const lithicCard = await this.adapter.getCard(card.lithicCardToken);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
last4: lithicCard.last_four,
|
|
134
|
+
expMonth: lithicCard.exp_month || '',
|
|
135
|
+
expYear: lithicCard.exp_year || '',
|
|
136
|
+
cvv: lithicCard.cvv || '',
|
|
137
|
+
pan: lithicCard.pan || '',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Freeze (pause) the agent's card. */
|
|
142
|
+
async freezeCard(agentId: string): Promise<void> {
|
|
143
|
+
const card = await this.requireCard(agentId);
|
|
144
|
+
if (card.state === 'PAUSED') return;
|
|
145
|
+
if (card.state === 'CLOSED') throw new Error('Cannot freeze a closed card');
|
|
146
|
+
|
|
147
|
+
await this.adapter.pauseCard(card.lithicCardToken);
|
|
148
|
+
await this.store.updateCardState(agentId, 'PAUSED');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Unfreeze (resume) the agent's card. */
|
|
152
|
+
async unfreezeCard(agentId: string): Promise<void> {
|
|
153
|
+
const card = await this.requireCard(agentId);
|
|
154
|
+
if (card.state === 'OPEN') return;
|
|
155
|
+
if (card.state === 'CLOSED') throw new Error('Cannot unfreeze a closed card');
|
|
156
|
+
|
|
157
|
+
await this.adapter.resumeCard(card.lithicCardToken);
|
|
158
|
+
await this.store.updateCardState(agentId, 'OPEN');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Permanently close the agent's card. */
|
|
162
|
+
async closeCard(agentId: string): Promise<void> {
|
|
163
|
+
const card = await this.requireCard(agentId);
|
|
164
|
+
if (card.state === 'CLOSED') return;
|
|
165
|
+
|
|
166
|
+
await this.adapter.closeCard(card.lithicCardToken);
|
|
167
|
+
await this.store.updateCardState(agentId, 'CLOSED');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// Spending summary
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
async getSpendingSummary(agentId: string, period: 'day' | 'month' = 'month'): Promise<SpendingSummary> {
|
|
175
|
+
const card = await this.requireCard(agentId);
|
|
176
|
+
|
|
177
|
+
const now = new Date();
|
|
178
|
+
let since: number;
|
|
179
|
+
if (period === 'day') {
|
|
180
|
+
since = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
181
|
+
} else {
|
|
182
|
+
since = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const txs = await this.store.getTransactionsByPeriod(card.id, since);
|
|
186
|
+
const settled = txs.filter(t => t.status === 'SETTLED' || t.status === 'PENDING');
|
|
187
|
+
|
|
188
|
+
const byCategory = new Map<SpendCategory, number>();
|
|
189
|
+
let totalUsd = 0;
|
|
190
|
+
|
|
191
|
+
for (const tx of settled) {
|
|
192
|
+
totalUsd += tx.amountUsd;
|
|
193
|
+
byCategory.set(tx.category, (byCategory.get(tx.category) || 0) + tx.amountUsd);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
totalUsd,
|
|
198
|
+
period,
|
|
199
|
+
byCategory: [...byCategory.entries()].map(([category, amountUsd]) => ({ category, amountUsd })),
|
|
200
|
+
transactionCount: settled.length,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// -----------------------------------------------------------------------
|
|
205
|
+
// Webhook processing
|
|
206
|
+
// -----------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Process a Lithic transaction webhook event.
|
|
210
|
+
* Resolves MCC → category and records the spend in the shared ledger.
|
|
211
|
+
*/
|
|
212
|
+
async processWebhookTransaction(agentId: string, lithicTx: LithicTransaction): Promise<void> {
|
|
213
|
+
const card = await this.store.getCard(agentId);
|
|
214
|
+
if (!card) return; // Unknown card — ignore
|
|
215
|
+
|
|
216
|
+
const category = this.mccMap.mccToCategory(lithicTx.merchant.mcc);
|
|
217
|
+
const amountUsd = Math.abs(lithicTx.amount) / 100; // Lithic amounts are in cents
|
|
218
|
+
|
|
219
|
+
const txRecord: CardTransactionRecord = {
|
|
220
|
+
id: uuidv4(),
|
|
221
|
+
cardId: card.id,
|
|
222
|
+
lithicTxToken: lithicTx.token,
|
|
223
|
+
merchantName: lithicTx.merchant.descriptor,
|
|
224
|
+
merchantMcc: lithicTx.merchant.mcc,
|
|
225
|
+
category,
|
|
226
|
+
amountUsd,
|
|
227
|
+
status: lithicTx.status,
|
|
228
|
+
createdAt: Date.now(),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
await this.store.insertTransaction(txRecord);
|
|
232
|
+
|
|
233
|
+
// Record in shared spending ledger (same as crypto sends)
|
|
234
|
+
if (lithicTx.status === 'SETTLED' || lithicTx.status === 'PENDING') {
|
|
235
|
+
await this.policy.recordSpend(agentId, amountUsd, category);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// -----------------------------------------------------------------------
|
|
240
|
+
// Internal helpers
|
|
241
|
+
// -----------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
private async requireCard(agentId: string): Promise<AgentCardRecord> {
|
|
244
|
+
const card = await this.store.getCard(agentId);
|
|
245
|
+
if (!card) {
|
|
246
|
+
throw new Error(`No card found for agent ${agentId}. Issue one first.`);
|
|
247
|
+
}
|
|
248
|
+
return card;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build a flat list of blocked MCC codes from the spending policy's
|
|
253
|
+
* blockedCategories, expanded via the MCC map.
|
|
254
|
+
*/
|
|
255
|
+
private getBlockedMccs(): string[] {
|
|
256
|
+
// Access the policy's config to get blocked categories
|
|
257
|
+
// The SpendingPolicyEnforcer doesn't expose config directly,
|
|
258
|
+
// so we pass blocked categories through the card config or
|
|
259
|
+
// use the MCC map to expand known blocked categories.
|
|
260
|
+
// For now, this is handled at the extension pack level.
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Error classes
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
export class CardSpendingBlockedError extends Error {
|
|
270
|
+
constructor(reason: string) {
|
|
271
|
+
super(reason);
|
|
272
|
+
this.name = 'CardSpendingBlockedError';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Pure REST client for the Lithic card issuance API.
|
|
4
|
+
*
|
|
5
|
+
* No SDK dependency — uses fetch() directly. Supports sandbox and production
|
|
6
|
+
* environments via configurable base URL.
|
|
7
|
+
*
|
|
8
|
+
* @see https://docs.lithic.com/reference
|
|
9
|
+
* @module wallet/cards/LithicCardAdapter
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
AuthRuleParams,
|
|
14
|
+
CreateCardParams,
|
|
15
|
+
ICardAdapter,
|
|
16
|
+
LithicCardResponse,
|
|
17
|
+
LithicTransaction,
|
|
18
|
+
ListTxOpts,
|
|
19
|
+
UpdateCardParams,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Config
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface LithicCardAdapterOptions {
|
|
27
|
+
apiKey: string;
|
|
28
|
+
/** Use sandbox environment. Defaults to true. */
|
|
29
|
+
sandbox?: boolean;
|
|
30
|
+
/** Override base URL (for testing). */
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SANDBOX_URL = 'https://sandbox.lithic.com/v1';
|
|
35
|
+
const PRODUCTION_URL = 'https://api.lithic.com/v1';
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Adapter
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export class LithicCardAdapter implements ICardAdapter {
|
|
42
|
+
private readonly apiKey: string;
|
|
43
|
+
private readonly baseUrl: string;
|
|
44
|
+
|
|
45
|
+
constructor(opts: LithicCardAdapterOptions) {
|
|
46
|
+
this.apiKey = opts.apiKey;
|
|
47
|
+
this.baseUrl = opts.baseUrl || (opts.sandbox !== false ? SANDBOX_URL : PRODUCTION_URL);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// -----------------------------------------------------------------------
|
|
51
|
+
// Card CRUD
|
|
52
|
+
// -----------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
async createCard(params: CreateCardParams): Promise<LithicCardResponse> {
|
|
55
|
+
return this.post<LithicCardResponse>('/cards', params);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async getCard(cardToken: string): Promise<LithicCardResponse> {
|
|
59
|
+
return this.get<LithicCardResponse>(`/cards/${cardToken}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async updateCard(cardToken: string, updates: UpdateCardParams): Promise<LithicCardResponse> {
|
|
63
|
+
return this.patch<LithicCardResponse>(`/cards/${cardToken}`, updates);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async pauseCard(cardToken: string): Promise<void> {
|
|
67
|
+
await this.patch(`/cards/${cardToken}`, { state: 'PAUSED' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async resumeCard(cardToken: string): Promise<void> {
|
|
71
|
+
await this.patch(`/cards/${cardToken}`, { state: 'OPEN' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async closeCard(cardToken: string): Promise<void> {
|
|
75
|
+
await this.patch(`/cards/${cardToken}`, { state: 'CLOSED' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// -----------------------------------------------------------------------
|
|
79
|
+
// Transactions
|
|
80
|
+
// -----------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async listTransactions(cardToken: string, opts?: ListTxOpts): Promise<LithicTransaction[]> {
|
|
83
|
+
const params = new URLSearchParams();
|
|
84
|
+
params.set('card_token', cardToken);
|
|
85
|
+
if (opts?.begin) params.set('begin', opts.begin);
|
|
86
|
+
if (opts?.end) params.set('end', opts.end);
|
|
87
|
+
if (opts?.page_size) params.set('page_size', String(opts.page_size));
|
|
88
|
+
|
|
89
|
+
const result = await this.get<{ data: LithicTransaction[] }>(`/transactions?${params.toString()}`);
|
|
90
|
+
return result.data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
// Auth rules (MCC-based spending controls)
|
|
95
|
+
// -----------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async createAuthRule(cardToken: string, rule: AuthRuleParams): Promise<void> {
|
|
98
|
+
await this.post('/auth_rules', {
|
|
99
|
+
card_tokens: [cardToken],
|
|
100
|
+
...rule,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -----------------------------------------------------------------------
|
|
105
|
+
// HTTP helpers
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
private async get<T>(path: string): Promise<T> {
|
|
109
|
+
return this.request<T>('GET', path);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async post<T>(path: string, body: unknown): Promise<T> {
|
|
113
|
+
return this.request<T>('POST', path, body);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async patch<T>(path: string, body: unknown): Promise<T> {
|
|
117
|
+
return this.request<T>('PATCH', path, body);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
121
|
+
const url = `${this.baseUrl}${path}`;
|
|
122
|
+
const headers: Record<string, string> = {
|
|
123
|
+
'Authorization': this.apiKey,
|
|
124
|
+
'Accept': 'application/json',
|
|
125
|
+
};
|
|
126
|
+
if (body) headers['Content-Type'] = 'application/json';
|
|
127
|
+
|
|
128
|
+
const resp = await fetch(url, {
|
|
129
|
+
method,
|
|
130
|
+
headers,
|
|
131
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!resp.ok) {
|
|
135
|
+
const text = await resp.text().catch(() => '');
|
|
136
|
+
throw new LithicApiError(resp.status, `Lithic API error ${resp.status}: ${text}`, path);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Some endpoints return no body (204)
|
|
140
|
+
if (resp.status === 204) return undefined as T;
|
|
141
|
+
|
|
142
|
+
return resp.json() as Promise<T>;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Error
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export class LithicApiError extends Error {
|
|
151
|
+
constructor(
|
|
152
|
+
public readonly statusCode: number,
|
|
153
|
+
message: string,
|
|
154
|
+
public readonly path: string,
|
|
155
|
+
) {
|
|
156
|
+
super(message);
|
|
157
|
+
this.name = 'LithicApiError';
|
|
158
|
+
}
|
|
159
|
+
}
|