ap2-payment-handler 1.0.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/README.md +133 -0
- package/dist/__tests__/handler.test.d.ts +2 -0
- package/dist/__tests__/handler.test.d.ts.map +1 -0
- package/dist/__tests__/handler.test.js +251 -0
- package/dist/handler.d.ts +14 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +87 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/validation.d.ts +6 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +61 -0
- package/dist/x402.d.ts +31 -0
- package/dist/x402.d.ts.map +1 -0
- package/dist/x402.js +74 -0
- package/package.json +35 -0
- package/src/__tests__/handler.test.ts +278 -0
- package/src/handler.ts +107 -0
- package/src/index.ts +4 -0
- package/src/types.ts +53 -0
- package/src/validation.ts +61 -0
- package/src/x402.ts +86 -0
- package/tsconfig.json +17 -0
package/dist/x402.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AP2PaymentResponse } from './types';
|
|
2
|
+
export declare class X402Bridge {
|
|
3
|
+
/**
|
|
4
|
+
* Parse an HTTP 402 Payment Required response headers to extract payment details.
|
|
5
|
+
*/
|
|
6
|
+
parsePaymentRequired(headers: Record<string, string>): Promise<{
|
|
7
|
+
amount: string;
|
|
8
|
+
currency: string;
|
|
9
|
+
address: string;
|
|
10
|
+
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Build a non-custodial payment proof for a given payment request.
|
|
13
|
+
*/
|
|
14
|
+
buildPaymentProof(params: {
|
|
15
|
+
amount: string;
|
|
16
|
+
currency: string;
|
|
17
|
+
address: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
proof: string;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Handle an HTTP response. Returns an AP2PaymentResponse if it's a 402, null otherwise.
|
|
25
|
+
*/
|
|
26
|
+
handleResponse(response: {
|
|
27
|
+
status: number;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
}): Promise<AP2PaymentResponse | null>;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=x402.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"x402.d.ts","sourceRoot":"","sources":["../src/x402.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAM7C,qBAAa,UAAU;IACrB;;OAEG;IACG,oBAAoB,CACxB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IA0BjE;;OAEG;IACG,iBAAiB,CAAC,MAAM,EAAE;QAC9B,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAejD;;OAEG;IACG,cAAc,CAAC,QAAQ,EAAE;QAC7B,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACjC,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;CAkBvC"}
|
package/dist/x402.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.X402Bridge = void 0;
|
|
4
|
+
function generateId() {
|
|
5
|
+
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
6
|
+
}
|
|
7
|
+
class X402Bridge {
|
|
8
|
+
/**
|
|
9
|
+
* Parse an HTTP 402 Payment Required response headers to extract payment details.
|
|
10
|
+
*/
|
|
11
|
+
async parsePaymentRequired(headers) {
|
|
12
|
+
const paymentHeader = headers['x-payment-required'] || headers['X-Payment-Required'];
|
|
13
|
+
if (paymentHeader) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(paymentHeader);
|
|
16
|
+
return {
|
|
17
|
+
amount: parsed.amount || '0',
|
|
18
|
+
currency: parsed.currency || 'USDC',
|
|
19
|
+
address: parsed.address || '',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// fall through to individual headers
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const amount = headers['x-payment-amount'] || headers['X-Payment-Amount'] || '0';
|
|
27
|
+
const currency = headers['x-payment-currency'] || headers['X-Payment-Currency'] || 'USDC';
|
|
28
|
+
const address = headers['x-payment-address'] || headers['X-Payment-Address'] || '';
|
|
29
|
+
if (!address) {
|
|
30
|
+
throw new Error('Missing payment address in 402 response headers');
|
|
31
|
+
}
|
|
32
|
+
return { amount, currency, address };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build a non-custodial payment proof for a given payment request.
|
|
36
|
+
*/
|
|
37
|
+
async buildPaymentProof(params) {
|
|
38
|
+
const timestamp = Date.now();
|
|
39
|
+
const proofData = {
|
|
40
|
+
agentId: params.agentId,
|
|
41
|
+
amount: params.amount,
|
|
42
|
+
currency: params.currency,
|
|
43
|
+
address: params.address,
|
|
44
|
+
timestamp,
|
|
45
|
+
nonce: generateId(),
|
|
46
|
+
};
|
|
47
|
+
// In production this would be EIP-712 signed. Here we encode it.
|
|
48
|
+
const proof = Buffer.from(JSON.stringify(proofData)).toString('base64');
|
|
49
|
+
return { proof, timestamp };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Handle an HTTP response. Returns an AP2PaymentResponse if it's a 402, null otherwise.
|
|
53
|
+
*/
|
|
54
|
+
async handleResponse(response) {
|
|
55
|
+
if (response.status !== 402) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const paymentDetails = await this.parsePaymentRequired(response.headers);
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
error: `Payment required: ${paymentDetails.amount} ${paymentDetails.currency} to ${paymentDetails.address}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return {
|
|
67
|
+
success: false,
|
|
68
|
+
error: `402 Payment Required: ${err.message}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
exports.X402Bridge = X402Bridge;
|
|
74
|
+
//# sourceMappingURL=x402.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ap2-payment-handler",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Non-custodial crypto payment handler for the AP2 Agent Payment Protocol. Supports x402, USDC/Base, EIP-712 signing. Zero key escrow.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "jest --testEnvironment=node",
|
|
10
|
+
"prepublishOnly": "npm run build && npm test"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["ap2", "agent-payments", "x402", "crypto", "non-custodial", "agentic-commerce", "ai-agents"],
|
|
13
|
+
"author": "AI Agent Economy <max@ai-agent-economy.com>",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/up2itnow0822/ap2-payment-handler.git"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"zod": "^3.22.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.0.0",
|
|
24
|
+
"@types/node": "^20.0.0",
|
|
25
|
+
"jest": "^29.0.0",
|
|
26
|
+
"ts-jest": "^29.0.0",
|
|
27
|
+
"@types/jest": "^29.0.0"
|
|
28
|
+
},
|
|
29
|
+
"jest": {
|
|
30
|
+
"preset": "ts-jest",
|
|
31
|
+
"testEnvironment": "node",
|
|
32
|
+
"testMatch": ["**/src/**/*.test.ts"],
|
|
33
|
+
"testPathIgnorePatterns": ["/node_modules/", "/dist/"]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { AP2PaymentHandler } from '../handler';
|
|
2
|
+
import { X402Bridge } from '../x402';
|
|
3
|
+
import { AP2ValidationError, validateMandate } from '../validation';
|
|
4
|
+
import { IntentMandate, CartMandate } from '../types';
|
|
5
|
+
|
|
6
|
+
const futureTime = Date.now() + 60_000; // 1 minute from now
|
|
7
|
+
const pastTime = Date.now() - 60_000; // 1 minute ago
|
|
8
|
+
|
|
9
|
+
function makeIntentMandate(overrides: Partial<IntentMandate> = {}): IntentMandate {
|
|
10
|
+
return {
|
|
11
|
+
type: 'intent',
|
|
12
|
+
agentId: 'agent-123',
|
|
13
|
+
merchantId: 'merchant-456',
|
|
14
|
+
maxAmount: 10.0,
|
|
15
|
+
currency: 'USDC',
|
|
16
|
+
ttl: futureTime,
|
|
17
|
+
isAgentInitiated: true,
|
|
18
|
+
isNonCustodial: true,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeCartMandate(overrides: Partial<CartMandate> = {}): CartMandate {
|
|
24
|
+
return {
|
|
25
|
+
type: 'cart',
|
|
26
|
+
agentId: 'agent-123',
|
|
27
|
+
lineItems: [
|
|
28
|
+
{ name: 'Widget', price: 5.0, qty: 2 },
|
|
29
|
+
],
|
|
30
|
+
total: 10.0,
|
|
31
|
+
currency: 'USDC',
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('AP2PaymentHandler', () => {
|
|
37
|
+
let handler: AP2PaymentHandler;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
handler = new AP2PaymentHandler({ supportedMethods: ['usdc_base', 'x402'] });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 1. IntentMandate creation with correct flags
|
|
44
|
+
test('createIntentMandate sets isAgentInitiated and isNonCustodial', () => {
|
|
45
|
+
const mandate = handler.createIntentMandate({
|
|
46
|
+
agentId: 'a1',
|
|
47
|
+
merchantId: 'm1',
|
|
48
|
+
maxAmount: 5,
|
|
49
|
+
currency: 'USDC',
|
|
50
|
+
ttl: futureTime,
|
|
51
|
+
});
|
|
52
|
+
expect(mandate.isAgentInitiated).toBe(true);
|
|
53
|
+
expect(mandate.isNonCustodial).toBe(true);
|
|
54
|
+
expect(mandate.type).toBe('intent');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// 2. CartMandate total validation (correct sum)
|
|
58
|
+
test('validateMandate accepts CartMandate with correct total', () => {
|
|
59
|
+
const cart = makeCartMandate();
|
|
60
|
+
expect(() => validateMandate(cart)).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 3. CartMandate total validation (wrong sum — should throw)
|
|
64
|
+
test('validateMandate rejects CartMandate with wrong total', () => {
|
|
65
|
+
const cart = makeCartMandate({ total: 99.0 });
|
|
66
|
+
expect(() => validateMandate(cart)).toThrow(AP2ValidationError);
|
|
67
|
+
expect(() => validateMandate(cart)).toThrow(/mismatch/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// 4. IntentMandate TTL validation (future TTL — valid)
|
|
71
|
+
test('validateMandate accepts IntentMandate with future TTL', () => {
|
|
72
|
+
const intent = makeIntentMandate({ ttl: futureTime });
|
|
73
|
+
expect(() => validateMandate(intent)).not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 5. IntentMandate TTL validation (past TTL — invalid)
|
|
77
|
+
test('validateMandate rejects IntentMandate with expired TTL', () => {
|
|
78
|
+
const intent = makeIntentMandate({ ttl: pastTime });
|
|
79
|
+
expect(() => validateMandate(intent)).toThrow(AP2ValidationError);
|
|
80
|
+
expect(() => validateMandate(intent)).toThrow(/expired/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// 6. Handler with usdc_base method
|
|
84
|
+
test('handle succeeds with usdc_base method', async () => {
|
|
85
|
+
const response = await handler.handle({
|
|
86
|
+
mandate: makeIntentMandate(),
|
|
87
|
+
preferredMethod: 'usdc_base',
|
|
88
|
+
});
|
|
89
|
+
expect(response.success).toBe(true);
|
|
90
|
+
expect(response.paymentMandate?.method).toBe('usdc_base');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 7. Handler with x402 method
|
|
94
|
+
test('handle succeeds with x402 method', async () => {
|
|
95
|
+
const response = await handler.handle({
|
|
96
|
+
mandate: makeIntentMandate(),
|
|
97
|
+
preferredMethod: 'x402',
|
|
98
|
+
});
|
|
99
|
+
expect(response.success).toBe(true);
|
|
100
|
+
expect(response.paymentMandate?.method).toBe('x402');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// 8. Handler with unsupported method falls back
|
|
104
|
+
test('handle falls back to first supported method when preferred is unsupported', async () => {
|
|
105
|
+
const response = await handler.handle({
|
|
106
|
+
mandate: makeIntentMandate(),
|
|
107
|
+
preferredMethod: 'usdc_arbitrum',
|
|
108
|
+
});
|
|
109
|
+
expect(response.success).toBe(true);
|
|
110
|
+
expect(response.paymentMandate?.method).toBe('usdc_base');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// 9. Handler returns success with transactionId
|
|
114
|
+
test('handle returns transactionId on success', async () => {
|
|
115
|
+
const response = await handler.handle({
|
|
116
|
+
mandate: makeIntentMandate(),
|
|
117
|
+
preferredMethod: 'usdc_base',
|
|
118
|
+
});
|
|
119
|
+
expect(response.success).toBe(true);
|
|
120
|
+
expect(response.transactionId).toBeDefined();
|
|
121
|
+
expect(typeof response.transactionId).toBe('string');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// 10. Handler audit trail includes entries
|
|
125
|
+
test('handle returns paymentMandate with non-empty auditTrail', async () => {
|
|
126
|
+
const response = await handler.handle({
|
|
127
|
+
mandate: makeIntentMandate(),
|
|
128
|
+
preferredMethod: 'usdc_base',
|
|
129
|
+
});
|
|
130
|
+
expect(response.paymentMandate?.auditTrail.length).toBeGreaterThan(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 11. PaymentMandate has isNonCustodial flag
|
|
134
|
+
test('paymentMandate has isNonCustodial set to true', async () => {
|
|
135
|
+
const response = await handler.handle({
|
|
136
|
+
mandate: makeIntentMandate(),
|
|
137
|
+
preferredMethod: 'usdc_base',
|
|
138
|
+
});
|
|
139
|
+
expect(response.paymentMandate?.isNonCustodial).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// 16. Validation rejects missing agentId
|
|
143
|
+
test('validateMandate rejects missing agentId', () => {
|
|
144
|
+
const intent = makeIntentMandate({ agentId: '' });
|
|
145
|
+
expect(() => validateMandate(intent)).toThrow(AP2ValidationError);
|
|
146
|
+
expect(() => validateMandate(intent)).toThrow(/agentId/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 17. Validation rejects negative amount
|
|
150
|
+
test('validateMandate rejects negative maxAmount on IntentMandate', () => {
|
|
151
|
+
const intent = makeIntentMandate({ maxAmount: -5 });
|
|
152
|
+
expect(() => validateMandate(intent)).toThrow(AP2ValidationError);
|
|
153
|
+
expect(() => validateMandate(intent)).toThrow(/positive/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// 18. createIntentMandate sets correct defaults
|
|
157
|
+
test('createIntentMandate preserves all provided params', () => {
|
|
158
|
+
const mandate = handler.createIntentMandate({
|
|
159
|
+
agentId: 'myAgent',
|
|
160
|
+
merchantId: 'myMerchant',
|
|
161
|
+
maxAmount: 25,
|
|
162
|
+
currency: 'USDC',
|
|
163
|
+
ttl: futureTime,
|
|
164
|
+
});
|
|
165
|
+
expect(mandate.agentId).toBe('myAgent');
|
|
166
|
+
expect(mandate.merchantId).toBe('myMerchant');
|
|
167
|
+
expect(mandate.maxAmount).toBe(25);
|
|
168
|
+
expect(mandate.currency).toBe('USDC');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// 19. createCartMandate with multiple items
|
|
172
|
+
test('createCartMandate with multiple line items', () => {
|
|
173
|
+
const mandate = handler.createCartMandate({
|
|
174
|
+
agentId: 'agent-1',
|
|
175
|
+
lineItems: [
|
|
176
|
+
{ name: 'Item A', price: 3, qty: 2 },
|
|
177
|
+
{ name: 'Item B', price: 4, qty: 1 },
|
|
178
|
+
],
|
|
179
|
+
total: 10,
|
|
180
|
+
currency: 'USDC',
|
|
181
|
+
});
|
|
182
|
+
expect(mandate.type).toBe('cart');
|
|
183
|
+
expect(mandate.lineItems.length).toBe(2);
|
|
184
|
+
expect(mandate.total).toBe(10);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 20. getSupportedMethods returns config
|
|
188
|
+
test('getSupportedMethods returns configured methods', () => {
|
|
189
|
+
const methods = handler.getSupportedMethods();
|
|
190
|
+
expect(methods).toContain('usdc_base');
|
|
191
|
+
expect(methods).toContain('x402');
|
|
192
|
+
expect(methods.length).toBe(2);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('X402Bridge', () => {
|
|
197
|
+
let bridge: X402Bridge;
|
|
198
|
+
|
|
199
|
+
beforeEach(() => {
|
|
200
|
+
bridge = new X402Bridge();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// 12. X402Bridge parsePaymentRequired
|
|
204
|
+
test('parsePaymentRequired extracts payment details from headers', async () => {
|
|
205
|
+
const headers = {
|
|
206
|
+
'x-payment-amount': '5.00',
|
|
207
|
+
'x-payment-currency': 'USDC',
|
|
208
|
+
'x-payment-address': '0xabc123',
|
|
209
|
+
};
|
|
210
|
+
const result = await bridge.parsePaymentRequired(headers);
|
|
211
|
+
expect(result.amount).toBe('5.00');
|
|
212
|
+
expect(result.currency).toBe('USDC');
|
|
213
|
+
expect(result.address).toBe('0xabc123');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// 13. X402Bridge buildPaymentProof
|
|
217
|
+
test('buildPaymentProof returns a proof and timestamp', async () => {
|
|
218
|
+
const result = await bridge.buildPaymentProof({
|
|
219
|
+
amount: '5.00',
|
|
220
|
+
currency: 'USDC',
|
|
221
|
+
address: '0xabc123',
|
|
222
|
+
agentId: 'agent-1',
|
|
223
|
+
});
|
|
224
|
+
expect(result.proof).toBeTruthy();
|
|
225
|
+
expect(typeof result.proof).toBe('string');
|
|
226
|
+
expect(result.timestamp).toBeGreaterThan(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// 14. X402Bridge handleResponse for 402 status
|
|
230
|
+
test('handleResponse returns AP2PaymentResponse for 402 status', async () => {
|
|
231
|
+
const response = await bridge.handleResponse({
|
|
232
|
+
status: 402,
|
|
233
|
+
headers: {
|
|
234
|
+
'x-payment-amount': '10.00',
|
|
235
|
+
'x-payment-currency': 'USDC',
|
|
236
|
+
'x-payment-address': '0xdef456',
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
expect(response).not.toBeNull();
|
|
240
|
+
expect(response?.success).toBe(false);
|
|
241
|
+
expect(response?.error).toContain('Payment required');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// 15. X402Bridge handleResponse for 200 status (null)
|
|
245
|
+
test('handleResponse returns null for non-402 status', async () => {
|
|
246
|
+
const response = await bridge.handleResponse({
|
|
247
|
+
status: 200,
|
|
248
|
+
headers: {},
|
|
249
|
+
});
|
|
250
|
+
expect(response).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Additional: parsePaymentRequired with JSON header
|
|
254
|
+
test('parsePaymentRequired parses JSON x-payment-required header', async () => {
|
|
255
|
+
const headers = {
|
|
256
|
+
'x-payment-required': JSON.stringify({
|
|
257
|
+
amount: '7.50',
|
|
258
|
+
currency: 'USDC',
|
|
259
|
+
address: '0xfeed',
|
|
260
|
+
}),
|
|
261
|
+
};
|
|
262
|
+
const result = await bridge.parsePaymentRequired(headers);
|
|
263
|
+
expect(result.amount).toBe('7.50');
|
|
264
|
+
expect(result.address).toBe('0xfeed');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Additional: buildPaymentProof encodes agentId
|
|
268
|
+
test('buildPaymentProof encodes agentId in proof', async () => {
|
|
269
|
+
const result = await bridge.buildPaymentProof({
|
|
270
|
+
amount: '1.00',
|
|
271
|
+
currency: 'USDC',
|
|
272
|
+
address: '0xabc',
|
|
273
|
+
agentId: 'agent-special',
|
|
274
|
+
});
|
|
275
|
+
const decoded = JSON.parse(Buffer.from(result.proof, 'base64').toString());
|
|
276
|
+
expect(decoded.agentId).toBe('agent-special');
|
|
277
|
+
});
|
|
278
|
+
});
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AP2PaymentMethod,
|
|
3
|
+
AP2PaymentRequest,
|
|
4
|
+
AP2PaymentResponse,
|
|
5
|
+
IntentMandate,
|
|
6
|
+
CartMandate,
|
|
7
|
+
PaymentMandate,
|
|
8
|
+
} from './types';
|
|
9
|
+
import { validateMandate } from './validation';
|
|
10
|
+
|
|
11
|
+
function generateId(): string {
|
|
12
|
+
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class AP2PaymentHandler {
|
|
16
|
+
private supportedMethods: AP2PaymentMethod[];
|
|
17
|
+
|
|
18
|
+
constructor(config: { supportedMethods: AP2PaymentMethod[] }) {
|
|
19
|
+
this.supportedMethods = config.supportedMethods;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getSupportedMethods(): AP2PaymentMethod[] {
|
|
23
|
+
return [...this.supportedMethods];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
createIntentMandate(
|
|
27
|
+
params: Omit<IntentMandate, 'type' | 'isAgentInitiated' | 'isNonCustodial'>
|
|
28
|
+
): IntentMandate {
|
|
29
|
+
return {
|
|
30
|
+
type: 'intent',
|
|
31
|
+
isAgentInitiated: true,
|
|
32
|
+
isNonCustodial: true,
|
|
33
|
+
...params,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
createCartMandate(params: Omit<CartMandate, 'type'>): CartMandate {
|
|
38
|
+
return {
|
|
39
|
+
type: 'cart',
|
|
40
|
+
...params,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private selectMethod(preferred: AP2PaymentMethod): AP2PaymentMethod | null {
|
|
45
|
+
if (this.supportedMethods.includes(preferred)) {
|
|
46
|
+
return preferred;
|
|
47
|
+
}
|
|
48
|
+
// Fallback to first supported method
|
|
49
|
+
return this.supportedMethods.length > 0 ? this.supportedMethods[0] : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private getAmount(mandate: IntentMandate | CartMandate): number {
|
|
53
|
+
if (mandate.type === 'intent') {
|
|
54
|
+
return mandate.maxAmount;
|
|
55
|
+
}
|
|
56
|
+
return mandate.total;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async handle(request: AP2PaymentRequest): Promise<AP2PaymentResponse> {
|
|
60
|
+
try {
|
|
61
|
+
validateMandate(request.mandate);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
error: (err as Error).message,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const method = this.selectMethod(request.preferredMethod);
|
|
70
|
+
if (!method) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
error: 'No supported payment methods available',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const mandateId = generateId();
|
|
78
|
+
const timestamp = Date.now();
|
|
79
|
+
const amount = this.getAmount(request.mandate);
|
|
80
|
+
|
|
81
|
+
const auditTrail: string[] = [
|
|
82
|
+
`[${new Date(timestamp).toISOString()}] Mandate received: type=${request.mandate.type}, agentId=${request.mandate.agentId}`,
|
|
83
|
+
`[${new Date(timestamp).toISOString()}] Payment method selected: ${method}`,
|
|
84
|
+
`[${new Date(timestamp).toISOString()}] Non-custodial payment initiated: amount=${amount} ${request.mandate.currency}`,
|
|
85
|
+
`[${new Date(timestamp).toISOString()}] MandateId assigned: ${mandateId}`,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const paymentMandate: PaymentMandate = {
|
|
89
|
+
type: 'payment',
|
|
90
|
+
mandateId,
|
|
91
|
+
method,
|
|
92
|
+
amount,
|
|
93
|
+
currency: request.mandate.currency,
|
|
94
|
+
auditTrail,
|
|
95
|
+
timestamp,
|
|
96
|
+
isNonCustodial: true,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const transactionId = `tx_${generateId()}`;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
transactionId,
|
|
104
|
+
paymentMandate,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type AP2PaymentMethod = 'usdc_base' | 'x402' | 'usdc_arbitrum' | 'usdc_optimism';
|
|
2
|
+
|
|
3
|
+
export type MandateType = 'intent' | 'cart' | 'payment';
|
|
4
|
+
|
|
5
|
+
export interface IntentMandate {
|
|
6
|
+
type: 'intent';
|
|
7
|
+
agentId: string;
|
|
8
|
+
merchantId: string;
|
|
9
|
+
maxAmount: number;
|
|
10
|
+
currency: string;
|
|
11
|
+
ttl: number; // Unix timestamp ms
|
|
12
|
+
isAgentInitiated: true;
|
|
13
|
+
isNonCustodial: true;
|
|
14
|
+
signature?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LineItem {
|
|
18
|
+
name: string;
|
|
19
|
+
price: number;
|
|
20
|
+
qty: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CartMandate {
|
|
24
|
+
type: 'cart';
|
|
25
|
+
agentId: string;
|
|
26
|
+
lineItems: LineItem[];
|
|
27
|
+
total: number;
|
|
28
|
+
currency: string;
|
|
29
|
+
signature?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PaymentMandate {
|
|
33
|
+
type: 'payment';
|
|
34
|
+
mandateId: string;
|
|
35
|
+
method: AP2PaymentMethod;
|
|
36
|
+
amount: number;
|
|
37
|
+
currency: string;
|
|
38
|
+
auditTrail: string[];
|
|
39
|
+
timestamp: number;
|
|
40
|
+
isNonCustodial: true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AP2PaymentRequest {
|
|
44
|
+
mandate: IntentMandate | CartMandate;
|
|
45
|
+
preferredMethod: AP2PaymentMethod;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AP2PaymentResponse {
|
|
49
|
+
success: boolean;
|
|
50
|
+
transactionId?: string;
|
|
51
|
+
error?: string;
|
|
52
|
+
paymentMandate?: PaymentMandate;
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { IntentMandate, CartMandate } from './types';
|
|
2
|
+
|
|
3
|
+
export class AP2ValidationError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'AP2ValidationError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function validateMandate(mandate: IntentMandate | CartMandate): void {
|
|
11
|
+
if (!mandate.agentId) {
|
|
12
|
+
throw new AP2ValidationError('Missing required field: agentId');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (mandate.type === 'intent') {
|
|
16
|
+
if (!mandate.merchantId) {
|
|
17
|
+
throw new AP2ValidationError('Missing required field: merchantId');
|
|
18
|
+
}
|
|
19
|
+
if (mandate.maxAmount === undefined || mandate.maxAmount === null) {
|
|
20
|
+
throw new AP2ValidationError('Missing required field: maxAmount');
|
|
21
|
+
}
|
|
22
|
+
if (typeof mandate.maxAmount !== 'number' || mandate.maxAmount <= 0) {
|
|
23
|
+
throw new AP2ValidationError('maxAmount must be a positive number');
|
|
24
|
+
}
|
|
25
|
+
if (!mandate.currency) {
|
|
26
|
+
throw new AP2ValidationError('Missing required field: currency');
|
|
27
|
+
}
|
|
28
|
+
if (!mandate.ttl) {
|
|
29
|
+
throw new AP2ValidationError('Missing required field: ttl');
|
|
30
|
+
}
|
|
31
|
+
if (mandate.ttl < Date.now()) {
|
|
32
|
+
throw new AP2ValidationError('IntentMandate TTL has expired');
|
|
33
|
+
}
|
|
34
|
+
} else if (mandate.type === 'cart') {
|
|
35
|
+
if (!mandate.lineItems || mandate.lineItems.length === 0) {
|
|
36
|
+
throw new AP2ValidationError('CartMandate must have at least one line item');
|
|
37
|
+
}
|
|
38
|
+
if (!mandate.currency) {
|
|
39
|
+
throw new AP2ValidationError('Missing required field: currency');
|
|
40
|
+
}
|
|
41
|
+
if (mandate.total === undefined || mandate.total === null) {
|
|
42
|
+
throw new AP2ValidationError('Missing required field: total');
|
|
43
|
+
}
|
|
44
|
+
if (typeof mandate.total !== 'number' || mandate.total <= 0) {
|
|
45
|
+
throw new AP2ValidationError('total must be a positive number');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const computedTotal = mandate.lineItems.reduce((sum, item) => {
|
|
49
|
+
return sum + item.price * item.qty;
|
|
50
|
+
}, 0);
|
|
51
|
+
|
|
52
|
+
const diff = Math.abs(computedTotal - mandate.total);
|
|
53
|
+
if (diff > 0.001) {
|
|
54
|
+
throw new AP2ValidationError(
|
|
55
|
+
`CartMandate total mismatch: expected ${computedTotal.toFixed(4)}, got ${mandate.total.toFixed(4)}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
throw new AP2ValidationError('Unknown mandate type');
|
|
60
|
+
}
|
|
61
|
+
}
|