@veridex/agentic-payments 0.1.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/MIGRATION.md +307 -0
- package/README.md +395 -0
- package/dist/index.d.mts +2327 -0
- package/dist/index.d.ts +2327 -0
- package/dist/index.js +5815 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5759 -0
- package/dist/index.mjs.map +1 -0
- package/examples/basic-agent.ts +126 -0
- package/examples/mcp-claude.ts +75 -0
- package/examples/ucp-checkout.ts +92 -0
- package/examples/x402-integration.ts +75 -0
- package/package.json +36 -0
- package/src/AgentWallet.ts +432 -0
- package/src/chains/AptosChainClient.ts +29 -0
- package/src/chains/ChainClient.ts +73 -0
- package/src/chains/ChainClientFactory.ts +113 -0
- package/src/chains/EVMChainClient.ts +39 -0
- package/src/chains/SolanaChainClient.ts +37 -0
- package/src/chains/StarknetChainClient.ts +36 -0
- package/src/chains/SuiChainClient.ts +28 -0
- package/src/index.ts +83 -0
- package/src/mcp/MCPServer.ts +73 -0
- package/src/mcp/schemas.ts +60 -0
- package/src/monitoring/AlertManager.ts +258 -0
- package/src/monitoring/AuditLogger.ts +86 -0
- package/src/monitoring/BalanceCache.ts +44 -0
- package/src/monitoring/ComplianceExporter.ts +52 -0
- package/src/oracle/PythFeeds.ts +60 -0
- package/src/oracle/PythOracle.ts +121 -0
- package/src/performance/ConnectionPool.ts +217 -0
- package/src/performance/NonceManager.ts +91 -0
- package/src/performance/ParallelRouteFinder.ts +438 -0
- package/src/performance/TransactionPoller.ts +201 -0
- package/src/performance/TransactionQueue.ts +565 -0
- package/src/performance/index.ts +46 -0
- package/src/react/hooks.ts +298 -0
- package/src/routing/BridgeOrchestrator.ts +18 -0
- package/src/routing/CrossChainRouter.ts +501 -0
- package/src/routing/DEXAggregator.ts +448 -0
- package/src/routing/FeeEstimator.ts +43 -0
- package/src/session/SessionKeyManager.ts +312 -0
- package/src/session/SessionStorage.ts +80 -0
- package/src/session/SpendingTracker.ts +71 -0
- package/src/types/agent.ts +105 -0
- package/src/types/errors.ts +115 -0
- package/src/types/mcp.ts +22 -0
- package/src/types/ucp.ts +47 -0
- package/src/types/x402.ts +170 -0
- package/src/ucp/CapabilityNegotiator.ts +44 -0
- package/src/ucp/CredentialProvider.ts +73 -0
- package/src/ucp/PaymentTokenizer.ts +169 -0
- package/src/ucp/TransportAdapter.ts +18 -0
- package/src/ucp/UCPClient.ts +143 -0
- package/src/x402/NonceManager.ts +26 -0
- package/src/x402/PaymentParser.ts +225 -0
- package/src/x402/PaymentSigner.ts +305 -0
- package/src/x402/X402Client.ts +364 -0
- package/src/x402/adapters/CronosFacilitatorAdapter.ts +109 -0
- package/tests/alerts.test.ts +208 -0
- package/tests/chains.test.ts +242 -0
- package/tests/integration.test.ts +315 -0
- package/tests/monitoring.test.ts +435 -0
- package/tests/performance.test.ts +303 -0
- package/tests/property.test.ts +186 -0
- package/tests/react-hooks.test.ts +262 -0
- package/tests/session.test.ts +376 -0
- package/tests/ucp.test.ts +253 -0
- package/tests/x402.test.ts +385 -0
- package/tsconfig.json +26 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UCP Module Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for PaymentTokenizer, CredentialProvider, and CapabilityNegotiator.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
8
|
+
import { PaymentTokenizer } from '../src/ucp/PaymentTokenizer';
|
|
9
|
+
import { UCPCredentialProvider } from '../src/ucp/CredentialProvider';
|
|
10
|
+
import { CapabilityNegotiator } from '../src/ucp/CapabilityNegotiator';
|
|
11
|
+
import { StoredSession } from '../src/session/SessionStorage';
|
|
12
|
+
|
|
13
|
+
// Helper to create mock sessions
|
|
14
|
+
function createMockSession(overrides: Partial<{
|
|
15
|
+
keyHash: string;
|
|
16
|
+
dailyLimitUSD: number;
|
|
17
|
+
perTransactionLimitUSD: number;
|
|
18
|
+
expiryTimestamp: number;
|
|
19
|
+
}> = {}): StoredSession {
|
|
20
|
+
const {
|
|
21
|
+
keyHash = '0x' + 'a'.repeat(64),
|
|
22
|
+
dailyLimitUSD = 100,
|
|
23
|
+
perTransactionLimitUSD = 25,
|
|
24
|
+
expiryTimestamp = Date.now() + 3600000,
|
|
25
|
+
} = overrides;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
keyHash,
|
|
29
|
+
encryptedPrivateKey: '0x' + 'b'.repeat(64),
|
|
30
|
+
publicKey: '0x04' + 'c'.repeat(128),
|
|
31
|
+
config: {
|
|
32
|
+
dailyLimitUSD,
|
|
33
|
+
perTransactionLimitUSD,
|
|
34
|
+
expiryTimestamp,
|
|
35
|
+
allowedChains: [30],
|
|
36
|
+
},
|
|
37
|
+
metadata: {
|
|
38
|
+
createdAt: Date.now(),
|
|
39
|
+
lastUsedAt: Date.now(),
|
|
40
|
+
totalSpentUSD: 0,
|
|
41
|
+
dailySpentUSD: 0,
|
|
42
|
+
dailyResetAt: Date.now() + 86400000,
|
|
43
|
+
transactionCount: 0,
|
|
44
|
+
},
|
|
45
|
+
masterKeyHash: '0x' + 'd'.repeat(64),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('PaymentTokenizer', () => {
|
|
50
|
+
let tokenizer: PaymentTokenizer;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
tokenizer = new PaymentTokenizer();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('tokenize', () => {
|
|
57
|
+
it('should generate a valid token from a session', async () => {
|
|
58
|
+
const session = createMockSession();
|
|
59
|
+
const token = await tokenizer.tokenize(session);
|
|
60
|
+
|
|
61
|
+
expect(token).toBeDefined();
|
|
62
|
+
expect(token.token).toBeDefined();
|
|
63
|
+
expect(token.keyHash).toBe(session.keyHash);
|
|
64
|
+
expect(token.limits.dailyLimitUSD).toBe(session.config.dailyLimitUSD);
|
|
65
|
+
expect(token.limits.perTransactionLimitUSD).toBe(session.config.perTransactionLimitUSD);
|
|
66
|
+
expect(token.expiresAt).toBeLessThanOrEqual(session.config.expiryTimestamp);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should respect custom TTL', async () => {
|
|
70
|
+
const session = createMockSession({ expiryTimestamp: Date.now() + 3600000 });
|
|
71
|
+
const customTtl = 5 * 60 * 1000; // 5 minutes
|
|
72
|
+
const token = await tokenizer.tokenize(session, customTtl);
|
|
73
|
+
|
|
74
|
+
const expectedExpiry = Date.now() + customTtl;
|
|
75
|
+
expect(token.expiresAt).toBeLessThanOrEqual(expectedExpiry + 100); // Allow 100ms tolerance
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should not exceed session expiry', async () => {
|
|
79
|
+
const shortExpiry = Date.now() + 60000; // 1 minute
|
|
80
|
+
const session = createMockSession({ expiryTimestamp: shortExpiry });
|
|
81
|
+
const longTtl = 3600000; // 1 hour
|
|
82
|
+
|
|
83
|
+
const token = await tokenizer.tokenize(session, longTtl);
|
|
84
|
+
|
|
85
|
+
expect(token.expiresAt).toBeLessThanOrEqual(shortExpiry);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('validate', () => {
|
|
90
|
+
it('should validate a valid token', async () => {
|
|
91
|
+
const session = createMockSession();
|
|
92
|
+
const token = await tokenizer.tokenize(session);
|
|
93
|
+
|
|
94
|
+
const result = tokenizer.validate(token.token);
|
|
95
|
+
|
|
96
|
+
expect(result.valid).toBe(true);
|
|
97
|
+
expect(result.session).toBeDefined();
|
|
98
|
+
expect(result.session?.keyHash).toBe(session.keyHash);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should reject malformed tokens', () => {
|
|
102
|
+
const result = tokenizer.validate('not-a-valid-token');
|
|
103
|
+
|
|
104
|
+
expect(result.valid).toBe(false);
|
|
105
|
+
expect(result.reason).toBeDefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should reject tokens not in cache', () => {
|
|
109
|
+
// Create a structurally valid token that's not in the cache
|
|
110
|
+
const fakeTokenData = {
|
|
111
|
+
keyHash: '0xfake',
|
|
112
|
+
type: 'VERIDEX_SESSION_TOKEN',
|
|
113
|
+
limits: { dailyLimitUSD: 100, perTransactionLimitUSD: 25 },
|
|
114
|
+
expiresAt: Date.now() + 60000,
|
|
115
|
+
nonce: '0x123',
|
|
116
|
+
};
|
|
117
|
+
const fakeToken = Buffer.from(JSON.stringify(fakeTokenData)).toString('base64url');
|
|
118
|
+
|
|
119
|
+
const result = tokenizer.validate(fakeToken);
|
|
120
|
+
|
|
121
|
+
expect(result.valid).toBe(false);
|
|
122
|
+
expect(result.reason).toContain('not found');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('revoke', () => {
|
|
127
|
+
it('should revoke a token', async () => {
|
|
128
|
+
const session = createMockSession();
|
|
129
|
+
const token = await tokenizer.tokenize(session);
|
|
130
|
+
|
|
131
|
+
// Token should be valid
|
|
132
|
+
expect(tokenizer.validate(token.token).valid).toBe(true);
|
|
133
|
+
|
|
134
|
+
// Revoke
|
|
135
|
+
const revoked = tokenizer.revoke(token.token);
|
|
136
|
+
expect(revoked).toBe(true);
|
|
137
|
+
|
|
138
|
+
// Token should now be invalid
|
|
139
|
+
expect(tokenizer.validate(token.token).valid).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return false for non-existent token', () => {
|
|
143
|
+
const revoked = tokenizer.revoke('non-existent-token');
|
|
144
|
+
expect(revoked).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('revokeAllForSession', () => {
|
|
149
|
+
it('should revoke all tokens for a session', async () => {
|
|
150
|
+
const session = createMockSession({ keyHash: '0xsession1' });
|
|
151
|
+
|
|
152
|
+
// Create multiple tokens
|
|
153
|
+
const token1 = await tokenizer.tokenize(session);
|
|
154
|
+
const token2 = await tokenizer.tokenize(session);
|
|
155
|
+
|
|
156
|
+
// All should be valid
|
|
157
|
+
expect(tokenizer.validate(token1.token).valid).toBe(true);
|
|
158
|
+
expect(tokenizer.validate(token2.token).valid).toBe(true);
|
|
159
|
+
|
|
160
|
+
// Revoke all for session
|
|
161
|
+
const count = tokenizer.revokeAllForSession('0xsession1');
|
|
162
|
+
expect(count).toBe(2);
|
|
163
|
+
|
|
164
|
+
// All should now be invalid
|
|
165
|
+
expect(tokenizer.validate(token1.token).valid).toBe(false);
|
|
166
|
+
expect(tokenizer.validate(token2.token).valid).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('refresh', () => {
|
|
171
|
+
it('should refresh a valid token', async () => {
|
|
172
|
+
const session = createMockSession();
|
|
173
|
+
const oldToken = await tokenizer.tokenize(session);
|
|
174
|
+
|
|
175
|
+
const newToken = await tokenizer.refresh(oldToken.token, session);
|
|
176
|
+
|
|
177
|
+
expect(newToken).not.toBeNull();
|
|
178
|
+
expect(newToken?.token).not.toBe(oldToken.token);
|
|
179
|
+
expect(newToken?.keyHash).toBe(session.keyHash);
|
|
180
|
+
|
|
181
|
+
// Old token should be invalid
|
|
182
|
+
expect(tokenizer.validate(oldToken.token).valid).toBe(false);
|
|
183
|
+
|
|
184
|
+
// New token should be valid
|
|
185
|
+
expect(tokenizer.validate(newToken!.token).valid).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should return null for invalid token', async () => {
|
|
189
|
+
const session = createMockSession();
|
|
190
|
+
const result = await tokenizer.refresh('invalid-token', session);
|
|
191
|
+
expect(result).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('CapabilityNegotiator', () => {
|
|
197
|
+
let negotiator: CapabilityNegotiator;
|
|
198
|
+
|
|
199
|
+
beforeEach(() => {
|
|
200
|
+
negotiator = new CapabilityNegotiator();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should return intersection of requested and supported capabilities', () => {
|
|
204
|
+
const requested = ['checkout', 'identity_linking', 'orders', 'unknown_capability'];
|
|
205
|
+
const result = negotiator.negotiate(requested);
|
|
206
|
+
|
|
207
|
+
expect(result.agreed).toContain('checkout');
|
|
208
|
+
expect(result.agreed).not.toContain('unknown_capability');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should handle empty requested capabilities', () => {
|
|
212
|
+
const result = negotiator.negotiate([]);
|
|
213
|
+
expect(result.agreed).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should return all supported when requesting all', () => {
|
|
217
|
+
const requested = ['checkout', 'identity_linking', 'orders'];
|
|
218
|
+
const result = negotiator.negotiate(requested);
|
|
219
|
+
|
|
220
|
+
expect(result.agreed.length).toBeGreaterThan(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('UCPCredentialProvider', () => {
|
|
225
|
+
it('should generate a UCP profile', () => {
|
|
226
|
+
// UCPCredentialProvider requires a SessionKeyManager
|
|
227
|
+
// For this test, we'll mock it
|
|
228
|
+
const mockSessionManager = {
|
|
229
|
+
checkLimits: vi.fn().mockReturnValue({ allowed: true }),
|
|
230
|
+
recordSpending: vi.fn(),
|
|
231
|
+
} as any;
|
|
232
|
+
|
|
233
|
+
const provider = new UCPCredentialProvider(mockSessionManager);
|
|
234
|
+
const profile = provider.getProfile();
|
|
235
|
+
|
|
236
|
+
expect(profile).toBeDefined();
|
|
237
|
+
expect(profile.version).toBeDefined();
|
|
238
|
+
expect(profile.capabilities).toBeDefined();
|
|
239
|
+
expect(Array.isArray(profile.capabilities)).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should include expected capabilities in profile', () => {
|
|
243
|
+
const mockSessionManager = {
|
|
244
|
+
checkLimits: vi.fn().mockReturnValue({ allowed: true }),
|
|
245
|
+
recordSpending: vi.fn(),
|
|
246
|
+
} as any;
|
|
247
|
+
|
|
248
|
+
const provider = new UCPCredentialProvider(mockSessionManager);
|
|
249
|
+
const profile = provider.getProfile();
|
|
250
|
+
|
|
251
|
+
expect(profile.capabilities).toContain('checkout');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 Protocol Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for x402 payment parsing, signing, and flow handling.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
8
|
+
import * as fc from 'fast-check';
|
|
9
|
+
import { PaymentParser } from '../src/x402/PaymentParser';
|
|
10
|
+
import { PaymentSigner } from '../src/x402/PaymentSigner';
|
|
11
|
+
import { StoredSession } from '../src/session/SessionStorage';
|
|
12
|
+
import { ethers } from 'ethers';
|
|
13
|
+
|
|
14
|
+
describe('PaymentParser', () => {
|
|
15
|
+
let parser: PaymentParser;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
parser = new PaymentParser();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('Header Parsing', () => {
|
|
22
|
+
it('should parse valid PAYMENT-REQUIRED header', () => {
|
|
23
|
+
const requirement = {
|
|
24
|
+
paymentRequirements: [{
|
|
25
|
+
scheme: 'exact',
|
|
26
|
+
network: 'base-mainnet',
|
|
27
|
+
maxAmountRequired: '1000000', // 1 USDC
|
|
28
|
+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
29
|
+
payTo: '0x0000000000000000000000000000000000000001',
|
|
30
|
+
}],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const encoded = Buffer.from(JSON.stringify(requirement)).toString('base64');
|
|
34
|
+
const headers = { 'payment-required': encoded };
|
|
35
|
+
|
|
36
|
+
const result = parser.parseHeaders(headers);
|
|
37
|
+
|
|
38
|
+
expect(result).toBeDefined();
|
|
39
|
+
expect(result!.amount).toBe('1000000');
|
|
40
|
+
expect(result!.token).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
|
|
41
|
+
expect(result!.recipient).toBe('0x0000000000000000000000000000000000000001');
|
|
42
|
+
expect(result!.chain).toBe(30); // Base
|
|
43
|
+
expect(result!.scheme).toBe('exact');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle uppercase header names', () => {
|
|
47
|
+
const requirement = {
|
|
48
|
+
paymentRequirements: [{
|
|
49
|
+
scheme: 'exact',
|
|
50
|
+
network: 'base',
|
|
51
|
+
maxAmountRequired: '500000',
|
|
52
|
+
asset: 'USDC',
|
|
53
|
+
payTo: '0x0000000000000000000000000000000000000123',
|
|
54
|
+
}],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const encoded = Buffer.from(JSON.stringify(requirement)).toString('base64');
|
|
58
|
+
const headers = { 'PAYMENT-REQUIRED': encoded };
|
|
59
|
+
|
|
60
|
+
const result = parser.parseHeaders(headers);
|
|
61
|
+
expect(result).toBeDefined();
|
|
62
|
+
expect(result!.amount).toBe('500000');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return null for missing header', () => {
|
|
66
|
+
const result = parser.parseHeaders({});
|
|
67
|
+
expect(result).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should return null for invalid base64', () => {
|
|
71
|
+
const headers = { 'payment-required': 'not-valid-base64!!!' };
|
|
72
|
+
const result = parser.parseHeaders(headers);
|
|
73
|
+
expect(result).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return null for invalid JSON', () => {
|
|
77
|
+
const encoded = Buffer.from('not json').toString('base64');
|
|
78
|
+
const headers = { 'payment-required': encoded };
|
|
79
|
+
const result = parser.parseHeaders(headers);
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('Network Mapping', () => {
|
|
85
|
+
it('should map ethereum-mainnet to chain ID 2', () => {
|
|
86
|
+
const requirement = createPaymentRequirement('ethereum-mainnet');
|
|
87
|
+
const encoded = Buffer.from(JSON.stringify(requirement)).toString('base64');
|
|
88
|
+
const result = parser.parseHeaders({ 'payment-required': encoded });
|
|
89
|
+
|
|
90
|
+
expect(result!.chain).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should map base-mainnet to chain ID 30', () => {
|
|
94
|
+
const requirement = createPaymentRequirement('base-mainnet');
|
|
95
|
+
const encoded = Buffer.from(JSON.stringify(requirement)).toString('base64');
|
|
96
|
+
const result = parser.parseHeaders({ 'payment-required': encoded });
|
|
97
|
+
|
|
98
|
+
expect(result!.chain).toBe(30);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should map solana-mainnet to chain ID 1', () => {
|
|
102
|
+
const requirement = createPaymentRequirement('solana-mainnet');
|
|
103
|
+
const encoded = Buffer.from(JSON.stringify(requirement)).toString('base64');
|
|
104
|
+
const result = parser.parseHeaders({ 'payment-required': encoded });
|
|
105
|
+
|
|
106
|
+
expect(result!.chain).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should handle numeric network IDs', () => {
|
|
110
|
+
const requirement = createPaymentRequirement('8453'); // Base EVM chain ID
|
|
111
|
+
const encoded = Buffer.from(JSON.stringify(requirement)).toString('base64');
|
|
112
|
+
const result = parser.parseHeaders({ 'payment-required': encoded });
|
|
113
|
+
|
|
114
|
+
expect(result!.chain).toBe(30); // Should map to Wormhole Base ID
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('Amount Parsing', () => {
|
|
119
|
+
it('should parse integer amounts', () => {
|
|
120
|
+
const amount = parser.parseAmount('1000000', 6);
|
|
121
|
+
expect(amount).toBe(1000000n);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should parse decimal amounts', () => {
|
|
125
|
+
const amount = parser.parseAmount('1.5', 6);
|
|
126
|
+
expect(amount).toBe(1500000n);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle decimals correctly for 18 decimal tokens', () => {
|
|
130
|
+
const amount = parser.parseAmount('1.0', 18);
|
|
131
|
+
expect(amount).toBe(1000000000000000000n);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('Amount Formatting', () => {
|
|
136
|
+
it('should format whole numbers', () => {
|
|
137
|
+
const formatted = parser.formatAmount(1000000n, 6);
|
|
138
|
+
expect(formatted).toBe('1');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should format decimal amounts', () => {
|
|
142
|
+
const formatted = parser.formatAmount(1500000n, 6);
|
|
143
|
+
expect(formatted).toBe('1.5');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should handle small amounts', () => {
|
|
147
|
+
const formatted = parser.formatAmount(1n, 6);
|
|
148
|
+
expect(formatted).toBe('0.000001');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('PaymentSigner', () => {
|
|
154
|
+
let signer: PaymentSigner;
|
|
155
|
+
let testSession: StoredSession;
|
|
156
|
+
let testWallet: ethers.Wallet;
|
|
157
|
+
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
signer = new PaymentSigner();
|
|
160
|
+
testWallet = ethers.Wallet.createRandom();
|
|
161
|
+
testSession = {
|
|
162
|
+
keyHash: '0x' + 'a'.repeat(64),
|
|
163
|
+
encryptedPrivateKey: testWallet.privateKey, // Unencrypted for tests
|
|
164
|
+
publicKey: testWallet.signingKey.publicKey,
|
|
165
|
+
config: {
|
|
166
|
+
dailyLimitUSD: 100,
|
|
167
|
+
perTransactionLimitUSD: 25,
|
|
168
|
+
expiryTimestamp: Date.now() + 3600000,
|
|
169
|
+
allowedChains: [30],
|
|
170
|
+
},
|
|
171
|
+
metadata: {
|
|
172
|
+
createdAt: Date.now(),
|
|
173
|
+
lastUsedAt: Date.now(),
|
|
174
|
+
totalSpentUSD: 0,
|
|
175
|
+
dailySpentUSD: 0,
|
|
176
|
+
dailyResetAt: Date.now() + 86400000,
|
|
177
|
+
transactionCount: 0,
|
|
178
|
+
},
|
|
179
|
+
masterKeyHash: '0x' + 'b'.repeat(64),
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('Signature Generation', () => {
|
|
184
|
+
it('should generate a valid EIP-712 signature', async () => {
|
|
185
|
+
const request = {
|
|
186
|
+
amount: '1000000',
|
|
187
|
+
token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
188
|
+
recipient: '0x0000000000000000000000000000000000000001',
|
|
189
|
+
chain: 30,
|
|
190
|
+
network: 'base-mainnet',
|
|
191
|
+
scheme: 'exact' as const,
|
|
192
|
+
original: {} as any,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const result = await signer.sign(request, testSession);
|
|
196
|
+
|
|
197
|
+
expect(result.signature).toBeDefined();
|
|
198
|
+
expect(result.signature.length).toBe(132); // 0x + 65 bytes hex
|
|
199
|
+
expect(result.nonce).toBeDefined();
|
|
200
|
+
expect(result.deadline).toBeGreaterThan(Date.now() / 1000);
|
|
201
|
+
expect(result.paymentPayload).toBeDefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should include correct deadline in signature', async () => {
|
|
205
|
+
const now = Math.floor(Date.now() / 1000);
|
|
206
|
+
const request = {
|
|
207
|
+
amount: '1000000',
|
|
208
|
+
token: 'USDC',
|
|
209
|
+
recipient: '0x0000000000000000000000000000000000000123',
|
|
210
|
+
chain: 30,
|
|
211
|
+
network: 'base',
|
|
212
|
+
scheme: 'exact' as const,
|
|
213
|
+
original: {} as any,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const result = await signer.sign(request, testSession);
|
|
217
|
+
|
|
218
|
+
// Default deadline should be 5 minutes from now
|
|
219
|
+
expect(result.deadline).toBeGreaterThanOrEqual(now + 280);
|
|
220
|
+
expect(result.deadline).toBeLessThanOrEqual(now + 320);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should use provided deadline if specified', async () => {
|
|
224
|
+
const customDeadline = Math.floor(Date.now() / 1000) + 600;
|
|
225
|
+
const request = {
|
|
226
|
+
amount: '1000000',
|
|
227
|
+
token: 'USDC',
|
|
228
|
+
recipient: '0x0000000000000000000000000000000000000123',
|
|
229
|
+
chain: 30,
|
|
230
|
+
network: 'base',
|
|
231
|
+
scheme: 'exact' as const,
|
|
232
|
+
deadline: customDeadline,
|
|
233
|
+
original: {} as any,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const result = await signer.sign(request, testSession);
|
|
237
|
+
|
|
238
|
+
expect(result.deadline).toBe(customDeadline);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should generate unique nonces', async () => {
|
|
242
|
+
const request = {
|
|
243
|
+
amount: '1000000',
|
|
244
|
+
token: 'USDC',
|
|
245
|
+
recipient: '0x0000000000000000000000000000000000000123',
|
|
246
|
+
chain: 30,
|
|
247
|
+
network: 'base',
|
|
248
|
+
scheme: 'exact' as const,
|
|
249
|
+
original: {} as any,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const result1 = await signer.sign(request, testSession);
|
|
253
|
+
const result2 = await signer.sign(request, testSession);
|
|
254
|
+
|
|
255
|
+
expect(result1.nonce).not.toBe(result2.nonce);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('Signature Verification', () => {
|
|
260
|
+
it('should verify its own signatures', async () => {
|
|
261
|
+
const request = {
|
|
262
|
+
amount: '1000000',
|
|
263
|
+
token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
264
|
+
recipient: '0x0000000000000000000000000000000000000001',
|
|
265
|
+
chain: 30,
|
|
266
|
+
network: 'base',
|
|
267
|
+
scheme: 'exact' as const,
|
|
268
|
+
original: {} as any,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const result = await signer.sign(request, testSession);
|
|
272
|
+
|
|
273
|
+
// Decode the payload to get the authorization
|
|
274
|
+
const payload = JSON.parse(Buffer.from(result.paymentPayload, 'base64').toString());
|
|
275
|
+
const authorization = payload.payload.authorization;
|
|
276
|
+
|
|
277
|
+
const isValid = signer.verifySignature(
|
|
278
|
+
result.signature,
|
|
279
|
+
authorization,
|
|
280
|
+
testWallet.address,
|
|
281
|
+
8453, // Base EVM chain ID
|
|
282
|
+
'0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base USDC address
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(isValid).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should reject signatures from different signers', async () => {
|
|
289
|
+
const request = {
|
|
290
|
+
amount: '1000000',
|
|
291
|
+
token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base USDC
|
|
292
|
+
recipient: '0x0000000000000000000000000000000000000123',
|
|
293
|
+
chain: 30,
|
|
294
|
+
network: 'base',
|
|
295
|
+
scheme: 'exact' as const,
|
|
296
|
+
original: {} as any,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const result = await signer.sign(request, testSession);
|
|
300
|
+
const payload = JSON.parse(Buffer.from(result.paymentPayload, 'base64').toString());
|
|
301
|
+
const authorization = payload.payload.authorization;
|
|
302
|
+
|
|
303
|
+
const wrongAddress = '0x0000000000000000000000000000000000000000';
|
|
304
|
+
const isValid = signer.verifySignature(
|
|
305
|
+
result.signature,
|
|
306
|
+
authorization,
|
|
307
|
+
wrongAddress,
|
|
308
|
+
8453,
|
|
309
|
+
'0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base USDC address
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(isValid).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Property-based tests
|
|
318
|
+
describe('x402 - Property Tests', () => {
|
|
319
|
+
describe('Property 5: Nonce Sequencing Prevents Replay Attacks', () => {
|
|
320
|
+
it('should never generate duplicate nonces in sequence', () => {
|
|
321
|
+
fc.assert(
|
|
322
|
+
fc.asyncProperty(
|
|
323
|
+
fc.integer({ min: 2, max: 50 }), // number of signatures to generate
|
|
324
|
+
async (count) => {
|
|
325
|
+
const signer = new PaymentSigner();
|
|
326
|
+
const wallet = ethers.Wallet.createRandom();
|
|
327
|
+
const session: StoredSession = {
|
|
328
|
+
keyHash: '0x' + 'a'.repeat(64),
|
|
329
|
+
encryptedPrivateKey: wallet.privateKey,
|
|
330
|
+
publicKey: wallet.signingKey.publicKey,
|
|
331
|
+
config: {
|
|
332
|
+
dailyLimitUSD: 100,
|
|
333
|
+
perTransactionLimitUSD: 25,
|
|
334
|
+
expiryTimestamp: Date.now() + 3600000,
|
|
335
|
+
allowedChains: [30],
|
|
336
|
+
},
|
|
337
|
+
metadata: {
|
|
338
|
+
createdAt: Date.now(),
|
|
339
|
+
lastUsedAt: Date.now(),
|
|
340
|
+
totalSpentUSD: 0,
|
|
341
|
+
dailySpentUSD: 0,
|
|
342
|
+
dailyResetAt: Date.now() + 86400000,
|
|
343
|
+
transactionCount: 0,
|
|
344
|
+
},
|
|
345
|
+
masterKeyHash: '0x' + 'b'.repeat(64),
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const request = {
|
|
349
|
+
amount: '1000000',
|
|
350
|
+
token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base USDC
|
|
351
|
+
recipient: '0x0000000000000000000000000000000000000123',
|
|
352
|
+
chain: 30,
|
|
353
|
+
network: 'base',
|
|
354
|
+
scheme: 'exact' as const,
|
|
355
|
+
original: {} as any,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const nonces = new Set<string>();
|
|
359
|
+
for (let i = 0; i < count; i++) {
|
|
360
|
+
const result = await signer.sign(request, session);
|
|
361
|
+
expect(nonces.has(result.nonce)).toBe(false);
|
|
362
|
+
nonces.add(result.nonce);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
expect(nonces.size).toBe(count);
|
|
366
|
+
}
|
|
367
|
+
),
|
|
368
|
+
{ numRuns: 20 }
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Helper functions
|
|
375
|
+
function createPaymentRequirement(network: string) {
|
|
376
|
+
return {
|
|
377
|
+
paymentRequirements: [{
|
|
378
|
+
scheme: 'exact',
|
|
379
|
+
network,
|
|
380
|
+
maxAmountRequired: '1000000',
|
|
381
|
+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
382
|
+
payTo: '0x0000000000000000000000000000000000000001',
|
|
383
|
+
}],
|
|
384
|
+
};
|
|
385
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": [
|
|
6
|
+
"ES2020",
|
|
7
|
+
"DOM"
|
|
8
|
+
],
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"outDir": "./dist"
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src/**/*"
|
|
20
|
+
],
|
|
21
|
+
"exclude": [
|
|
22
|
+
"node_modules",
|
|
23
|
+
"dist",
|
|
24
|
+
"**/*.test.ts"
|
|
25
|
+
]
|
|
26
|
+
}
|
package/tsup.config.ts
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
reporter: ['text', 'json', 'html'],
|
|
11
|
+
include: ['src/**/*.ts'],
|
|
12
|
+
exclude: ['src/**/*.test.ts', 'src/types/**'],
|
|
13
|
+
},
|
|
14
|
+
testTimeout: 30000,
|
|
15
|
+
},
|
|
16
|
+
});
|