@0xopenseeddev/seller 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/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@0xopenseeddev/seller",
3
+ "version": "1.0.0",
4
+ "description": "Seller node — serves AI inference and accepts payments",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "node --watch src/index.js",
9
+ "start": "node src/index.js"
10
+ },
11
+ "dependencies": {
12
+ "@0xopenseeddev/shared": "*",
13
+ "@fastify/cors": "^10.0.1",
14
+ "fastify": "^5.2.1",
15
+ "undici": "^7.3.0",
16
+ "viem": "^2.21.19"
17
+ },
18
+ "keywords": [],
19
+ "license": "ISC"
20
+ }
package/src/config.js ADDED
@@ -0,0 +1,47 @@
1
+ // ─── Seller node configuration ────────────────────────────────────────────────
2
+ // All values can be overridden via environment variables.
3
+
4
+ export function loadConfig() {
5
+ return {
6
+ // This node's unique identifier (use a real hex key in production)
7
+ peerId: process.env.PEER_ID ?? 'seller-dev-node-0000000000000000000000000000000000000001',
8
+
9
+ merchantAddress: process.env.MERCHANT_ADDRESS ?? '0x742d35Cc6634C0532925a3b844Bc454e4438f44e',
10
+
11
+ // Port this Fastify server listens on
12
+ port: Number(process.env.SELLER_PORT ?? 8378),
13
+
14
+ // Public-facing URL (what the registry tells buyers to connect to)
15
+ endpoint: process.env.SELLER_ENDPOINT ?? 'http://127.0.0.1:8378',
16
+
17
+ // Registry base URL
18
+ registryUrl: process.env.REGISTRY_URL ?? 'http://localhost:9000',
19
+
20
+ // How often (ms) to send a heartbeat to the registry
21
+ heartbeatIntervalMs: 30_000,
22
+
23
+ // Upstream API settings
24
+ upstream: {
25
+ // "openai" | "anthropic" | "ollama"
26
+ plugin: process.env.UPSTREAM_PLUGIN || 'openai',
27
+ baseUrl: process.env.UPSTREAM_BASE_URL || 'https://openrouter.ai/api',
28
+ apiKey: process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || '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'
29
+ },
30
+
31
+ // Offerings this node advertises
32
+ offerings: process.env.OFFERINGS ? JSON.parse(process.env.OFFERINGS) : [
33
+ {
34
+ capability: 'inference',
35
+ name: 'GPT-4o Mini',
36
+ description: 'Fast, affordable model for everyday tasks. Hosted via OpenRouter.',
37
+ services: ['gpt-4o-mini'],
38
+ pricing: {
39
+ inputUsdPerMillion: 0.15,
40
+ cachedInputUsdPerMillion: 0.075,
41
+ outputUsdPerMillion: 0.60
42
+ },
43
+ upstreamModel: 'openai/gpt-4o-mini'
44
+ }
45
+ ]
46
+ };
47
+ }
package/src/index.js ADDED
@@ -0,0 +1,223 @@
1
+ import Fastify from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import { loadConfig } from './config.js';
4
+ import { RegistryClient } from './registry-client.js';
5
+ import { UpstreamProxy } from './upstream-proxy.js';
6
+ import { PaymentChannelManager } from './payment-channel.js';
7
+ import { SellerRequestTracker } from './request-tracker.js';
8
+ import { createSellerMetrics } from './metrics.js';
9
+
10
+ const cfg = loadConfig();
11
+ const tracker = new SellerRequestTracker();
12
+ const { reg, m } = createSellerMetrics();
13
+ const _startTime = Date.now();
14
+
15
+ // ─── Build service map ────────────────────────────────────────────────────────
16
+ const serviceMap = new Map();
17
+ for (const offering of cfg.offerings) {
18
+ for (const serviceId of offering.services) {
19
+ serviceMap.set(serviceId, offering);
20
+ }
21
+ }
22
+
23
+ // ─── Upstream proxy ───────────────────────────────────────────────────────────
24
+ const proxy = new UpstreamProxy(cfg.upstream, serviceMap);
25
+
26
+ // ─── Payment channel manager ──────────────────────────────────────────────────
27
+ const payments = new PaymentChannelManager({
28
+ privateKey: process.env.SELLER_PRIVATE_KEY,
29
+ contractAddress: process.env.CONTRACT_ADDRESS,
30
+ chainName: process.env.CHAIN_NAME ?? 'localhost',
31
+ rpcUrl: process.env.RPC_URL ?? 'http://127.0.0.1:8545'
32
+ });
33
+
34
+ if (payments.paymentsEnabled) {
35
+ // If the seller registered no explicit merchant address, advertise the on-chain account address.
36
+ if (!process.env.MERCHANT_ADDRESS) {
37
+ cfg.merchantAddress = payments.account.address;
38
+ } else if (cfg.merchantAddress.toLowerCase() !== payments.account.address.toLowerCase()) {
39
+ console.warn('[seller] MERCHANT_ADDRESS does not match SELLER_PRIVATE_KEY address. Using SELLER_PRIVATE_KEY address for on-chain settlement.');
40
+ cfg.merchantAddress = payments.account.address;
41
+ }
42
+ }
43
+
44
+ // ─── Fastify instance ─────────────────────────────────────────────────────────
45
+ const app = Fastify({ logger: true });
46
+ await app.register(cors, { origin: '*' });
47
+
48
+ // ── GET /health ───────────────────────────────────────────────────────────────
49
+ app.get('/health', async () => ({
50
+ status: 'ok',
51
+ peerId: cfg.peerId,
52
+ offerings: cfg.offerings.map(o => o.name),
53
+ paymentsEnabled: payments.paymentsEnabled,
54
+ ...tracker.summary()
55
+ }));
56
+
57
+ // ── GET /offerings ────────────────────────────────────────────────────────────
58
+ app.get('/offerings', async () => ({ offerings: cfg.offerings }));
59
+
60
+ // ── GET /metrics (Prometheus) ────────────────────────────────────────────────
61
+ app.get('/metrics', async (req, reply) => {
62
+ const s = tracker.summary();
63
+ m.uptimeSeconds.set({}, s.uptimeSeconds);
64
+ m.successRate.set({}, s.successRate);
65
+ m.uniqueBuyers.set({}, s.uniqueBuyers);
66
+ m.earnedUsdcTotal.set({}, Number(BigInt(s.totalEarnedUsdc)));
67
+
68
+ const payStatus = payments.status();
69
+ m.activeChannels.set({}, payStatus.channels.filter(c => c.status === 'open').length);
70
+ m.unsettledUsdcTotal.set({}, payStatus.channels.reduce((acc, c) => {
71
+ const match = c.pendingSettlement?.replace(' USDC', '');
72
+ return acc + (parseFloat(match ?? 0) * 1_000_000);
73
+ }, 0));
74
+
75
+ reply.header('content-type', 'text/plain; version=0.0.4; charset=utf-8');
76
+ return reply.send(reg.format());
77
+ });
78
+
79
+ // ── GET /stats ────────────────────────────────────────────────────────────────
80
+ app.get('/stats', async () => tracker.summary());
81
+
82
+ // ── Payment session routes ────────────────────────────────────────────────────
83
+ app.post('/session/start', {
84
+ schema: {
85
+ body: {
86
+ type: 'object',
87
+ required: ['channelId', 'buyerAddress', 'sellerAddress', 'maxAmount', 'deadline', 'signature'],
88
+ properties: {
89
+ channelId: { type: 'string' },
90
+ buyerAddress: { type: 'string' },
91
+ sellerAddress: { type: 'string' },
92
+ maxAmount: { type: 'string' },
93
+ deadline: { type: 'string' },
94
+ signature: { type: 'string' }
95
+ }
96
+ }
97
+ }
98
+ }, async (req, reply) => {
99
+ try {
100
+ const result = await payments.handleReserveAuth({
101
+ ...req.body,
102
+ maxAmount: BigInt(req.body.maxAmount),
103
+ deadline: BigInt(req.body.deadline)
104
+ });
105
+ return result;
106
+ } catch (err) {
107
+ return reply.status(400).send({ error: { code: 'reserve_failed', message: err.message } });
108
+ }
109
+ });
110
+
111
+ app.post('/session/close', {
112
+ schema: {
113
+ body: {
114
+ type: 'object',
115
+ required: ['channelId'],
116
+ properties: { channelId: { type: 'string' } }
117
+ }
118
+ }
119
+ }, async (req) => {
120
+ await payments.closeChannel(req.body.channelId);
121
+ return { ok: true };
122
+ });
123
+
124
+ app.get('/payments/status', async () => payments.status());
125
+
126
+ // ─── Inference request handler ────────────────────────────────────────────────
127
+ async function handleInference(req, reply, upstreamPath) {
128
+ const serviceId = req.body?.model;
129
+ const buyerAddress = req.headers['x-openseed-buyer-address'] ?? 'unknown';
130
+
131
+ if (!serviceId) {
132
+ return reply.status(400).send({ error: { code: 'missing_model', message: 'The "model" field is required.' } });
133
+ }
134
+
135
+ // ── Payment validation ────────────────────────────────────────────────────
136
+ const spendingAuth = req.headers['x-402-payment-auth'];
137
+ const channelId = req.headers['x-402-channel-id'];
138
+
139
+ if (!spendingAuth || !channelId || !buyerAddress || buyerAddress === 'unknown') {
140
+ reply.header('x-402-payment-required', `price=1000000, to=${cfg.merchantAddress || cfg.peerId}`);
141
+ return reply.status(402).send({
142
+ error: {
143
+ code: 'payment_required',
144
+ message: 'Include x-402-payment-auth, x-402-channel-id, and x-openseed-buyer-address headers.'
145
+ }
146
+ });
147
+ }
148
+
149
+ if (payments.paymentsEnabled) {
150
+ try {
151
+ const parsed = JSON.parse(Buffer.from(spendingAuth, 'base64').toString('utf8'));
152
+ await payments.validateSpendingAuth({
153
+ channelId,
154
+ cumulativeAmount: BigInt(parsed.cumulativeAmount),
155
+ metadataHash: parsed.metadataHash,
156
+ signature: parsed.signature,
157
+ buyerAddress
158
+ });
159
+ } catch (err) {
160
+ return reply.status(402).send({ error: { code: 'invalid_spending_auth', message: err.message } });
161
+ }
162
+ }
163
+
164
+ const t0 = Date.now();
165
+ const isAnthropic = upstreamPath.includes('messages');
166
+
167
+ try {
168
+ // We need to capture the response to extract token counts before streaming
169
+ // For streaming responses we record with 0 tokens (good enough for metrics)
170
+ await proxy.forward(serviceId, isAnthropic ? 'anthropic' : 'openai', req.body, reply);
171
+
172
+ const latencyMs = Date.now() - t0;
173
+ tracker.record({ latencyMs, success: true, model: serviceId, buyerAddress });
174
+ m.requestsTotal.inc({ model: serviceId, status: '200' });
175
+ m.upstreamDurationMs.observe({ model: serviceId }, latencyMs);
176
+ } catch (err) {
177
+ const latencyMs = Date.now() - t0;
178
+ tracker.record({ latencyMs, success: false, model: serviceId, buyerAddress });
179
+ m.requestsTotal.inc({ model: serviceId, status: '502' });
180
+ }
181
+ }
182
+
183
+ app.post('/v1/chat/completions', (req, reply) => handleInference(req, reply, '/v1/chat/completions'));
184
+ app.post('/v1/messages', (req, reply) => handleInference(req, reply, '/v1/messages'));
185
+ app.post('/v1/responses', (req, reply) => handleInference(req, reply, '/v1/responses'));
186
+
187
+ // ─── Registry client ──────────────────────────────────────────────────────────
188
+ const registry = new RegistryClient(cfg.registryUrl, {
189
+ peerId: cfg.peerId,
190
+ endpoint: cfg.endpoint,
191
+ offerings: cfg.offerings,
192
+ merchantAddress: cfg.merchantAddress
193
+ });
194
+
195
+ // ─── Start ────────────────────────────────────────────────────────────────────
196
+ try {
197
+ await app.listen({ port: cfg.port, host: '0.0.0.0' });
198
+ console.log(`
199
+ 🐜 OpenSeed Seller http://0.0.0.0:${cfg.port}
200
+ Peer ID : ${cfg.peerId}
201
+ Offerings: ${cfg.offerings.map(o => o.name).join(', ')}
202
+ Payments : ${payments.paymentsEnabled ? 'enabled' : 'disabled'}
203
+
204
+ Observability:
205
+ GET /metrics (Prometheus)
206
+ GET /stats (JSON summary)
207
+ GET /health (liveness)
208
+ `);
209
+ await registry.register();
210
+ registry.startHeartbeat(cfg.heartbeatIntervalMs);
211
+ } catch (err) {
212
+ app.log.error(err);
213
+ process.exit(1);
214
+ }
215
+
216
+ async function shutdown() {
217
+ console.log('\n[seller] Shutting down…');
218
+ await registry.deregister();
219
+ await app.close();
220
+ process.exit(0);
221
+ }
222
+ process.on('SIGINT', shutdown);
223
+ process.on('SIGTERM', shutdown);
package/src/metrics.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Seller Prometheus metrics registry.
3
+ */
4
+
5
+ import { PrometheusRegistry } from '@0xopenseeddev/shared';
6
+
7
+ export function createSellerMetrics() {
8
+ const reg = new PrometheusRegistry();
9
+
10
+ const m = {
11
+ // ── Inference requests ───────────────────────────────────────────────────
12
+ requestsTotal: reg.counter(
13
+ 'openseed_seller_requests_total',
14
+ 'Total inference requests served',
15
+ ['model', 'status']
16
+ ),
17
+
18
+ upstreamDurationMs: reg.histogram(
19
+ 'openseed_seller_upstream_duration_ms',
20
+ 'Latency of upstream AI API calls in milliseconds',
21
+ [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
22
+ ['model']
23
+ ),
24
+
25
+ // ── Tokens ───────────────────────────────────────────────────────────────
26
+ inputTokensTotal: reg.counter(
27
+ 'openseed_seller_input_tokens_total',
28
+ 'Total input tokens processed',
29
+ ['model']
30
+ ),
31
+
32
+ outputTokensTotal: reg.counter(
33
+ 'openseed_seller_output_tokens_total',
34
+ 'Total output tokens generated',
35
+ ['model']
36
+ ),
37
+
38
+ // ── Earnings ─────────────────────────────────────────────────────────────
39
+ earnedUsdcTotal: reg.gauge(
40
+ 'openseed_seller_earned_usdc_total',
41
+ 'Cumulative USDC earned (base units, 6 decimal)'
42
+ ),
43
+
44
+ unsettledUsdcTotal: reg.gauge(
45
+ 'openseed_seller_unsettled_usdc_total',
46
+ 'USDC earned but not yet settled on-chain'
47
+ ),
48
+
49
+ activeChannels: reg.gauge(
50
+ 'openseed_seller_active_channels',
51
+ 'Number of currently open payment channels'
52
+ ),
53
+
54
+ // ── Node health ───────────────────────────────────────────────────────────
55
+ uptimeSeconds: reg.gauge(
56
+ 'openseed_seller_uptime_seconds',
57
+ 'Seconds since the seller node started'
58
+ ),
59
+
60
+ uniqueBuyers: reg.gauge(
61
+ 'openseed_seller_unique_buyers_total',
62
+ 'Number of distinct buyer addresses seen'
63
+ ),
64
+
65
+ successRate: reg.gauge(
66
+ 'openseed_seller_success_rate',
67
+ 'Ratio of successful requests to total requests (0-1)'
68
+ )
69
+ };
70
+
71
+ return { reg, m };
72
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Seller-side payment channel manager.
3
+ *
4
+ * Responsibilities:
5
+ * 1. Validate incoming SpendingAuth signatures from buyers
6
+ * 2. Track cumulative spend per channel in memory
7
+ * 3. Trigger on-chain settle() calls periodically or at thresholds
8
+ * 4. Call on-chain close() when a session ends
9
+ *
10
+ * The seller node is the on-chain actor:
11
+ * - It submits reserve() when starting a session
12
+ * - It submits settle() to collect earned USDC
13
+ * - It submits close() when done
14
+ */
15
+
16
+ import {
17
+ createWalletClient,
18
+ createPublicClient,
19
+ http,
20
+ parseAbi,
21
+ formatUnits
22
+ } from 'viem';
23
+ import { privateKeyToAccount } from 'viem/accounts';
24
+ import {
25
+ recoverSpendingAuthSigner,
26
+ recoverReserveAuthSigner,
27
+ buildDomain,
28
+ parseUsdc,
29
+ formatUsdc,
30
+ getChain
31
+ } from '@0xopenseeddev/shared';
32
+
33
+ // ─── Minimal ABI for the AntseedDeposits contract ─────────────────────────────
34
+
35
+ const DEPOSITS_ABI = parseAbi([
36
+ 'function reserve(bytes32 channelId, address buyer, uint256 maxAmount, uint256 deadline, bytes calldata signature) external',
37
+ 'function settle(bytes32 channelId, uint256 cumulativeAmount, bytes32 metadataHash, bytes calldata signature) external',
38
+ 'function close(bytes32 channelId, uint256 cumulativeAmount, bytes32 metadataHash, bytes calldata signature) external',
39
+ 'function channels(bytes32) external view returns (address buyer, address seller, uint256 maxAmount, uint256 settled, uint256 deadline, uint256 closeRequestedAt, bool closed)',
40
+ 'event Settled(bytes32 indexed channelId, uint256 delta, uint256 cumulative)',
41
+ 'event ChannelClosed(bytes32 indexed channelId, uint256 totalSettled)'
42
+ ]);
43
+
44
+ export class PaymentChannelManager {
45
+ /**
46
+ * @param {object} opts
47
+ * @param {string} opts.privateKey - seller's 0x-prefixed private key
48
+ * @param {string} opts.contractAddress - AntseedDeposits contract address
49
+ * @param {string} opts.chainName - 'base' | 'base-sepolia' | 'localhost'
50
+ * @param {string} opts.rpcUrl - RPC endpoint for the chain
51
+ * @param {number} [opts.settleThresholdUsdc=0.05] - auto-settle when earned > this
52
+ * @param {number} [opts.settleIntervalMs=300_000] - max interval between settles (5 min)
53
+ */
54
+ constructor({
55
+ privateKey,
56
+ contractAddress,
57
+ chainName = 'localhost',
58
+ rpcUrl = 'http://127.0.0.1:8545',
59
+ settleThresholdUsdc = 0.05,
60
+ settleIntervalMs = 300_000
61
+ }) {
62
+ this.paymentsEnabled = !!privateKey && !!contractAddress;
63
+ this.contractAddress = contractAddress;
64
+ this.settleThreshold = parseUsdc(settleThresholdUsdc);
65
+ this.settleIntervalMs = settleIntervalMs;
66
+
67
+ /** @type {Map<string, ChannelEntry>} channelId → state */
68
+ this._channels = new Map();
69
+
70
+ if (!this.paymentsEnabled) {
71
+ console.log('[payments] ⚠️ No SELLER_PRIVATE_KEY / CONTRACT_ADDRESS — running in no-payment mode.');
72
+ return;
73
+ }
74
+
75
+ const chain = getChain(chainName);
76
+ const account = privateKeyToAccount(privateKey);
77
+ this.account = account;
78
+ this._signerOpts = { contractAddress, chainId: chain.id };
79
+
80
+ this._walletClient = createWalletClient({
81
+ account,
82
+ chain,
83
+ transport: http(rpcUrl)
84
+ });
85
+ this._publicClient = createPublicClient({
86
+ chain,
87
+ transport: http(rpcUrl)
88
+ });
89
+
90
+ console.log(`[payments] Seller wallet: ${account.address}`);
91
+ console.log(`[payments] Contract: ${contractAddress}`);
92
+ console.log(`[payments] Chain: ${chainName} (${chain.id})`);
93
+ }
94
+
95
+ // ─── Session initiation ────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Process a ReserveAuth from a buyer.
99
+ * Validates the signature, calls reserve() on-chain, opens local state.
100
+ *
101
+ * @param {{ channelId, buyerAddress, sellerAddress, maxAmount, deadline, signature }} params
102
+ */
103
+ async handleReserveAuth({ channelId, buyerAddress, sellerAddress, maxAmount, deadline, signature }) {
104
+ if (!this.paymentsEnabled) {
105
+ // In no-payment mode, just trust the buyer and open a dummy channel
106
+ this._openChannel(channelId, buyerAddress, maxAmount);
107
+ return { ok: true, channelId, mode: 'no-payment' };
108
+ }
109
+
110
+ // 1. Verify signature off-chain first (free gas check)
111
+ const recovered = await recoverReserveAuthSigner(this._signerOpts, {
112
+ channelId,
113
+ buyer: buyerAddress,
114
+ seller: sellerAddress,
115
+ maxAmount: BigInt(maxAmount),
116
+ deadline: BigInt(deadline)
117
+ }, signature);
118
+
119
+ if (recovered.toLowerCase() !== buyerAddress.toLowerCase()) {
120
+ throw new Error('ReserveAuth: invalid buyer signature');
121
+ }
122
+
123
+ // 2. Submit reserve() on-chain
124
+ try {
125
+ const txHash = await this._walletClient.writeContract({
126
+ address: this.contractAddress,
127
+ abi: DEPOSITS_ABI,
128
+ functionName: 'reserve',
129
+ args: [channelId, buyerAddress, BigInt(maxAmount), BigInt(deadline), signature]
130
+ });
131
+ await this._publicClient.waitForTransactionReceipt({ hash: txHash });
132
+ console.log(`[payments] ✅ reserve() confirmed: ${txHash}`);
133
+ } catch (err) {
134
+ throw new Error(`reserve() failed: ${err.shortMessage ?? err.message}`);
135
+ }
136
+
137
+ this._openChannel(channelId, buyerAddress, BigInt(maxAmount));
138
+ return { ok: true, channelId };
139
+ }
140
+
141
+ // ─── Per-request payment validation ───────────────────────────────────────
142
+
143
+ /**
144
+ * Validate an inbound SpendingAuth header from the buyer.
145
+ * Returns the verified spend or throws.
146
+ *
147
+ * @param {{ channelId, cumulativeAmount, metadataHash, signature, buyerAddress }} params
148
+ * @returns {{ delta: bigint }} USDC earned since last recorded spend
149
+ */
150
+ async validateSpendingAuth({ channelId, cumulativeAmount, metadataHash, signature, buyerAddress }) {
151
+ if (!this.paymentsEnabled) return { delta: 0n };
152
+
153
+ const recovered = await recoverSpendingAuthSigner(this._signerOpts, {
154
+ channelId,
155
+ cumulativeAmount: BigInt(cumulativeAmount),
156
+ metadataHash
157
+ }, signature);
158
+
159
+ if (recovered.toLowerCase() !== buyerAddress.toLowerCase()) {
160
+ throw new Error('SpendingAuth: invalid buyer signature');
161
+ }
162
+
163
+ const ch = this._channels.get(channelId);
164
+ if (!ch) throw new Error('No open channel for this channelId');
165
+
166
+ const newAmount = BigInt(cumulativeAmount);
167
+ if (newAmount <= ch.lastValidatedCumulative) {
168
+ throw new Error('SpendingAuth: stale — cumulativeAmount did not increase');
169
+ }
170
+ if (newAmount > ch.maxAmount) {
171
+ throw new Error('SpendingAuth: exceeds channel maxAmount');
172
+ }
173
+
174
+ const delta = newAmount - ch.lastValidatedCumulative;
175
+ ch.pendingCumulative = newAmount;
176
+ ch.pendingMetadataHash = metadataHash;
177
+ ch.pendingSignature = signature;
178
+ ch.lastValidatedCumulative = newAmount;
179
+ ch.earnedUnsettled += delta;
180
+
181
+ // Immediately settle on-chain after a valid SpendingAuth.
182
+ // This ensures seller-side transfer occurs promptly rather than waiting
183
+ // for an arbitrary threshold or channel close.
184
+ try {
185
+ await this._triggerSettle(channelId);
186
+ } catch (err) {
187
+ console.error('[payments] settle() failed after SpendingAuth:', err.message);
188
+ }
189
+
190
+ return { delta };
191
+ }
192
+
193
+ // ─── On-chain settlement ───────────────────────────────────────────────────
194
+
195
+ async _triggerSettle(channelId) {
196
+ const ch = this._channels.get(channelId);
197
+ if (!ch || ch.status !== 'open') return;
198
+ if (!ch.pendingSignature) return;
199
+
200
+ console.log(`[payments] Settling ${formatUsdc(ch.earnedUnsettled)} USDC for channel ${channelId.slice(0, 18)}…`);
201
+ try {
202
+ const txHash = await this._walletClient.writeContract({
203
+ address: this.contractAddress,
204
+ abi: DEPOSITS_ABI,
205
+ functionName: 'settle',
206
+ args: [channelId, ch.pendingCumulative, ch.pendingMetadataHash, ch.pendingSignature]
207
+ });
208
+ await this._publicClient.waitForTransactionReceipt({ hash: txHash });
209
+ ch.settledCumulative = ch.pendingCumulative;
210
+ ch.earnedUnsettled = 0n;
211
+ console.log(`[payments] ✅ settle() confirmed: ${txHash}`);
212
+ } catch (err) {
213
+ console.error('[payments] settle() failed:', err.shortMessage ?? err.message);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Close the channel on-chain and release unspent buyer funds.
219
+ */
220
+ async closeChannel(channelId) {
221
+ const ch = this._channels.get(channelId);
222
+ if (!ch) return;
223
+
224
+ if (this.paymentsEnabled && ch.pendingSignature) {
225
+ console.log(`[payments] Closing channel ${channelId.slice(0, 18)}…`);
226
+ try {
227
+ const txHash = await this._walletClient.writeContract({
228
+ address: this.contractAddress,
229
+ abi: DEPOSITS_ABI,
230
+ functionName: 'close',
231
+ args: [channelId, ch.pendingCumulative, ch.pendingMetadataHash, ch.pendingSignature]
232
+ });
233
+ await this._publicClient.waitForTransactionReceipt({ hash: txHash });
234
+ console.log(`[payments] ✅ close() confirmed: ${txHash}`);
235
+ } catch (err) {
236
+ console.error('[payments] close() failed:', err.shortMessage ?? err.message);
237
+ }
238
+ }
239
+
240
+ ch.status = 'closed';
241
+ ch.closedAt = Date.now();
242
+ }
243
+
244
+ // ─── Internal helpers ──────────────────────────────────────────────────────
245
+
246
+ _openChannel(channelId, buyerAddress, maxAmount) {
247
+ this._channels.set(channelId, {
248
+ channelId,
249
+ buyerAddress,
250
+ maxAmount,
251
+ status: 'open',
252
+ openedAt: Date.now(),
253
+ lastValidatedCumulative: 0n,
254
+ pendingCumulative: 0n,
255
+ pendingMetadataHash: null,
256
+ pendingSignature: null,
257
+ settledCumulative: 0n,
258
+ earnedUnsettled: 0n
259
+ });
260
+ console.log(`[payments] Opened channel ${channelId.slice(0, 18)}… | max: ${formatUsdc(maxAmount)} USDC`);
261
+ }
262
+
263
+ // ─── Status / metrics ──────────────────────────────────────────────────────
264
+
265
+ status() {
266
+ const channels = [...this._channels.values()].map(ch => ({
267
+ channelId: ch.channelId,
268
+ buyerAddress: ch.buyerAddress,
269
+ status: ch.status,
270
+ maxAmount: formatUsdc(ch.maxAmount) + ' USDC',
271
+ settled: formatUsdc(ch.settledCumulative) + ' USDC',
272
+ pendingSettlement: formatUsdc(ch.earnedUnsettled) + ' USDC',
273
+ openedAt: new Date(ch.openedAt).toISOString()
274
+ }));
275
+ return { paymentsEnabled: this.paymentsEnabled, channels };
276
+ }
277
+ }
@@ -0,0 +1,60 @@
1
+ import { fetch } from 'undici';
2
+
3
+ /**
4
+ * Registry client used by the seller node.
5
+ * Handles registration, heartbeating, and graceful deregistration.
6
+ */
7
+ export class RegistryClient {
8
+ /**
9
+ * @param {string} registryUrl
10
+ * @param {{ peerId: string, endpoint: string, services: any[] }} nodeInfo
11
+ */
12
+ constructor(registryUrl, nodeInfo) {
13
+ this.registryUrl = registryUrl;
14
+ this.nodeInfo = nodeInfo;
15
+ this._timer = null;
16
+ }
17
+
18
+ async register() {
19
+ const res = await fetch(`${this.registryUrl}/peers/register`, {
20
+ method: 'POST',
21
+ headers: { 'content-type': 'application/json' },
22
+ body: JSON.stringify(this.nodeInfo)
23
+ });
24
+ if (!res.ok) {
25
+ const text = await res.text();
26
+ throw new Error(`Registry registration failed: ${res.status} ${text}`);
27
+ }
28
+ console.log(`[seller] ✅ Registered with registry as ${this.nodeInfo.peerId.slice(0, 16)}…`);
29
+ }
30
+
31
+ startHeartbeat(intervalMs = 30_000) {
32
+ this._timer = setInterval(async () => {
33
+ try {
34
+ const res = await fetch(`${this.registryUrl}/peers/heartbeat`, {
35
+ method: 'POST',
36
+ headers: { 'content-type': 'application/json' },
37
+ body: JSON.stringify({ peerId: this.nodeInfo.peerId })
38
+ });
39
+ if (!res.ok) {
40
+ console.warn('[seller] ⚠️ Heartbeat rejected — re-registering…');
41
+ await this.register();
42
+ }
43
+ } catch (err) {
44
+ console.error('[seller] ❌ Heartbeat error:', err.message);
45
+ }
46
+ }, intervalMs);
47
+ }
48
+
49
+ async deregister() {
50
+ clearInterval(this._timer);
51
+ try {
52
+ await fetch(`${this.registryUrl}/peers/${this.nodeInfo.peerId}`, {
53
+ method: 'DELETE'
54
+ });
55
+ console.log('[seller] 👋 Deregistered from registry.');
56
+ } catch {
57
+ // best-effort
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Seller-side request tracker.
3
+ * Tracks requests served, upstream latency, token counts, and earnings.
4
+ */
5
+
6
+ export class SellerRequestTracker {
7
+ constructor() {
8
+ this._start = Date.now();
9
+ this.totalRequests = 0;
10
+ this.successfulRequests = 0;
11
+ this.failedRequests = 0;
12
+ this.totalInputTokens = 0;
13
+ this.totalOutputTokens = 0;
14
+ this.totalEarnedUsdc = 0n;
15
+ this.latencySamples = []; // rolling 100
16
+ this.requestsByModel = new Map(); // model → count
17
+ this.requestsByBuyer = new Map(); // buyerAddr → count
18
+ }
19
+
20
+ /**
21
+ * @param {object} opts
22
+ * @param {number} opts.latencyMs
23
+ * @param {boolean} opts.success
24
+ * @param {number} [opts.inputTokens]
25
+ * @param {number} [opts.outputTokens]
26
+ * @param {bigint} [opts.earnedUsdc]
27
+ * @param {string} [opts.model]
28
+ * @param {string} [opts.buyerAddress]
29
+ */
30
+ record({ latencyMs, success, inputTokens = 0, outputTokens = 0, earnedUsdc = 0n, model = 'unknown', buyerAddress = 'unknown' }) {
31
+ this.totalRequests++;
32
+ success ? this.successfulRequests++ : this.failedRequests++;
33
+
34
+ this.latencySamples.push(latencyMs);
35
+ if (this.latencySamples.length > 100) this.latencySamples.shift();
36
+
37
+ this.totalInputTokens += inputTokens;
38
+ this.totalOutputTokens += outputTokens;
39
+ this.totalEarnedUsdc += earnedUsdc;
40
+
41
+ this.requestsByModel.set(model, (this.requestsByModel.get(model) ?? 0) + 1);
42
+ this.requestsByBuyer.set(buyerAddress, (this.requestsByBuyer.get(buyerAddress) ?? 0) + 1);
43
+ }
44
+
45
+ summary() {
46
+ const sorted = [...this.latencySamples].sort((a, b) => a - b);
47
+ const p50 = pct(sorted, 50);
48
+ const p95 = pct(sorted, 95);
49
+ const p99 = pct(sorted, 99);
50
+
51
+ return {
52
+ uptimeSeconds: Math.floor((Date.now() - this._start) / 1000),
53
+ totalRequests: this.totalRequests,
54
+ successfulRequests: this.successfulRequests,
55
+ failedRequests: this.failedRequests,
56
+ successRate: this.totalRequests > 0
57
+ ? +((this.successfulRequests / this.totalRequests).toFixed(4))
58
+ : 1,
59
+ latency: { p50, p95, p99, sampleCount: sorted.length },
60
+ totalInputTokens: this.totalInputTokens,
61
+ totalOutputTokens: this.totalOutputTokens,
62
+ totalEarnedUsdc: this.totalEarnedUsdc.toString(),
63
+ requestsByModel: Object.fromEntries(this.requestsByModel),
64
+ uniqueBuyers: this.requestsByBuyer.size
65
+ };
66
+ }
67
+ }
68
+
69
+ function pct(sorted, p) {
70
+ if (!sorted.length) return 0;
71
+ return +(sorted[Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)] ?? 0).toFixed(1);
72
+ }
@@ -0,0 +1,88 @@
1
+ import { fetch } from 'undici';
2
+
3
+ /**
4
+ * Upstream proxy — forwards inference requests to the configured
5
+ * AI API (OpenAI-compatible or Anthropic) and streams the response back.
6
+ */
7
+ export class UpstreamProxy {
8
+ /**
9
+ * @param {{ plugin: string, baseUrl: string, apiKey: string }} upstream
10
+ * @param {Map<string, any>} serviceMap - serviceId → ServiceDefinition
11
+ */
12
+ constructor(upstream, serviceMap) {
13
+ this.upstream = upstream;
14
+ this.serviceMap = serviceMap;
15
+ }
16
+
17
+ /**
18
+ * Resolve the correct upstream path and headers for the given format.
19
+ * @param {'openai'|'anthropic'} format
20
+ * @returns {{ path: string, headers: Record<string,string> }}
21
+ */
22
+ _resolveTarget(format) {
23
+ if (format === 'anthropic') {
24
+ return {
25
+ path: '/v1/messages',
26
+ headers: {
27
+ 'x-api-key': this.upstream.apiKey,
28
+ 'anthropic-version': '2023-06-01',
29
+ 'content-type': 'application/json'
30
+ }
31
+ };
32
+ }
33
+ // default: openai-compatible
34
+ return {
35
+ path: '/v1/chat/completions',
36
+ headers: {
37
+ 'authorization': `Bearer ${this.upstream.apiKey}`,
38
+ 'content-type': 'application/json'
39
+ }
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Forward a parsed request body to the upstream API and pipe the
45
+ * raw response back through the Fastify reply (supports streaming).
46
+ *
47
+ * @param {string} serviceId - buyer-requested model / service name
48
+ * @param {string} format - 'openai' | 'anthropic'
49
+ * @param {object} body - parsed JSON body from the buyer
50
+ * @param {import('fastify').FastifyReply} reply
51
+ */
52
+ async forward(serviceId, format, body, reply) {
53
+ const service = this.serviceMap.get(serviceId);
54
+ if (!service) {
55
+ return reply.status(404).send({
56
+ error: { code: 'service_not_found', message: `Service "${serviceId}" is not offered by this node.` }
57
+ });
58
+ }
59
+
60
+ // Rewrite the model field to the upstream model name and set a safe max_tokens default
61
+ const upstreamBody = { max_tokens: 1000, ...body, model: service.upstreamModel };
62
+ const { path, headers } = this._resolveTarget(format);
63
+ const url = `${this.upstream.baseUrl}${path}`;
64
+
65
+ try {
66
+ const upstreamRes = await fetch(url, {
67
+ method: 'POST',
68
+ headers,
69
+ body: JSON.stringify(upstreamBody)
70
+ });
71
+
72
+ // Mirror status and content-type
73
+ reply.status(upstreamRes.status);
74
+ const ct = upstreamRes.headers.get('content-type') ?? 'application/json';
75
+ reply.header('content-type', ct);
76
+
77
+ // Stream the body straight through
78
+ if (upstreamRes.body) {
79
+ return reply.send(upstreamRes.body);
80
+ }
81
+ return reply.send(await upstreamRes.text());
82
+ } catch (err) {
83
+ return reply.status(502).send({
84
+ error: { code: 'upstream_error', message: err.message }
85
+ });
86
+ }
87
+ }
88
+ }