@antseed/cli 0.1.168 → 0.1.170
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 +4 -0
- package/dist/buyer-identities/loader.d.ts +28 -0
- package/dist/buyer-identities/loader.d.ts.map +1 -0
- package/dist/buyer-identities/loader.js +58 -0
- package/dist/buyer-identities/loader.js.map +1 -0
- package/dist/buyer-identities/store.d.ts +41 -0
- package/dist/buyer-identities/store.d.ts.map +1 -0
- package/dist/buyer-identities/store.js +171 -0
- package/dist/buyer-identities/store.js.map +1 -0
- package/dist/buyer-identities/store.test.d.ts +2 -0
- package/dist/buyer-identities/store.test.d.ts.map +1 -0
- package/dist/buyer-identities/store.test.js +122 -0
- package/dist/buyer-identities/store.test.js.map +1 -0
- package/dist/cli/commands/buyer/channel-withdraw.d.ts.map +1 -1
- package/dist/cli/commands/buyer/channel-withdraw.js +9 -4
- package/dist/cli/commands/buyer/channel-withdraw.js.map +1 -1
- package/dist/cli/commands/buyer/channel-withdraw.test.js +69 -0
- package/dist/cli/commands/buyer/channel-withdraw.test.js.map +1 -1
- package/dist/cli/commands/buyer/channels.js +1 -1
- package/dist/cli/commands/buyer/channels.js.map +1 -1
- package/dist/cli/commands/buyer/identity.d.ts +3 -0
- package/dist/cli/commands/buyer/identity.d.ts.map +1 -0
- package/dist/cli/commands/buyer/identity.js +115 -0
- package/dist/cli/commands/buyer/identity.js.map +1 -0
- package/dist/cli/commands/buyer/index.d.ts.map +1 -1
- package/dist/cli/commands/buyer/index.js +2 -0
- package/dist/cli/commands/buyer/index.js.map +1 -1
- package/dist/cli/commands/buyer/start.d.ts.map +1 -1
- package/dist/cli/commands/buyer/start.js +30 -2
- package/dist/cli/commands/buyer/start.js.map +1 -1
- package/dist/cli/commands/gateway/index.d.ts +3 -0
- package/dist/cli/commands/gateway/index.d.ts.map +1 -0
- package/dist/cli/commands/gateway/index.js +43 -0
- package/dist/cli/commands/gateway/index.js.map +1 -0
- package/dist/cli/commands/gateway/key.d.ts +3 -0
- package/dist/cli/commands/gateway/key.d.ts.map +1 -0
- package/dist/cli/commands/gateway/key.js +240 -0
- package/dist/cli/commands/gateway/key.js.map +1 -0
- package/dist/cli/commands/gateway/shared.d.ts +29 -0
- package/dist/cli/commands/gateway/shared.d.ts.map +1 -0
- package/dist/cli/commands/gateway/shared.js +90 -0
- package/dist/cli/commands/gateway/shared.js.map +1 -0
- package/dist/cli/commands/seller/setup.d.ts.map +1 -1
- package/dist/cli/commands/seller/setup.js +4 -0
- package/dist/cli/commands/seller/setup.js.map +1 -1
- package/dist/cli/commands/seller/start.d.ts.map +1 -1
- package/dist/cli/commands/seller/start.js +8 -2
- package/dist/cli/commands/seller/start.js.map +1 -1
- package/dist/cli/commands/seller/start.test.js +20 -0
- package/dist/cli/commands/seller/start.test.js.map +1 -1
- package/dist/cli/commands/tunnel/start.d.ts.map +1 -1
- package/dist/cli/commands/tunnel/start.js +25 -14
- package/dist/cli/commands/tunnel/start.js.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/gateway/accounting.d.ts +45 -0
- package/dist/gateway/accounting.d.ts.map +1 -0
- package/dist/gateway/accounting.js +94 -0
- package/dist/gateway/accounting.js.map +1 -0
- package/dist/gateway/cdp-auth.d.ts +12 -0
- package/dist/gateway/cdp-auth.d.ts.map +1 -0
- package/dist/gateway/cdp-auth.js +53 -0
- package/dist/gateway/cdp-auth.js.map +1 -0
- package/dist/gateway/cdp-auth.test.d.ts +2 -0
- package/dist/gateway/cdp-auth.test.d.ts.map +1 -0
- package/dist/gateway/cdp-auth.test.js +96 -0
- package/dist/gateway/cdp-auth.test.js.map +1 -0
- package/dist/gateway/gateway.test.d.ts.map +1 -0
- package/dist/gateway/gateway.test.js +709 -0
- package/dist/gateway/gateway.test.js.map +1 -0
- package/dist/gateway/keys.d.ts +13 -0
- package/dist/gateway/keys.d.ts.map +1 -0
- package/dist/gateway/keys.js +24 -0
- package/dist/gateway/keys.js.map +1 -0
- package/dist/gateway/limits.d.ts +25 -0
- package/dist/gateway/limits.d.ts.map +1 -0
- package/dist/gateway/limits.js +45 -0
- package/dist/gateway/limits.js.map +1 -0
- package/dist/gateway/money.d.ts +9 -0
- package/dist/gateway/money.d.ts.map +1 -0
- package/dist/gateway/money.js +37 -0
- package/dist/gateway/money.js.map +1 -0
- package/dist/gateway/runtime.d.ts +38 -0
- package/dist/gateway/runtime.d.ts.map +1 -0
- package/dist/gateway/runtime.js +144 -0
- package/dist/gateway/runtime.js.map +1 -0
- package/dist/gateway/server.d.ts +49 -0
- package/dist/gateway/server.d.ts.map +1 -0
- package/dist/gateway/server.js +460 -0
- package/dist/gateway/server.js.map +1 -0
- package/dist/gateway/spend-feed.d.ts +32 -0
- package/dist/gateway/spend-feed.d.ts.map +1 -0
- package/dist/gateway/spend-feed.js +86 -0
- package/dist/gateway/spend-feed.js.map +1 -0
- package/dist/gateway/store.d.ts +114 -0
- package/dist/gateway/store.d.ts.map +1 -0
- package/dist/gateway/store.js +253 -0
- package/dist/gateway/store.js.map +1 -0
- package/dist/gateway/x402.d.ts +95 -0
- package/dist/gateway/x402.d.ts.map +1 -0
- package/dist/gateway/x402.js +120 -0
- package/dist/gateway/x402.js.map +1 -0
- package/dist/plugins/registry.d.ts.map +1 -1
- package/dist/plugins/registry.js +2 -0
- package/dist/plugins/registry.js.map +1 -1
- package/dist/proxy/buyer-proxy.d.ts +15 -0
- package/dist/proxy/buyer-proxy.d.ts.map +1 -1
- package/dist/proxy/buyer-proxy.js +157 -15
- package/dist/proxy/buyer-proxy.js.map +1 -1
- package/dist/proxy/buyer-proxy.test.js +157 -1
- package/dist/proxy/buyer-proxy.test.js.map +1 -1
- package/dist/proxy/native-video-proxy.d.ts +34 -0
- package/dist/proxy/native-video-proxy.d.ts.map +1 -0
- package/dist/proxy/native-video-proxy.js +41 -0
- package/dist/proxy/native-video-proxy.js.map +1 -0
- package/dist/proxy/native-video-proxy.test.d.ts +2 -0
- package/dist/proxy/native-video-proxy.test.d.ts.map +1 -0
- package/dist/proxy/native-video-proxy.test.js +23 -0
- package/dist/proxy/native-video-proxy.test.js.map +1 -0
- package/dist/proxy/network-models.d.ts +3 -2
- package/dist/proxy/network-models.d.ts.map +1 -1
- package/dist/proxy/network-models.js +3 -0
- package/dist/proxy/network-models.js.map +1 -1
- package/dist/proxy/network-models.test.js +13 -0
- package/dist/proxy/network-models.test.js.map +1 -1
- package/dist/proxy/request-utils.d.ts +3 -0
- package/dist/proxy/request-utils.d.ts.map +1 -1
- package/dist/proxy/request-utils.js +6 -1
- package/dist/proxy/request-utils.js.map +1 -1
- package/dist/proxy/resource-routes.d.ts +20 -0
- package/dist/proxy/resource-routes.d.ts.map +1 -0
- package/dist/proxy/resource-routes.js +48 -0
- package/dist/proxy/resource-routes.js.map +1 -0
- package/dist/proxy/resource-routes.test.d.ts +2 -0
- package/dist/proxy/resource-routes.test.d.ts.map +1 -0
- package/dist/proxy/resource-routes.test.js +21 -0
- package/dist/proxy/resource-routes.test.js.map +1 -0
- package/dist/proxy/routing.d.ts.map +1 -1
- package/dist/proxy/routing.js +5 -2
- package/dist/proxy/routing.js.map +1 -1
- package/dist/proxy/service-api-adapter.d.ts +1 -1
- package/dist/proxy/service-api-adapter.d.ts.map +1 -1
- package/dist/proxy/service-api-adapter.js +1 -1
- package/dist/proxy/service-api-adapter.js.map +1 -1
- package/dist/proxy/spend-attribution.d.ts +63 -0
- package/dist/proxy/spend-attribution.d.ts.map +1 -0
- package/dist/proxy/spend-attribution.js +97 -0
- package/dist/proxy/spend-attribution.js.map +1 -0
- package/dist/proxy/video-download.d.ts +7 -0
- package/dist/proxy/video-download.d.ts.map +1 -0
- package/dist/proxy/video-download.js +66 -0
- package/dist/proxy/video-download.js.map +1 -0
- package/dist/proxy/video-download.test.d.ts +2 -0
- package/dist/proxy/video-download.test.d.ts.map +1 -0
- package/dist/proxy/video-download.test.js +108 -0
- package/dist/proxy/video-download.test.js.map +1 -0
- package/package.json +9 -7
- package/dist/tunnel/gateway.d.ts +0 -16
- package/dist/tunnel/gateway.d.ts.map +0 -1
- package/dist/tunnel/gateway.js +0 -146
- package/dist/tunnel/gateway.js.map +0 -1
- package/dist/tunnel/gateway.test.d.ts.map +0 -1
- package/dist/tunnel/gateway.test.js +0 -89
- package/dist/tunnel/gateway.test.js.map +0 -1
- /package/dist/{tunnel → gateway}/gateway.test.d.ts +0 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import * as http from 'node:http';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { SPEND_ATTRIBUTION_HEADER, SpendAttributionFeed } from '../proxy/spend-attribution.js';
|
|
8
|
+
import { GatewayAccounting } from './accounting.js';
|
|
9
|
+
import { generateApiKey } from './keys.js';
|
|
10
|
+
import { findLimitBreach, periodResetsAt, periodStart } from './limits.js';
|
|
11
|
+
import { formatUsdc, parseUsdToUsdc, usdcToDecimalString } from './money.js';
|
|
12
|
+
import { GatewayServer } from './server.js';
|
|
13
|
+
import { SpendFeedPoller } from './spend-feed.js';
|
|
14
|
+
import { DEFAULT_BUYER_IDENTITY } from '@antseed/node';
|
|
15
|
+
import { BUYER_IDENTITY_HEADER } from '../proxy/request-utils.js';
|
|
16
|
+
import { GatewayStore } from './store.js';
|
|
17
|
+
import { liveBuyerIdentityAddress } from './runtime.js';
|
|
18
|
+
import { Wallet } from 'ethers';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { X402Facilitator, decodeHeaderJson, encodeHeaderJson, } from './x402.js';
|
|
21
|
+
const NO_LIMITS = { daily: null, monthly: null, total: null };
|
|
22
|
+
function tempStore() {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), 'antseed-gateway-'));
|
|
24
|
+
const store = new GatewayStore(dir);
|
|
25
|
+
return { store, dir, cleanup: () => { store.close(); rmSync(dir, { recursive: true, force: true }); } };
|
|
26
|
+
}
|
|
27
|
+
async function listen(server) {
|
|
28
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
29
|
+
return server.address().port;
|
|
30
|
+
}
|
|
31
|
+
async function request(port, path, options = {}) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const req = http.request({
|
|
34
|
+
hostname: '127.0.0.1',
|
|
35
|
+
port,
|
|
36
|
+
path,
|
|
37
|
+
method: options.method ?? 'GET',
|
|
38
|
+
headers: {
|
|
39
|
+
...(options.key ? { authorization: `Bearer ${options.key}`, 'content-type': 'application/json' } : {}),
|
|
40
|
+
...options.headers,
|
|
41
|
+
},
|
|
42
|
+
}, (res) => {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
45
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8'), headers: res.headers }));
|
|
46
|
+
});
|
|
47
|
+
req.on('error', reject);
|
|
48
|
+
req.end(options.body);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Stand-in for `antseed buyer start`: answers API routes, records the tag the
|
|
53
|
+
* gateway attaches, and serves the attributed-spend feed for tagged requests.
|
|
54
|
+
*/
|
|
55
|
+
async function fakeBuyer(options = {}) {
|
|
56
|
+
const feed = new SpendAttributionFeed();
|
|
57
|
+
const captured = [];
|
|
58
|
+
let requestCounter = 0;
|
|
59
|
+
const server = http.createServer((req, res) => {
|
|
60
|
+
if (req.url?.startsWith('/_antseed/attributed-spend')) {
|
|
61
|
+
if (options.spendFeed === false) {
|
|
62
|
+
res.writeHead(404).end();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const after = Number(new URL(req.url, 'http://localhost').searchParams.get('after') ?? '0');
|
|
66
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
67
|
+
res.end(JSON.stringify({ ok: true, ...feed.page(after) }));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
req.resume();
|
|
71
|
+
req.on('end', () => {
|
|
72
|
+
const tag = req.headers[SPEND_ATTRIBUTION_HEADER];
|
|
73
|
+
const identity = req.headers[BUYER_IDENTITY_HEADER];
|
|
74
|
+
captured.push({
|
|
75
|
+
identity,
|
|
76
|
+
url: req.url ?? '',
|
|
77
|
+
auth: req.headers.authorization,
|
|
78
|
+
source: req.headers['x-antseed-system-proxy-source'],
|
|
79
|
+
tag,
|
|
80
|
+
});
|
|
81
|
+
const requestId = `req-${++requestCounter}`;
|
|
82
|
+
if (tag && options.costUsdc) {
|
|
83
|
+
feed.track(requestId, tag);
|
|
84
|
+
feed.record({
|
|
85
|
+
requestId,
|
|
86
|
+
buyerIdentity: options.reportIdentity ?? identity ?? DEFAULT_BUYER_IDENTITY,
|
|
87
|
+
sellerPeerId: 'seller',
|
|
88
|
+
amountUsdc: String(options.costUsdc),
|
|
89
|
+
inputTokens: '100',
|
|
90
|
+
cachedInputTokens: '0',
|
|
91
|
+
outputTokens: '20',
|
|
92
|
+
outputImages: '0',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
res.writeHead(200, { 'content-type': 'application/json', 'x-antseed-request-id': requestId });
|
|
96
|
+
res.end(JSON.stringify({ ok: true }));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
const port = await listen(server);
|
|
100
|
+
return { port, captured, close: () => new Promise((resolve) => server.close(() => { feed.close(); resolve(); })) };
|
|
101
|
+
}
|
|
102
|
+
async function startGateway(store, buyerPort, holdUsdc = 300_000, topup = null) {
|
|
103
|
+
const accounting = new GatewayAccounting(store, { holdUsdc, settleGraceMs: 50 });
|
|
104
|
+
const feed = new SpendFeedPoller({
|
|
105
|
+
buyerPort,
|
|
106
|
+
onPage: (page) => { accounting.ingest(page.bootId, page.events); },
|
|
107
|
+
intervalMs: 60_000,
|
|
108
|
+
});
|
|
109
|
+
const logs = [];
|
|
110
|
+
const server = new GatewayServer({
|
|
111
|
+
store,
|
|
112
|
+
accounting,
|
|
113
|
+
buyerPort,
|
|
114
|
+
topup,
|
|
115
|
+
identityAddress: async (name) => (name === 'team-a' ? '0x00000000000000000000000000000000000000aa' : null),
|
|
116
|
+
spendFeedState: () => feed.state,
|
|
117
|
+
refreshSpendFeed: () => feed.pollOnce(),
|
|
118
|
+
onLog: (message) => logs.push(message),
|
|
119
|
+
});
|
|
120
|
+
const port = await server.start();
|
|
121
|
+
return {
|
|
122
|
+
port,
|
|
123
|
+
logs,
|
|
124
|
+
feed,
|
|
125
|
+
accounting,
|
|
126
|
+
stop: async () => {
|
|
127
|
+
await server.stop();
|
|
128
|
+
accounting.dispose();
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
test('money parses and formats USD amounts as USDC base units', () => {
|
|
133
|
+
assert.equal(parseUsdToUsdc('5'), 5_000_000);
|
|
134
|
+
assert.equal(parseUsdToUsdc('$0.25'), 250_000);
|
|
135
|
+
assert.equal(parseUsdToUsdc('12.000001'), 12_000_001);
|
|
136
|
+
assert.throws(() => parseUsdToUsdc('-1'));
|
|
137
|
+
assert.throws(() => parseUsdToUsdc('1.0000001'));
|
|
138
|
+
assert.equal(usdcToDecimalString(1_500_000), '1.500000');
|
|
139
|
+
assert.equal(formatUsdc(1_500_000), '$1.50');
|
|
140
|
+
assert.equal(formatUsdc(1_234), '$0.0012');
|
|
141
|
+
});
|
|
142
|
+
test('limits use UTC calendar periods and count in-flight holds', () => {
|
|
143
|
+
const now = Date.UTC(2026, 9, 6, 15, 30);
|
|
144
|
+
assert.equal(periodStart('daily', now), Date.UTC(2026, 9, 6));
|
|
145
|
+
assert.equal(periodStart('monthly', now), Date.UTC(2026, 9, 1));
|
|
146
|
+
assert.equal(periodResetsAt('monthly', now), Date.UTC(2026, 10, 1));
|
|
147
|
+
assert.equal(periodResetsAt('total', now), null);
|
|
148
|
+
const limits = { daily: 1_000_000, monthly: null, total: 5_000_000 };
|
|
149
|
+
assert.equal(findLimitBreach(limits, { daily: 600_000, monthly: 600_000, total: 600_000 }, 300_000, now), null);
|
|
150
|
+
const breach = findLimitBreach(limits, { daily: 600_000, monthly: 600_000, total: 600_000 }, 400_000, now);
|
|
151
|
+
assert.equal(breach?.period, 'daily');
|
|
152
|
+
assert.equal(breach?.resetsAt, Date.UTC(2026, 9, 7));
|
|
153
|
+
});
|
|
154
|
+
test('store keeps keys hashed, syncs the tunnel env key, and records ledger entries idempotently', () => {
|
|
155
|
+
const { store, cleanup } = tempStore();
|
|
156
|
+
try {
|
|
157
|
+
const { key, secret } = store.createKey({ label: 'Alice', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
|
|
158
|
+
assert.equal(store.findKeyBySecret(secret)?.id, key.id);
|
|
159
|
+
assert.equal(store.findKeyBySecret(`${secret}x`), null);
|
|
160
|
+
assert.ok(!JSON.stringify(store.listKeys()).includes(secret));
|
|
161
|
+
const envKey = store.syncEnvironmentKey('antseed_legacy_tunnel_key_1');
|
|
162
|
+
assert.equal(envKey.source, 'tunnel-env');
|
|
163
|
+
assert.equal(store.syncEnvironmentKey('antseed_rotated_tunnel_key_2').id, envKey.id);
|
|
164
|
+
assert.equal(store.findKeyBySecret('antseed_legacy_tunnel_key_1'), null);
|
|
165
|
+
assert.equal(store.findKeyBySecret('antseed_rotated_tunnel_key_2')?.id, envKey.id);
|
|
166
|
+
assert.equal(store.retireEnvironmentKey()?.status, 'revoked');
|
|
167
|
+
assert.equal(store.retireEnvironmentKey(), null);
|
|
168
|
+
assert.equal(store.getKey(key.id)?.status, 'active');
|
|
169
|
+
assert.equal(store.syncEnvironmentKey('antseed_rotated_tunnel_key_2').status, 'active');
|
|
170
|
+
const entry = {
|
|
171
|
+
kind: 'spend',
|
|
172
|
+
keyId: key.id,
|
|
173
|
+
buyerIdentity: DEFAULT_BUYER_IDENTITY,
|
|
174
|
+
amountUsdc: 250_000,
|
|
175
|
+
externalRef: 'spend:default:boot:1',
|
|
176
|
+
createdAt: Date.now(),
|
|
177
|
+
};
|
|
178
|
+
assert.equal(store.recordLedgerEntry(entry), true);
|
|
179
|
+
assert.equal(store.recordLedgerEntry(entry), false);
|
|
180
|
+
assert.equal(store.recordLedgerEntry({ ...entry, kind: 'credit', amountUsdc: 1_000_000, externalRef: 'credit:test' }), true);
|
|
181
|
+
assert.deepEqual(store.periodSpend(key.id), { daily: 250_000, monthly: 250_000, total: 250_000 });
|
|
182
|
+
const stats = store.usageStats(key.id);
|
|
183
|
+
assert.equal(stats.spentUsdc, 250_000);
|
|
184
|
+
assert.equal(stats.creditedUsdc, 1_000_000);
|
|
185
|
+
store.revokeKey(key.id);
|
|
186
|
+
assert.equal(store.getKey(key.id)?.status, 'revoked');
|
|
187
|
+
assert.equal(store.countActiveKeys(), 1);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
cleanup();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
test('gateway pays with the key\'s buyer identity and ignores the client\'s choice', async () => {
|
|
194
|
+
const { store, cleanup } = tempStore();
|
|
195
|
+
const buyer = await fakeBuyer({ costUsdc: 100_000 });
|
|
196
|
+
const team = store.createKey({ label: 'Team', buyerIdentity: 'team-a', limits: { daily: 5_000_000, monthly: null, total: null }, expiresAt: null });
|
|
197
|
+
const owner = store.createKey({ label: 'Owner', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
|
|
198
|
+
const gateway = await startGateway(store, buyer.port);
|
|
199
|
+
try {
|
|
200
|
+
const spoof = { [BUYER_IDENTITY_HEADER]: 'someone-else' };
|
|
201
|
+
assert.equal((await request(gateway.port, '/v1/responses', { method: 'POST', key: team.secret, body: '{}', headers: spoof })).status, 200);
|
|
202
|
+
assert.equal((await request(gateway.port, '/v1/responses', { method: 'POST', key: owner.secret, body: '{}', headers: spoof })).status, 200);
|
|
203
|
+
assert.deepEqual(buyer.captured.map((entry) => entry.identity), ['team-a', undefined]);
|
|
204
|
+
await gateway.feed.pollOnce();
|
|
205
|
+
assert.equal(store.periodSpend(team.key.id).total, 100_000);
|
|
206
|
+
assert.equal(store.periodSpend(owner.key.id).total, 100_000);
|
|
207
|
+
const info = JSON.parse((await request(gateway.port, '/v1/key', { key: team.secret })).body).data;
|
|
208
|
+
assert.equal(info.buyer_address, '0x00000000000000000000000000000000000000aa');
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
await gateway.stop();
|
|
212
|
+
await buyer.close();
|
|
213
|
+
cleanup();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
test('spend signed by a different identity than the key\'s is not booked to the key', async () => {
|
|
217
|
+
const { store, cleanup } = tempStore();
|
|
218
|
+
const buyer = await fakeBuyer({ costUsdc: 100_000, reportIdentity: 'other' });
|
|
219
|
+
const team = store.createKey({ label: 'Team', buyerIdentity: 'team-a', limits: NO_LIMITS, expiresAt: null });
|
|
220
|
+
const gateway = await startGateway(store, buyer.port);
|
|
221
|
+
try {
|
|
222
|
+
assert.equal((await request(gateway.port, '/v1/responses', { method: 'POST', key: team.secret, body: '{}' })).status, 200);
|
|
223
|
+
await gateway.feed.pollOnce();
|
|
224
|
+
assert.equal(store.periodSpend(team.key.id).total, 0);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await gateway.stop();
|
|
228
|
+
await buyer.close();
|
|
229
|
+
cleanup();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
test('spend attribution feed reports only tagged requests and pages by cursor', (context) => {
|
|
233
|
+
const feed = new SpendAttributionFeed(() => 1000);
|
|
234
|
+
context.after(() => feed.close());
|
|
235
|
+
feed.track('req-1', 'gw_a');
|
|
236
|
+
const spend = { sellerPeerId: 's', amountUsdc: '10', inputTokens: '1', cachedInputTokens: '0', outputTokens: '1', outputImages: '0' };
|
|
237
|
+
feed.record({ ...spend, requestId: 'req-1' });
|
|
238
|
+
feed.record({ ...spend, requestId: 'req-untagged' });
|
|
239
|
+
feed.record({ ...spend, requestId: 'req-1', amountUsdc: '5' });
|
|
240
|
+
const first = feed.page(0);
|
|
241
|
+
assert.deepEqual(first.events.map((event) => [event.seq, event.tag, event.amountUsdc]), [[1, 'gw_a', '10'], [2, 'gw_a', '5']]);
|
|
242
|
+
assert.equal(first.cursor, 2);
|
|
243
|
+
assert.deepEqual(feed.page(first.cursor).events, []);
|
|
244
|
+
assert.equal(feed.page(first.cursor).cursor, 2);
|
|
245
|
+
});
|
|
246
|
+
test('spend signed without a request id goes to the latest tag on that seller', (context) => {
|
|
247
|
+
const feed = new SpendAttributionFeed();
|
|
248
|
+
context.after(() => feed.close());
|
|
249
|
+
feed.track('req-1', 'gw_a');
|
|
250
|
+
const spend = { sellerPeerId: 's', amountUsdc: '10', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0' };
|
|
251
|
+
feed.record({ ...spend, requestId: null });
|
|
252
|
+
feed.record({ ...spend, requestId: 'req-1' });
|
|
253
|
+
feed.record({ ...spend, requestId: null, amountUsdc: '7' });
|
|
254
|
+
feed.record({ ...spend, requestId: null, sellerPeerId: 'other' });
|
|
255
|
+
assert.deepEqual(feed.page(0).events.map((event) => [event.tag, event.amountUsdc]), [['gw_a', '10'], ['gw_a', '7']]);
|
|
256
|
+
});
|
|
257
|
+
test('spend without a request id is not guessed when several tags share the identity and seller', (context) => {
|
|
258
|
+
let now = 0;
|
|
259
|
+
const feed = new SpendAttributionFeed(() => now);
|
|
260
|
+
context.after(() => feed.close());
|
|
261
|
+
feed.track('req-a', 'gw_a');
|
|
262
|
+
feed.track('req-b', 'gw_b');
|
|
263
|
+
const spend = { sellerPeerId: 's', amountUsdc: '10', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0' };
|
|
264
|
+
feed.record({ ...spend, requestId: 'req-a' });
|
|
265
|
+
feed.record({ ...spend, requestId: 'req-b' });
|
|
266
|
+
feed.record({ ...spend, requestId: null, amountUsdc: '7' });
|
|
267
|
+
assert.deepEqual(feed.page(0).events.map((event) => event.tag), ['gw_a', 'gw_b']);
|
|
268
|
+
now = 6 * 60 * 1000;
|
|
269
|
+
feed.record({ ...spend, requestId: 'req-b' });
|
|
270
|
+
feed.record({ ...spend, requestId: null, amountUsdc: '3' });
|
|
271
|
+
assert.deepEqual(feed.page(0).events.map((event) => [event.tag, event.amountUsdc]).slice(2), [['gw_b', '10'], ['gw_b', '3']]);
|
|
272
|
+
});
|
|
273
|
+
test('late spend keeps its tag after more than 2048 newer requests', (context) => {
|
|
274
|
+
const feed = new SpendAttributionFeed();
|
|
275
|
+
context.after(() => feed.close());
|
|
276
|
+
feed.track('long-running', 'gw_original');
|
|
277
|
+
for (let index = 0; index < 4096; index++)
|
|
278
|
+
feed.track(`request-${index}`, `gw_${index}`);
|
|
279
|
+
const spend = { requestId: 'long-running', sellerPeerId: 'seller', amountUsdc: '10', inputTokens: '1', cachedInputTokens: '0', outputTokens: '1', outputImages: '0' };
|
|
280
|
+
feed.record(spend);
|
|
281
|
+
feed.record(spend);
|
|
282
|
+
assert.deepEqual(feed.page(0).events.map((event) => event.tag), ['gw_original', 'gw_original']);
|
|
283
|
+
});
|
|
284
|
+
test('request tags are dropped an hour after they were tracked', (context) => {
|
|
285
|
+
let now = 0;
|
|
286
|
+
const feed = new SpendAttributionFeed(() => now);
|
|
287
|
+
context.after(() => feed.close());
|
|
288
|
+
feed.track('old', 'gw_old');
|
|
289
|
+
now = 59 * 60 * 1000;
|
|
290
|
+
feed.track('recent', 'gw_recent');
|
|
291
|
+
now = 61 * 60 * 1000;
|
|
292
|
+
feed.track('new', 'gw_new');
|
|
293
|
+
const spend = { sellerPeerId: 's', amountUsdc: '1', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0' };
|
|
294
|
+
feed.record({ ...spend, requestId: 'old', sellerPeerId: 'a' });
|
|
295
|
+
feed.record({ ...spend, requestId: 'recent' });
|
|
296
|
+
assert.deepEqual(feed.page(0).events.map((event) => event.tag), ['gw_recent']);
|
|
297
|
+
});
|
|
298
|
+
test('gateway wallet lookup follows the live buyer until it reloads an identity', async () => {
|
|
299
|
+
let address = 'original-wallet';
|
|
300
|
+
let status = 200;
|
|
301
|
+
const server = http.createServer((req, res) => {
|
|
302
|
+
assert.equal(req.url, '/_antseed/buyer-identities');
|
|
303
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
304
|
+
res.end(JSON.stringify({ identities: [{ name: 'customer', address }] }));
|
|
305
|
+
});
|
|
306
|
+
const port = await listen(server);
|
|
307
|
+
try {
|
|
308
|
+
assert.equal(await liveBuyerIdentityAddress(port, 'customer'), 'original-wallet');
|
|
309
|
+
assert.equal(await liveBuyerIdentityAddress(port, 'missing'), null);
|
|
310
|
+
address = 'recreated-wallet';
|
|
311
|
+
assert.equal(await liveBuyerIdentityAddress(port, 'customer'), 'recreated-wallet');
|
|
312
|
+
status = 503;
|
|
313
|
+
assert.equal(await liveBuyerIdentityAddress(port, 'customer'), null);
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
317
|
+
}
|
|
318
|
+
await assert.rejects(liveBuyerIdentityAddress(port, 'customer'));
|
|
319
|
+
});
|
|
320
|
+
test('gateway exposes only authenticated supported API routes', async () => {
|
|
321
|
+
const { store, cleanup } = tempStore();
|
|
322
|
+
const buyer = await fakeBuyer();
|
|
323
|
+
const { secret } = store.createKey({ label: 'Cursor', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
|
|
324
|
+
const gateway = await startGateway(store, buyer.port);
|
|
325
|
+
try {
|
|
326
|
+
assert.equal((await request(gateway.port, '/_antseed/status', { key: secret })).status, 404);
|
|
327
|
+
assert.equal((await request(gateway.port, '/v1/models')).status, 401);
|
|
328
|
+
assert.equal((await request(gateway.port, '/v1/models', { key: 'wrong' })).status, 401);
|
|
329
|
+
assert.equal((await request(gateway.port, '/v1/models', { key: secret })).status, 200);
|
|
330
|
+
assert.equal((await request(gateway.port, '/messages/count_tokens', { method: 'POST', key: secret, body: '{}' })).status, 200);
|
|
331
|
+
assert.equal((await request(gateway.port, '/v1/responses', {
|
|
332
|
+
method: 'POST', key: secret, body: '{"model":"m"}', headers: { originator: 'Cursor Agent' },
|
|
333
|
+
})).status, 200);
|
|
334
|
+
assert.equal((await request(gateway.port, '/responses', {
|
|
335
|
+
method: 'POST', key: secret, body: '{}', headers: { 'x-cursor-client-version': '1.0.0' },
|
|
336
|
+
})).status, 200);
|
|
337
|
+
assert.equal((await request(gateway.port, '/v1/v1/responses?cursor=1', {
|
|
338
|
+
method: 'POST', key: secret, body: '{}', headers: { 'user-agent': 'Cursor/1.0.0', [SPEND_ATTRIBUTION_HEADER]: 'spoofed' },
|
|
339
|
+
})).status, 200);
|
|
340
|
+
assert.equal((await request(gateway.port, '/responses/other', { method: 'POST', key: secret, body: '{}' })).status, 404);
|
|
341
|
+
assert.deepEqual(buyer.captured.map(({ url, auth, source }) => ({ url, auth, source })), [
|
|
342
|
+
{ url: '/v1/models', auth: undefined, source: 'public-tunnel' },
|
|
343
|
+
{ url: '/v1/messages/count_tokens', auth: undefined, source: 'public-tunnel' },
|
|
344
|
+
{ url: '/v1/responses', auth: undefined, source: 'cursor' },
|
|
345
|
+
{ url: '/v1/responses', auth: undefined, source: 'cursor' },
|
|
346
|
+
{ url: '/v1/responses?cursor=1', auth: undefined, source: 'cursor' },
|
|
347
|
+
]);
|
|
348
|
+
// Every forwarded request carries a gateway tag, never the client's.
|
|
349
|
+
assert.ok(buyer.captured.every((entry) => entry.tag?.startsWith('gw_')));
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
await gateway.stop();
|
|
353
|
+
await buyer.close();
|
|
354
|
+
cleanup();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
test('gateway rejects revoked and expired keys', async () => {
|
|
358
|
+
const { store, cleanup } = tempStore();
|
|
359
|
+
const buyer = await fakeBuyer();
|
|
360
|
+
const revoked = store.createKey({ label: 'Old', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
|
|
361
|
+
const expired = store.createKey({ label: 'Trial', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: Date.now() - 1 });
|
|
362
|
+
store.revokeKey(revoked.key.id);
|
|
363
|
+
const gateway = await startGateway(store, buyer.port);
|
|
364
|
+
try {
|
|
365
|
+
const revokedResponse = await request(gateway.port, '/v1/models', { key: revoked.secret });
|
|
366
|
+
assert.equal(revokedResponse.status, 401);
|
|
367
|
+
assert.equal(JSON.parse(revokedResponse.body).error.code, 'key_revoked');
|
|
368
|
+
const expiredResponse = await request(gateway.port, '/v1/models', { key: expired.secret });
|
|
369
|
+
assert.equal(expiredResponse.status, 401);
|
|
370
|
+
assert.equal(JSON.parse(expiredResponse.body).error.code, 'key_expired');
|
|
371
|
+
assert.equal(buyer.captured.length, 0);
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
await gateway.stop();
|
|
375
|
+
await buyer.close();
|
|
376
|
+
cleanup();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
test('gateway answers 413 to oversized bodies without forwarding them', async () => {
|
|
380
|
+
const { store, cleanup } = tempStore();
|
|
381
|
+
const buyer = await fakeBuyer();
|
|
382
|
+
const { secret } = store.createKey({ label: 'Big', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
|
|
383
|
+
const gateway = await startGateway(store, buyer.port);
|
|
384
|
+
try {
|
|
385
|
+
const response = await request(gateway.port, '/v1/chat/completions', { method: 'POST', key: secret, body: 'x'.repeat(64 * 1024 * 1024 + 1) });
|
|
386
|
+
assert.equal(response.status, 413);
|
|
387
|
+
assert.equal(JSON.parse(response.body).error.code, 'request_too_large');
|
|
388
|
+
assert.equal(buyer.captured.length, 0);
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
await gateway.stop();
|
|
392
|
+
await buyer.close();
|
|
393
|
+
cleanup();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
test('gateway settles reported spend per key and answers 402 once a cap is reached', async () => {
|
|
397
|
+
const { store, cleanup } = tempStore();
|
|
398
|
+
// Each request costs $0.40 against a $1.00 daily cap.
|
|
399
|
+
const buyer = await fakeBuyer({ costUsdc: 400_000 });
|
|
400
|
+
const limited = store.createKey({ label: 'Friend', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
|
|
401
|
+
const unlimited = store.createKey({ label: 'Owner', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
|
|
402
|
+
const gateway = await startGateway(store, buyer.port, 300_000);
|
|
403
|
+
const send = (key) => request(gateway.port, '/v1/chat/completions', { method: 'POST', key, body: '{"model":"deepseek-v4-flash"}' });
|
|
404
|
+
try {
|
|
405
|
+
assert.equal((await send(limited.secret)).status, 200);
|
|
406
|
+
await gateway.feed.pollOnce();
|
|
407
|
+
assert.equal((await send(limited.secret)).status, 200);
|
|
408
|
+
await gateway.feed.pollOnce();
|
|
409
|
+
// $0.80 settled; the next request is admitted (0.80 < 1.00) and overshoots by one request.
|
|
410
|
+
assert.equal((await send(limited.secret)).status, 200);
|
|
411
|
+
await gateway.feed.pollOnce();
|
|
412
|
+
const blocked = await send(limited.secret);
|
|
413
|
+
assert.equal(blocked.status, 402);
|
|
414
|
+
const error = JSON.parse(blocked.body).error;
|
|
415
|
+
assert.equal(error.code, 'spend_limit_reached');
|
|
416
|
+
assert.equal(error.limit.period, 'daily');
|
|
417
|
+
assert.equal(error.limit.spent_usd, '1.200000');
|
|
418
|
+
// Spend is attributed per key: the other key on the same buyer is unaffected.
|
|
419
|
+
assert.equal((await send(unlimited.secret)).status, 200);
|
|
420
|
+
await gateway.feed.pollOnce();
|
|
421
|
+
assert.equal(store.periodSpend(limited.key.id).total, 1_200_000);
|
|
422
|
+
assert.equal(store.periodSpend(unlimited.key.id).total, 400_000);
|
|
423
|
+
const info = await request(gateway.port, '/v1/key', { key: limited.secret });
|
|
424
|
+
assert.equal(info.status, 200);
|
|
425
|
+
const data = JSON.parse(info.body).data;
|
|
426
|
+
assert.equal(data.id, limited.key.id);
|
|
427
|
+
// The rejected request never reached the buyer and is not logged.
|
|
428
|
+
assert.equal(data.usage.requests, 3);
|
|
429
|
+
assert.equal(data.usage.spent_usd, '1.200000');
|
|
430
|
+
assert.equal(data.limits.daily.limit_usd, '1.000000');
|
|
431
|
+
assert.equal(data.limits.daily.remaining_usd, '0.000000');
|
|
432
|
+
assert.equal(data.limits.total.limit_usd, null);
|
|
433
|
+
const stats = store.usageStats(limited.key.id);
|
|
434
|
+
assert.equal(stats.failedRequests, 0);
|
|
435
|
+
assert.equal(stats.inputTokens, 300);
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
await gateway.stop();
|
|
439
|
+
await buyer.close();
|
|
440
|
+
cleanup();
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
test('gateway holds block concurrent requests from slipping under a cap', () => {
|
|
444
|
+
const { store, cleanup } = tempStore();
|
|
445
|
+
const accounting = new GatewayAccounting(store, { holdUsdc: 300_000, settleGraceMs: 10 });
|
|
446
|
+
try {
|
|
447
|
+
const { key } = store.createKey({ label: 'Burst', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 500_000, monthly: null, total: null }, expiresAt: null });
|
|
448
|
+
assert.equal(accounting.admit(key).ok, true);
|
|
449
|
+
assert.equal(accounting.admit(key).ok, true);
|
|
450
|
+
const third = accounting.admit(key);
|
|
451
|
+
assert.equal(third.ok, false);
|
|
452
|
+
assert.equal(accounting.heldUsdc(key.id), 600_000);
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
accounting.dispose();
|
|
456
|
+
cleanup();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
test('a finished request keeps its remaining hold after its first spend delta', () => {
|
|
460
|
+
const { store, cleanup } = tempStore();
|
|
461
|
+
const accounting = new GatewayAccounting(store, { holdUsdc: 300_000, settleGraceMs: 60_000 });
|
|
462
|
+
try {
|
|
463
|
+
const { key } = store.createKey({ label: 'Stream', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
|
|
464
|
+
const admission = accounting.admit(key);
|
|
465
|
+
assert.ok(admission.ok);
|
|
466
|
+
store.startRequest({ tag: admission.tag, keyId: key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/v1/chat/completions', model: null, startedAt: 0 });
|
|
467
|
+
accounting.finish(admission.tag);
|
|
468
|
+
const event = { seq: 1, tag: admission.tag, requestId: 'r', buyerIdentity: DEFAULT_BUYER_IDENTITY, sellerPeerId: 's', amountUsdc: '100000', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0', at: 0 };
|
|
469
|
+
accounting.ingest('boot', [event]);
|
|
470
|
+
assert.equal(accounting.heldUsdc(key.id), 200_000, 'later deltas of the same request stay covered');
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
accounting.dispose();
|
|
474
|
+
cleanup();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
test('free routes neither reserve holds nor count against caps', async () => {
|
|
478
|
+
const { store, cleanup } = tempStore();
|
|
479
|
+
const buyer = await fakeBuyer();
|
|
480
|
+
const { key, secret } = store.createKey({ label: 'Poller', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 400_000, monthly: null, total: null }, expiresAt: null });
|
|
481
|
+
const gateway = await startGateway(store, buyer.port, 300_000);
|
|
482
|
+
try {
|
|
483
|
+
for (let index = 0; index < 3; index += 1) {
|
|
484
|
+
assert.equal((await request(gateway.port, '/v1/models', { key: secret })).status, 200);
|
|
485
|
+
}
|
|
486
|
+
assert.equal(gateway.accounting.heldUsdc(key.id), 0);
|
|
487
|
+
assert.equal((await request(gateway.port, '/v1/responses', { method: 'POST', key: secret, body: '{}' })).status, 200);
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
await gateway.stop();
|
|
491
|
+
await buyer.close();
|
|
492
|
+
cleanup();
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
test('gateway fails closed for capped keys when the buyer does not report spend', async () => {
|
|
496
|
+
const { store, cleanup } = tempStore();
|
|
497
|
+
const buyer = await fakeBuyer({ spendFeed: false });
|
|
498
|
+
const capped = store.createKey({ label: 'Capped', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: null, monthly: 5_000_000, total: null }, expiresAt: null });
|
|
499
|
+
const open = store.createKey({ label: 'Open', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
|
|
500
|
+
const gateway = await startGateway(store, buyer.port);
|
|
501
|
+
try {
|
|
502
|
+
const response = await request(gateway.port, '/v1/responses', { method: 'POST', key: capped.secret, body: '{}' });
|
|
503
|
+
assert.equal(response.status, 503);
|
|
504
|
+
assert.equal(JSON.parse(response.body).error.code, 'spend_tracking_unavailable');
|
|
505
|
+
assert.equal((await request(gateway.port, '/v1/responses', { method: 'POST', key: open.secret, body: '{}' })).status, 200);
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
await gateway.stop();
|
|
509
|
+
await buyer.close();
|
|
510
|
+
cleanup();
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
test('generated keys are long random antseed secrets', () => {
|
|
514
|
+
const first = generateApiKey();
|
|
515
|
+
const second = generateApiKey();
|
|
516
|
+
assert.match(first.secret, /^antseed_[A-Za-z0-9_-]{43}$/);
|
|
517
|
+
assert.notEqual(first.secret, second.secret);
|
|
518
|
+
assert.notEqual(first.hash, first.secret);
|
|
519
|
+
assert.ok(first.hint.startsWith('antseed_') && first.hint.includes('…'));
|
|
520
|
+
});
|
|
521
|
+
const TEAM_WALLET = '0x00000000000000000000000000000000000000aa';
|
|
522
|
+
const USDC = {
|
|
523
|
+
network: 'eip155:8453',
|
|
524
|
+
chainId: 8453,
|
|
525
|
+
address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
526
|
+
name: 'USD Coin',
|
|
527
|
+
version: '2',
|
|
528
|
+
};
|
|
529
|
+
/** Facilitator stand-in: accepts each EIP-3009 nonce once, like the USDC contract. */
|
|
530
|
+
async function fakeFacilitator() {
|
|
531
|
+
const calls = [];
|
|
532
|
+
const used = new Set();
|
|
533
|
+
const server = http.createServer((req, res) => {
|
|
534
|
+
const chunks = [];
|
|
535
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
536
|
+
req.on('end', () => {
|
|
537
|
+
const { paymentPayload } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
538
|
+
const { nonce, from } = paymentPayload.payload.authorization;
|
|
539
|
+
calls.push(req.url ?? '');
|
|
540
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
541
|
+
if (req.url === '/verify') {
|
|
542
|
+
res.end(JSON.stringify(used.has(nonce) ? { isValid: false, invalidReason: 'invalid_transaction_state', payer: from } : { isValid: true, payer: from }));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
used.add(nonce);
|
|
546
|
+
res.end(JSON.stringify({ success: true, transaction: '0xabc', network: USDC.network, payer: from }));
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
const port = await listen(server);
|
|
550
|
+
return {
|
|
551
|
+
calls,
|
|
552
|
+
topup: () => ({
|
|
553
|
+
asset: async () => USDC,
|
|
554
|
+
facilitator: new X402Facilitator({ url: `http://127.0.0.1:${port}` }),
|
|
555
|
+
minUsdc: 1_000_000,
|
|
556
|
+
maxUsdc: 100_000_000,
|
|
557
|
+
}),
|
|
558
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
async function signPayment(payer, required, overrides = {}) {
|
|
562
|
+
const accepted = required.accepts[0];
|
|
563
|
+
const now = Math.floor(Date.now() / 1000);
|
|
564
|
+
const authorization = {
|
|
565
|
+
from: payer.address,
|
|
566
|
+
to: overrides.to ?? accepted.payTo,
|
|
567
|
+
value: overrides.value ?? accepted.amount,
|
|
568
|
+
validAfter: String(now - 5),
|
|
569
|
+
validBefore: String(now + 300),
|
|
570
|
+
nonce: `0x${randomBytes(32).toString('hex')}`,
|
|
571
|
+
};
|
|
572
|
+
const signature = await payer.signTypedData({ name: USDC.name, version: USDC.version, chainId: USDC.chainId, verifyingContract: USDC.address }, {
|
|
573
|
+
TransferWithAuthorization: [
|
|
574
|
+
{ name: 'from', type: 'address' },
|
|
575
|
+
{ name: 'to', type: 'address' },
|
|
576
|
+
{ name: 'value', type: 'uint256' },
|
|
577
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
578
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
579
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
580
|
+
],
|
|
581
|
+
}, authorization);
|
|
582
|
+
const payment = { x402Version: 2, resource: required.resource, accepted, payload: { signature, authorization } };
|
|
583
|
+
return encodeHeaderJson(payment);
|
|
584
|
+
}
|
|
585
|
+
test('x402 top-up: 402 names the key wallet, a signed payment is settled and credited once', async () => {
|
|
586
|
+
const { store, cleanup } = tempStore();
|
|
587
|
+
const buyer = await fakeBuyer();
|
|
588
|
+
const facilitator = await fakeFacilitator();
|
|
589
|
+
const team = store.createKey({ label: 'Team', buyerIdentity: 'team-a', limits: NO_LIMITS, expiresAt: null, topupEnabled: true });
|
|
590
|
+
const gateway = await startGateway(store, buyer.port, 300_000, facilitator.topup());
|
|
591
|
+
const topup = (headers = {}) => request(gateway.port, '/v1/key/topup', { method: 'POST', key: team.secret, body: '{"amount_usd":"5"}', headers });
|
|
592
|
+
try {
|
|
593
|
+
const challenge = await topup();
|
|
594
|
+
assert.equal(challenge.status, 402);
|
|
595
|
+
const required = decodeHeaderJson(challenge.headers['payment-required']);
|
|
596
|
+
assert.equal(required.x402Version, 2);
|
|
597
|
+
assert.equal(required.accepts[0].payTo.toLowerCase(), TEAM_WALLET);
|
|
598
|
+
assert.equal(required.accepts[0].amount, '5000000');
|
|
599
|
+
assert.equal(required.accepts[0].network, 'eip155:8453');
|
|
600
|
+
const payer = Wallet.createRandom();
|
|
601
|
+
const signature = await signPayment(payer, required);
|
|
602
|
+
const paid = await topup({ 'payment-signature': signature });
|
|
603
|
+
assert.equal(paid.status, 200);
|
|
604
|
+
assert.equal(JSON.parse(paid.body).data.topped_up_usd, '5.000000');
|
|
605
|
+
const settlement = decodeHeaderJson(paid.headers['payment-response']);
|
|
606
|
+
assert.equal(settlement.success, true);
|
|
607
|
+
assert.equal(settlement.payer, payer.address);
|
|
608
|
+
assert.deepEqual(facilitator.calls, ['/verify', '/settle']);
|
|
609
|
+
assert.equal(store.usageStats(team.key.id).creditedUsdc, 5_000_000);
|
|
610
|
+
// Replaying the same authorization fails on-chain and is not credited twice.
|
|
611
|
+
const replay = await topup({ 'payment-signature': signature });
|
|
612
|
+
assert.equal(replay.status, 402);
|
|
613
|
+
assert.equal(store.usageStats(team.key.id).creditedUsdc, 5_000_000);
|
|
614
|
+
const info = JSON.parse((await request(gateway.port, '/v1/key', { key: team.secret })).body).data;
|
|
615
|
+
assert.equal(info.usage.topped_up_usd, '5.000000');
|
|
616
|
+
assert.equal(buyer.captured.length, 0, 'top-ups never reach the buyer');
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
await gateway.stop();
|
|
620
|
+
await buyer.close();
|
|
621
|
+
await facilitator.close();
|
|
622
|
+
cleanup();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
test('x402 top-up rejects payments that do not match before asking the facilitator', async () => {
|
|
626
|
+
const { store, cleanup } = tempStore();
|
|
627
|
+
const buyer = await fakeBuyer();
|
|
628
|
+
const facilitator = await fakeFacilitator();
|
|
629
|
+
const team = store.createKey({ label: 'Team', buyerIdentity: 'team-a', limits: NO_LIMITS, expiresAt: null, topupEnabled: true });
|
|
630
|
+
const gateway = await startGateway(store, buyer.port, 300_000, facilitator.topup());
|
|
631
|
+
const topup = (headers = {}, body = '{"amount_usd":"5"}') => request(gateway.port, '/v1/key/topup', { method: 'POST', key: team.secret, body, headers });
|
|
632
|
+
try {
|
|
633
|
+
const required = decodeHeaderJson((await topup()).headers['payment-required']);
|
|
634
|
+
const payer = Wallet.createRandom();
|
|
635
|
+
const reasonFor = async (signature) => decodeHeaderJson((await topup({ 'payment-signature': signature })).headers['payment-required']).error;
|
|
636
|
+
assert.equal(await reasonFor(await signPayment(payer, required, { value: '1000000' })), 'invalid_exact_evm_payload_authorization_value_mismatch');
|
|
637
|
+
assert.equal(await reasonFor(await signPayment(payer, required, { to: Wallet.createRandom().address })), 'invalid_exact_evm_payload_recipient_mismatch');
|
|
638
|
+
const forged = decodeHeaderJson(await signPayment(payer, required));
|
|
639
|
+
forged.payload.authorization.from = Wallet.createRandom().address;
|
|
640
|
+
assert.equal(await reasonFor(encodeHeaderJson(forged)), 'invalid_exact_evm_payload_signature');
|
|
641
|
+
const malformed = decodeHeaderJson(await signPayment(payer, required));
|
|
642
|
+
malformed.payload.authorization.validBefore = 'never';
|
|
643
|
+
assert.equal(await reasonFor(encodeHeaderJson(malformed)), 'invalid_payload');
|
|
644
|
+
assert.deepEqual(facilitator.calls, []);
|
|
645
|
+
assert.equal((await topup({}, '{"amount_usd":"0.5"}')).status, 400);
|
|
646
|
+
assert.equal(store.usageStats(team.key.id).creditedUsdc, 0);
|
|
647
|
+
}
|
|
648
|
+
finally {
|
|
649
|
+
await gateway.stop();
|
|
650
|
+
await buyer.close();
|
|
651
|
+
await facilitator.close();
|
|
652
|
+
cleanup();
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
test('x402 top-up still answers 200 when the settled payment cannot be booked', async () => {
|
|
656
|
+
const { store, cleanup } = tempStore();
|
|
657
|
+
const buyer = await fakeBuyer();
|
|
658
|
+
const facilitator = await fakeFacilitator();
|
|
659
|
+
const team = store.createKey({ label: 'Team', buyerIdentity: 'team-a', limits: NO_LIMITS, expiresAt: null, topupEnabled: true });
|
|
660
|
+
const gateway = await startGateway(store, buyer.port, 300_000, facilitator.topup());
|
|
661
|
+
const topup = (headers = {}) => request(gateway.port, '/v1/key/topup', { method: 'POST', key: team.secret, body: '{"amount_usd":"5"}', headers });
|
|
662
|
+
try {
|
|
663
|
+
const required = decodeHeaderJson((await topup()).headers['payment-required']);
|
|
664
|
+
store.recordLedgerEntry = () => { throw new Error('disk full'); };
|
|
665
|
+
const paid = await topup({ 'payment-signature': await signPayment(Wallet.createRandom(), required) });
|
|
666
|
+
assert.equal(paid.status, 200);
|
|
667
|
+
assert.equal(decodeHeaderJson(paid.headers['payment-response']).success, true);
|
|
668
|
+
assert.ok(gateway.logs.some((line) => line.includes('NOT recorded in the ledger')));
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
await gateway.stop();
|
|
672
|
+
await buyer.close();
|
|
673
|
+
await facilitator.close();
|
|
674
|
+
cleanup();
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
test('x402 top-up is unavailable without a facilitator, for keys on the operator wallet, or when the owner has not allowed it', async () => {
|
|
678
|
+
const { store, cleanup } = tempStore();
|
|
679
|
+
const buyer = await fakeBuyer();
|
|
680
|
+
const facilitator = await fakeFacilitator();
|
|
681
|
+
const owner = store.createKey({ label: 'Owner', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null, topupEnabled: true });
|
|
682
|
+
const team = store.createKey({ label: 'Team', buyerIdentity: 'team-a', limits: NO_LIMITS, expiresAt: null, topupEnabled: true });
|
|
683
|
+
const notAllowed = store.createKey({ label: 'Friend', buyerIdentity: 'team-a', limits: NO_LIMITS, expiresAt: null });
|
|
684
|
+
const withTopup = await startGateway(store, buyer.port, 300_000, facilitator.topup());
|
|
685
|
+
const withoutTopup = await startGateway(store, buyer.port);
|
|
686
|
+
try {
|
|
687
|
+
const ownerResponse = await request(withTopup.port, '/v1/key/topup', { method: 'POST', key: owner.secret, body: '{"amount_usd":"5"}' });
|
|
688
|
+
assert.equal(ownerResponse.status, 403);
|
|
689
|
+
assert.equal(JSON.parse(ownerResponse.body).error.code, 'topup_not_available');
|
|
690
|
+
const disabled = await request(withoutTopup.port, '/v1/key/topup', { method: 'POST', key: team.secret, body: '{"amount_usd":"5"}' });
|
|
691
|
+
assert.equal(disabled.status, 501);
|
|
692
|
+
const blocked = await request(withTopup.port, '/v1/key/topup', { method: 'POST', key: notAllowed.secret, body: '{"amount_usd":"5"}' });
|
|
693
|
+
assert.equal(blocked.status, 403);
|
|
694
|
+
assert.equal(JSON.parse(blocked.body).error.code, 'topup_not_allowed');
|
|
695
|
+
assert.equal(JSON.parse((await request(withTopup.port, '/v1/key', { key: notAllowed.secret })).body).data.topup_enabled, false);
|
|
696
|
+
assert.equal(JSON.parse((await request(withTopup.port, '/v1/key', { key: team.secret })).body).data.topup_enabled, true);
|
|
697
|
+
store.setTopupEnabled(notAllowed.key.id, true);
|
|
698
|
+
const allowed = await request(withTopup.port, '/v1/key/topup', { method: 'POST', key: notAllowed.secret, body: '{"amount_usd":"5"}' });
|
|
699
|
+
assert.equal(allowed.status, 402, 'enabling takes effect without a restart');
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
await withTopup.stop();
|
|
703
|
+
await withoutTopup.stop();
|
|
704
|
+
await buyer.close();
|
|
705
|
+
await facilitator.close();
|
|
706
|
+
cleanup();
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
//# sourceMappingURL=gateway.test.js.map
|