@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
package/src/types.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Wallet types, spending policy, and category definitions
|
|
4
|
+
* for the Agent Personal Wallet system.
|
|
5
|
+
*
|
|
6
|
+
* Enables Wunderland agents to autonomously spend within user-configured
|
|
7
|
+
* guardrails — covering crypto (Solana + EVM) and virtual cards (Lithic).
|
|
8
|
+
*
|
|
9
|
+
* @module wunderland/wallet/types
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Chain & Token primitives
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export type ChainId = 'solana' | 'ethereum' | 'base' | 'polygon';
|
|
17
|
+
|
|
18
|
+
export type TokenSymbol = 'SOL' | 'ETH' | 'USDC' | 'USDT';
|
|
19
|
+
|
|
20
|
+
/** Token metadata for a specific chain deployment. */
|
|
21
|
+
export interface TokenInfo {
|
|
22
|
+
symbol: TokenSymbol;
|
|
23
|
+
chain: ChainId;
|
|
24
|
+
/** Mint address (Solana SPL) or contract address (ERC-20). Empty for native tokens. */
|
|
25
|
+
address: string;
|
|
26
|
+
decimals: number;
|
|
27
|
+
/** CoinGecko ID for price lookups. */
|
|
28
|
+
coingeckoId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Spending categories
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Spend categories used for budget enforcement and card MCC mapping.
|
|
37
|
+
*
|
|
38
|
+
* Crypto categories: api_costs, web_services, subscriptions, transfers, defi
|
|
39
|
+
* Card categories: shopping, dining, travel, entertainment, utilities
|
|
40
|
+
* Catch-all: other
|
|
41
|
+
*/
|
|
42
|
+
export type SpendCategory =
|
|
43
|
+
| 'api_costs' // LLM calls, search credits, SaaS APIs
|
|
44
|
+
| 'web_services' // Domain registration, hosting, cloud compute
|
|
45
|
+
| 'shopping' // Physical/digital goods
|
|
46
|
+
| 'subscriptions' // Recurring services
|
|
47
|
+
| 'transfers' // Crypto sends to external wallets
|
|
48
|
+
| 'defi' // Swaps, staking, lending, LP
|
|
49
|
+
| 'dining' // Restaurants (MCC 5812-5814)
|
|
50
|
+
| 'travel' // Airlines, hotels (MCC 3000-3999, 7011)
|
|
51
|
+
| 'entertainment' // Streaming, gaming (MCC 7832, 7841, etc.)
|
|
52
|
+
| 'utilities' // Bills, phone (MCC 4812, 4900, etc.)
|
|
53
|
+
| 'other';
|
|
54
|
+
|
|
55
|
+
export interface CategoryBudget {
|
|
56
|
+
category: SpendCategory;
|
|
57
|
+
/** Max daily spend in USD for this category. */
|
|
58
|
+
dailyLimitUsd: number;
|
|
59
|
+
/** Max monthly spend in USD for this category. */
|
|
60
|
+
monthlyLimitUsd: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Spending policy
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface SpendingPolicyConfig {
|
|
68
|
+
/** Global daily spending cap in USD across all categories. */
|
|
69
|
+
dailyLimitUsd: number;
|
|
70
|
+
/** Max single transaction amount in USD. */
|
|
71
|
+
perTransactionLimitUsd: number;
|
|
72
|
+
/** Global monthly spending cap in USD. */
|
|
73
|
+
monthlyLimitUsd: number;
|
|
74
|
+
/** Per-category budget overrides. Categories not listed inherit global limits. */
|
|
75
|
+
categoryBudgets: CategoryBudget[];
|
|
76
|
+
/** Transactions above this USD amount require human approval. */
|
|
77
|
+
requireApprovalAboveUsd: number;
|
|
78
|
+
/** Categories the agent is completely blocked from spending in. */
|
|
79
|
+
blockedCategories: SpendCategory[];
|
|
80
|
+
/** Crypto address whitelist — if set, only these addresses can receive funds. */
|
|
81
|
+
allowedAddresses?: string[];
|
|
82
|
+
/** Crypto address blacklist — these addresses are always blocked. */
|
|
83
|
+
blockedAddresses?: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Wallet config (lives in agent.config.json)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export interface WalletConfig {
|
|
91
|
+
/** Master switch for wallet functionality. */
|
|
92
|
+
enabled: boolean;
|
|
93
|
+
/** Which chains the agent has wallets on. */
|
|
94
|
+
chains: ChainId[];
|
|
95
|
+
/**
|
|
96
|
+
* Key custody mode:
|
|
97
|
+
* - hot: plaintext in memory (dev/testing only)
|
|
98
|
+
* - encrypted-hot: AES-256-GCM encrypted at rest, decrypted on sign
|
|
99
|
+
*/
|
|
100
|
+
custodyMode: 'hot' | 'encrypted-hot';
|
|
101
|
+
/** Tokens the agent is allowed to hold/send. */
|
|
102
|
+
allowedTokens: TokenSymbol[];
|
|
103
|
+
/** Spending guardrails. */
|
|
104
|
+
spendingPolicy: SpendingPolicyConfig;
|
|
105
|
+
/** Virtual card configuration (Lithic). */
|
|
106
|
+
card?: import('./cards/types.js').CardConfig;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Wallet records (DB / runtime)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export type WalletDirection = 'inbound' | 'outbound';
|
|
114
|
+
export type WalletTxStatus = 'pending' | 'confirmed' | 'failed';
|
|
115
|
+
|
|
116
|
+
export interface AgentWalletRecord {
|
|
117
|
+
id: string;
|
|
118
|
+
agentId: string;
|
|
119
|
+
chain: ChainId;
|
|
120
|
+
/** Public address (base58 for Solana, 0x-prefixed hex for EVM). */
|
|
121
|
+
address: string;
|
|
122
|
+
/** AES-256-GCM encrypted private key (base64). */
|
|
123
|
+
encryptedKey: string;
|
|
124
|
+
/** Salt used for key derivation (hex). */
|
|
125
|
+
keyDerivationSalt: string;
|
|
126
|
+
createdAt: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface WalletTransactionRecord {
|
|
130
|
+
id: string;
|
|
131
|
+
walletId: string;
|
|
132
|
+
txHash?: string;
|
|
133
|
+
direction: WalletDirection;
|
|
134
|
+
toAddress?: string;
|
|
135
|
+
fromAddress?: string;
|
|
136
|
+
/** Raw token amount as string (lamports, wei, etc.). */
|
|
137
|
+
amountRaw: string;
|
|
138
|
+
/** USD equivalent at time of transaction. */
|
|
139
|
+
amountUsd?: number;
|
|
140
|
+
token: TokenSymbol;
|
|
141
|
+
category?: SpendCategory;
|
|
142
|
+
status: WalletTxStatus;
|
|
143
|
+
description?: string;
|
|
144
|
+
createdAt: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface SpendingLedgerEntry {
|
|
148
|
+
id: string;
|
|
149
|
+
agentId: string;
|
|
150
|
+
category: SpendCategory;
|
|
151
|
+
amountUsd: number;
|
|
152
|
+
/** Date key: 'YYYY-MM-DD' for daily, 'YYYY-MM' for monthly. */
|
|
153
|
+
periodKey: string;
|
|
154
|
+
createdAt: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Spending policy check result
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export interface SpendCheckResult {
|
|
162
|
+
allowed: boolean;
|
|
163
|
+
/** Human-readable reason if blocked. */
|
|
164
|
+
reason?: string;
|
|
165
|
+
/** True if the transaction needs explicit human approval (above threshold but within limits). */
|
|
166
|
+
requiresApproval?: boolean;
|
|
167
|
+
/** Remaining daily budget in USD after this transaction (if allowed). */
|
|
168
|
+
remainingDailyUsd?: number;
|
|
169
|
+
/** Remaining monthly budget in USD after this transaction (if allowed). */
|
|
170
|
+
remainingMonthlyUsd?: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Chain adapter interface
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export interface IChainWalletAdapter {
|
|
178
|
+
readonly chain: ChainId;
|
|
179
|
+
|
|
180
|
+
/** Generate a new keypair. Returns { publicKey, secretKey (raw bytes) }. */
|
|
181
|
+
generateKeypair(): Promise<{ publicKey: string; secretKey: Uint8Array }>;
|
|
182
|
+
|
|
183
|
+
/** Get native token balance in raw units (lamports, wei). */
|
|
184
|
+
getBalance(address: string): Promise<bigint>;
|
|
185
|
+
|
|
186
|
+
/** Get SPL/ERC-20 token balance in raw units. */
|
|
187
|
+
getTokenBalance(address: string, tokenMint: string): Promise<bigint>;
|
|
188
|
+
|
|
189
|
+
/** Build and sign a native transfer transaction. Returns serialized signed tx. */
|
|
190
|
+
signTransfer(
|
|
191
|
+
secretKey: Uint8Array,
|
|
192
|
+
to: string,
|
|
193
|
+
amountRaw: bigint,
|
|
194
|
+
): Promise<Uint8Array>;
|
|
195
|
+
|
|
196
|
+
/** Build and sign a token transfer. Returns serialized signed tx. */
|
|
197
|
+
signTokenTransfer(
|
|
198
|
+
secretKey: Uint8Array,
|
|
199
|
+
to: string,
|
|
200
|
+
tokenMint: string,
|
|
201
|
+
amountRaw: bigint,
|
|
202
|
+
): Promise<Uint8Array>;
|
|
203
|
+
|
|
204
|
+
/** Broadcast a signed transaction and return the tx hash. */
|
|
205
|
+
broadcast(signedTx: Uint8Array): Promise<string>;
|
|
206
|
+
|
|
207
|
+
/** Check if a transaction is confirmed. */
|
|
208
|
+
getTransactionStatus(txHash: string): Promise<WalletTxStatus>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Default spending policy
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export const DEFAULT_SPENDING_POLICY: SpendingPolicyConfig = {
|
|
216
|
+
dailyLimitUsd: 50,
|
|
217
|
+
perTransactionLimitUsd: 20,
|
|
218
|
+
monthlyLimitUsd: 500,
|
|
219
|
+
categoryBudgets: [],
|
|
220
|
+
requireApprovalAboveUsd: 10,
|
|
221
|
+
blockedCategories: [],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const DEFAULT_WALLET_CONFIG: WalletConfig = {
|
|
225
|
+
enabled: false,
|
|
226
|
+
chains: ['solana'],
|
|
227
|
+
custodyMode: 'encrypted-hot',
|
|
228
|
+
allowedTokens: ['SOL', 'USDC'],
|
|
229
|
+
spendingPolicy: DEFAULT_SPENDING_POLICY,
|
|
230
|
+
};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Unit tests for AgentWalletManager.
|
|
4
|
+
*
|
|
5
|
+
* Tests cover: wallet creation, duplicate prevention, encrypted key storage,
|
|
6
|
+
* balance caching, spending with policy enforcement, policy violation errors,
|
|
7
|
+
* and transaction recording.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
11
|
+
import { AgentWalletManager, SpendingPolicyViolation, ApprovalRequiredError } from '../src/AgentWalletManager.js';
|
|
12
|
+
import { SpendingPolicyEnforcer } from '../src/SpendingPolicyEnforcer.js';
|
|
13
|
+
import type { IChainWalletAdapter, ChainId, WalletConfig, WalletTxStatus, AgentWalletRecord } from '../src/types.js';
|
|
14
|
+
import { DEFAULT_SPENDING_POLICY } from '../src/types.js';
|
|
15
|
+
|
|
16
|
+
/* ------------------------------------------------------------------ */
|
|
17
|
+
/* Mocks */
|
|
18
|
+
/* ------------------------------------------------------------------ */
|
|
19
|
+
|
|
20
|
+
function createMockAdapter(chain: ChainId = 'solana'): IChainWalletAdapter {
|
|
21
|
+
return {
|
|
22
|
+
chain,
|
|
23
|
+
generateKeypair: vi.fn(async () => ({
|
|
24
|
+
publicKey: `${chain}-pub-${Math.random().toString(36).slice(2, 8)}`,
|
|
25
|
+
secretKey: new Uint8Array(64).fill(1),
|
|
26
|
+
})),
|
|
27
|
+
getBalance: vi.fn(async () => 1_000_000_000n), // 1 SOL / 1 ETH
|
|
28
|
+
getTokenBalance: vi.fn(async () => 50_000_000n), // 50 USDC
|
|
29
|
+
signTransfer: vi.fn(async () => new Uint8Array([1, 2, 3])),
|
|
30
|
+
signTokenTransfer: vi.fn(async () => new Uint8Array([4, 5, 6])),
|
|
31
|
+
broadcast: vi.fn(async () => 'tx-hash-abc123'),
|
|
32
|
+
getTransactionStatus: vi.fn(async () => 'confirmed' as WalletTxStatus),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createMemoryStore() {
|
|
37
|
+
const wallets = new Map<string, AgentWalletRecord>();
|
|
38
|
+
const txs = new Map<string, any[]>();
|
|
39
|
+
return {
|
|
40
|
+
wallets,
|
|
41
|
+
txs,
|
|
42
|
+
async getWallet(agentId: string, chain: ChainId) {
|
|
43
|
+
return wallets.get(`${agentId}:${chain}`) || null;
|
|
44
|
+
},
|
|
45
|
+
async getAllWallets(agentId: string) {
|
|
46
|
+
return [...wallets.values()].filter(w => w.agentId === agentId);
|
|
47
|
+
},
|
|
48
|
+
async saveWallet(record: AgentWalletRecord) {
|
|
49
|
+
wallets.set(`${record.agentId}:${record.chain}`, record);
|
|
50
|
+
},
|
|
51
|
+
async insertTransaction(record: any) {
|
|
52
|
+
const list = txs.get(record.walletId) || [];
|
|
53
|
+
list.push(record);
|
|
54
|
+
txs.set(record.walletId, list);
|
|
55
|
+
},
|
|
56
|
+
async updateTransactionStatus(id: string, status: string, txHash?: string) {
|
|
57
|
+
for (const [, list] of txs) {
|
|
58
|
+
const tx = list.find((t: any) => t.id === id);
|
|
59
|
+
if (tx) { tx.status = status; if (txHash) tx.txHash = txHash; }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
async getTransactions(walletId: string, limit = 20) {
|
|
63
|
+
return (txs.get(walletId) || []).slice(-limit).reverse();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createMemoryLedger() {
|
|
69
|
+
const entries: any[] = [];
|
|
70
|
+
return {
|
|
71
|
+
entries,
|
|
72
|
+
async sumByPeriod(agentId: string, periodKey: string) {
|
|
73
|
+
return entries.filter(e => e.agentId === agentId && e.periodKey === periodKey)
|
|
74
|
+
.reduce((sum, e) => sum + e.amountUsd, 0);
|
|
75
|
+
},
|
|
76
|
+
async sumByCategoryAndPeriod(agentId: string, category: string, periodKey: string) {
|
|
77
|
+
return entries.filter(e => e.agentId === agentId && e.category === category && e.periodKey === periodKey)
|
|
78
|
+
.reduce((sum, e) => sum + e.amountUsd, 0);
|
|
79
|
+
},
|
|
80
|
+
async insert(entry: any) { entries.push(entry); },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const AGENT = 'agent-wallet-test';
|
|
85
|
+
const SECRET = 'test-master-secret-32bytes!!!!!';
|
|
86
|
+
|
|
87
|
+
function createManager(overrides?: { policy?: Partial<typeof DEFAULT_SPENDING_POLICY> }) {
|
|
88
|
+
const adapter = createMockAdapter();
|
|
89
|
+
const store = createMemoryStore();
|
|
90
|
+
const ledger = createMemoryLedger();
|
|
91
|
+
const policyConfig = { ...DEFAULT_SPENDING_POLICY, ...overrides?.policy };
|
|
92
|
+
const policyEnforcer = new SpendingPolicyEnforcer(policyConfig, ledger);
|
|
93
|
+
|
|
94
|
+
const config: WalletConfig = {
|
|
95
|
+
enabled: true,
|
|
96
|
+
chains: ['solana'],
|
|
97
|
+
custodyMode: 'encrypted-hot',
|
|
98
|
+
allowedTokens: ['SOL', 'USDC'],
|
|
99
|
+
spendingPolicy: policyConfig,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const adapters = new Map<ChainId, IChainWalletAdapter>([['solana', adapter]]);
|
|
103
|
+
|
|
104
|
+
const manager = new AgentWalletManager({
|
|
105
|
+
masterSecret: SECRET,
|
|
106
|
+
walletConfig: config,
|
|
107
|
+
store,
|
|
108
|
+
policyEnforcer,
|
|
109
|
+
adapters,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return { manager, adapter, store, ledger, policyEnforcer };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
/* Tests */
|
|
117
|
+
/* ------------------------------------------------------------------ */
|
|
118
|
+
|
|
119
|
+
describe('AgentWalletManager', () => {
|
|
120
|
+
|
|
121
|
+
/* ── Wallet creation ───────────────────────────────────────────── */
|
|
122
|
+
|
|
123
|
+
describe('createWallet', () => {
|
|
124
|
+
it('should create a wallet with encrypted key', async () => {
|
|
125
|
+
const { manager, store, adapter } = createManager();
|
|
126
|
+
|
|
127
|
+
const wallet = await manager.createWallet(AGENT, 'solana');
|
|
128
|
+
|
|
129
|
+
expect(wallet.agentId).toBe(AGENT);
|
|
130
|
+
expect(wallet.chain).toBe('solana');
|
|
131
|
+
expect(wallet.address).toBeTruthy();
|
|
132
|
+
expect(wallet.encryptedKey).toBeTruthy();
|
|
133
|
+
expect(wallet.keyDerivationSalt).toBeTruthy();
|
|
134
|
+
expect(adapter.generateKeypair).toHaveBeenCalledOnce();
|
|
135
|
+
expect(store.wallets.size).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should reject duplicate wallet for same agent+chain', async () => {
|
|
139
|
+
const { manager } = createManager();
|
|
140
|
+
|
|
141
|
+
await manager.createWallet(AGENT, 'solana');
|
|
142
|
+
await expect(manager.createWallet(AGENT, 'solana')).rejects.toThrow('already has a solana wallet');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should store different wallets for different chains', async () => {
|
|
146
|
+
const evmAdapter = createMockAdapter('ethereum');
|
|
147
|
+
const solAdapter = createMockAdapter('solana');
|
|
148
|
+
const store = createMemoryStore();
|
|
149
|
+
const ledger = createMemoryLedger();
|
|
150
|
+
const enforcer = new SpendingPolicyEnforcer(DEFAULT_SPENDING_POLICY, ledger);
|
|
151
|
+
|
|
152
|
+
const manager = new AgentWalletManager({
|
|
153
|
+
masterSecret: SECRET,
|
|
154
|
+
walletConfig: { enabled: true, chains: ['solana', 'ethereum'], custodyMode: 'encrypted-hot', allowedTokens: ['SOL', 'ETH'], spendingPolicy: DEFAULT_SPENDING_POLICY },
|
|
155
|
+
store,
|
|
156
|
+
policyEnforcer: enforcer,
|
|
157
|
+
adapters: new Map([['solana', solAdapter], ['ethereum', evmAdapter]]),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const sol = await manager.createWallet(AGENT, 'solana');
|
|
161
|
+
const eth = await manager.createWallet(AGENT, 'ethereum');
|
|
162
|
+
|
|
163
|
+
expect(sol.chain).toBe('solana');
|
|
164
|
+
expect(eth.chain).toBe('ethereum');
|
|
165
|
+
expect(sol.address).not.toBe(eth.address);
|
|
166
|
+
expect(store.wallets.size).toBe(2);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/* ── Balance queries ───────────────────────────────────────────── */
|
|
171
|
+
|
|
172
|
+
describe('getBalance', () => {
|
|
173
|
+
it('should return balance from adapter', async () => {
|
|
174
|
+
const { manager } = createManager();
|
|
175
|
+
await manager.createWallet(AGENT, 'solana');
|
|
176
|
+
|
|
177
|
+
const balance = await manager.getBalance(AGENT, 'solana');
|
|
178
|
+
expect(balance).toBe(1_000_000_000n);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should cache balance for 30s', async () => {
|
|
182
|
+
const { manager, adapter } = createManager();
|
|
183
|
+
await manager.createWallet(AGENT, 'solana');
|
|
184
|
+
|
|
185
|
+
await manager.getBalance(AGENT, 'solana');
|
|
186
|
+
await manager.getBalance(AGENT, 'solana');
|
|
187
|
+
await manager.getBalance(AGENT, 'solana');
|
|
188
|
+
|
|
189
|
+
expect(adapter.getBalance).toHaveBeenCalledOnce();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should throw for non-existent wallet', async () => {
|
|
193
|
+
const { manager } = createManager();
|
|
194
|
+
await expect(manager.getBalance(AGENT, 'solana')).rejects.toThrow('No solana wallet found');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/* ── Spending with policy ──────────────────────────────────────── */
|
|
199
|
+
|
|
200
|
+
describe('sendNative', () => {
|
|
201
|
+
it('should send and record transaction', async () => {
|
|
202
|
+
const { manager, store, adapter } = createManager();
|
|
203
|
+
await manager.createWallet(AGENT, 'solana');
|
|
204
|
+
|
|
205
|
+
const tx = await manager.sendNative(AGENT, 'solana', 'dest-addr', 500_000_000n, 5, 'transfers', 'test send');
|
|
206
|
+
|
|
207
|
+
expect(tx.status).toBe('confirmed');
|
|
208
|
+
expect(tx.txHash).toBe('tx-hash-abc123');
|
|
209
|
+
expect(tx.toAddress).toBe('dest-addr');
|
|
210
|
+
expect(adapter.signTransfer).toHaveBeenCalledOnce();
|
|
211
|
+
expect(adapter.broadcast).toHaveBeenCalledOnce();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should block spend exceeding per-transaction limit', async () => {
|
|
215
|
+
const { manager } = createManager({ policy: { perTransactionLimitUsd: 5 } });
|
|
216
|
+
await manager.createWallet(AGENT, 'solana');
|
|
217
|
+
|
|
218
|
+
await expect(
|
|
219
|
+
manager.sendNative(AGENT, 'solana', 'dest', 1_000_000_000n, 10, 'transfers'),
|
|
220
|
+
).rejects.toThrow(SpendingPolicyViolation);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should throw ApprovalRequiredError for high-value transactions', async () => {
|
|
224
|
+
const { manager } = createManager({ policy: { requireApprovalAboveUsd: 5 } });
|
|
225
|
+
await manager.createWallet(AGENT, 'solana');
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
manager.sendNative(AGENT, 'solana', 'dest', 500_000_000n, 10, 'transfers'),
|
|
229
|
+
).rejects.toThrow(ApprovalRequiredError);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should mark transaction as failed on broadcast error', async () => {
|
|
233
|
+
const { manager, adapter, store } = createManager();
|
|
234
|
+
await manager.createWallet(AGENT, 'solana');
|
|
235
|
+
|
|
236
|
+
(adapter.broadcast as any).mockRejectedValueOnce(new Error('network timeout'));
|
|
237
|
+
|
|
238
|
+
await expect(
|
|
239
|
+
manager.sendNative(AGENT, 'solana', 'dest', 500_000_000n, 5, 'transfers'),
|
|
240
|
+
).rejects.toThrow('network timeout');
|
|
241
|
+
|
|
242
|
+
// Transaction should be recorded as failed
|
|
243
|
+
const wallet = await store.getWallet(AGENT, 'solana');
|
|
244
|
+
const txs = await store.getTransactions(wallet!.id);
|
|
245
|
+
expect(txs[0].status).toBe('failed');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should record spend in ledger after successful send', async () => {
|
|
249
|
+
const { manager, ledger } = createManager();
|
|
250
|
+
await manager.createWallet(AGENT, 'solana');
|
|
251
|
+
|
|
252
|
+
await manager.sendNative(AGENT, 'solana', 'dest', 500_000_000n, 8, 'api_costs');
|
|
253
|
+
|
|
254
|
+
expect(ledger.entries.length).toBe(2); // daily + monthly
|
|
255
|
+
expect(ledger.entries[0].amountUsd).toBe(8);
|
|
256
|
+
expect(ledger.entries[0].category).toBe('api_costs');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/* ── Transaction history ───────────────────────────────────────── */
|
|
261
|
+
|
|
262
|
+
describe('getTransactionHistory', () => {
|
|
263
|
+
it('should return empty list for new wallet', async () => {
|
|
264
|
+
const { manager } = createManager();
|
|
265
|
+
await manager.createWallet(AGENT, 'solana');
|
|
266
|
+
|
|
267
|
+
const history = await manager.getTransactionHistory(AGENT, 'solana');
|
|
268
|
+
expect(history).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should return transactions after sends', async () => {
|
|
272
|
+
const { manager } = createManager();
|
|
273
|
+
await manager.createWallet(AGENT, 'solana');
|
|
274
|
+
|
|
275
|
+
await manager.sendNative(AGENT, 'solana', 'dest1', 100n, 1, 'transfers');
|
|
276
|
+
await manager.sendNative(AGENT, 'solana', 'dest2', 200n, 2, 'transfers');
|
|
277
|
+
|
|
278
|
+
const history = await manager.getTransactionHistory(AGENT, 'solana');
|
|
279
|
+
expect(history.length).toBe(2);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/* ── Error classes ─────────────────────────────────────────────── */
|
|
284
|
+
|
|
285
|
+
describe('error types', () => {
|
|
286
|
+
it('SpendingPolicyViolation should have correct name', () => {
|
|
287
|
+
const err = new SpendingPolicyViolation('test reason');
|
|
288
|
+
expect(err.name).toBe('SpendingPolicyViolation');
|
|
289
|
+
expect(err.message).toBe('test reason');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('ApprovalRequiredError should expose amount and category', () => {
|
|
293
|
+
const err = new ApprovalRequiredError(25, 'shopping');
|
|
294
|
+
expect(err.name).toBe('ApprovalRequiredError');
|
|
295
|
+
expect(err.amountUsd).toBe(25);
|
|
296
|
+
expect(err.category).toBe('shopping');
|
|
297
|
+
expect(err.message).toContain('$25.00');
|
|
298
|
+
expect(err.message).toContain('shopping');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|