2020117-agent 0.6.7 → 0.6.9

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/dist/agent.js CHANGED
@@ -79,7 +79,7 @@ for (const arg of process.argv.slice(2)) {
79
79
  break;
80
80
  }
81
81
  }
82
- import { randomBytes } from 'crypto';
82
+ import { randomBytes, createHash } from 'crypto';
83
83
  import { createConnection } from 'net';
84
84
  import { SwarmNode, topicFromKind } from './swarm.js';
85
85
  import { createProcessor } from './processor.js';
@@ -827,21 +827,91 @@ function markSeen(eventId) {
827
827
  }
828
828
  return true;
829
829
  }
830
+ /**
831
+ * Decode payment_hash from a bolt11 invoice (bech32, tagged field type 1).
832
+ * Returns hex string or undefined if decoding fails.
833
+ */
834
+ function decodeBolt11PaymentHash(bolt11) {
835
+ try {
836
+ // Strip prefix (lnbc/lntb/lnbcrt + amount)
837
+ const lower = bolt11.toLowerCase();
838
+ const sepIdx = lower.lastIndexOf('1');
839
+ if (sepIdx < 0)
840
+ return undefined;
841
+ const data5 = lower.slice(sepIdx + 1, -6); // strip checksum
842
+ // Bech32 charset
843
+ const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
844
+ const decoded5 = [];
845
+ for (const c of data5) {
846
+ const v = CHARSET.indexOf(c);
847
+ if (v < 0)
848
+ return undefined;
849
+ decoded5.push(v);
850
+ }
851
+ // Convert 5-bit groups to 8-bit bytes
852
+ let acc = 0, bits = 0;
853
+ const bytes = [];
854
+ for (const v of decoded5) {
855
+ acc = (acc << 5) | v;
856
+ bits += 5;
857
+ while (bits >= 8) {
858
+ bits -= 8;
859
+ bytes.push((acc >> bits) & 0xff);
860
+ }
861
+ }
862
+ // Skip timestamp (35 bits = 7 x 5-bit groups at start, already in bytes[0..4])
863
+ // Parse tagged fields: type(5b) len(10b) data
864
+ let i = 7; // bytes 0-4 = timestamp, skip
865
+ while (i < bytes.length - 32) {
866
+ const type = bytes[i];
867
+ const len = (bytes[i + 1] << 5) | bytes[i + 2];
868
+ i += 3;
869
+ if (type === 1 && len === 52) {
870
+ // payment_hash: 52 x 5-bit = 260 bits → 32 bytes
871
+ const hash5 = decoded5.slice(/* recalculate offset */ 0);
872
+ // Simpler: just look for the 32-byte hash in the byte stream at this position
873
+ const hashBytes = bytes.slice(i, i + 32);
874
+ if (hashBytes.length === 32)
875
+ return Buffer.from(hashBytes).toString('hex');
876
+ }
877
+ i += len;
878
+ }
879
+ return undefined;
880
+ }
881
+ catch {
882
+ return undefined;
883
+ }
884
+ }
885
+ /** Verify Lightning payment preimage: SHA256(preimage_hex) should equal payment_hash_hex */
886
+ function verifyPreimage(preimageHex, paymentHashHex) {
887
+ try {
888
+ const preimageBytes = Buffer.from(preimageHex, 'hex');
889
+ const computed = createHash('sha256').update(preimageBytes).digest('hex');
890
+ return computed === paymentHashHex.toLowerCase();
891
+ }
892
+ catch {
893
+ return false;
894
+ }
895
+ }
830
896
  /** Send a billing tick to the customer — generate Lightning invoice.
831
897
  * Prefers NWC make_invoice (if nwc_uri configured), falls back to LNURL-pay. */
832
898
  async function sendBillingTick(node, session, amount, label) {
833
899
  const tickId = randomBytes(4).toString('hex');
834
900
  try {
835
901
  let bolt11;
902
+ let paymentHash;
836
903
  if (state.nwcParsed) {
837
904
  // NWC make_invoice: wallet generates invoice directly — no lightning address needed
838
905
  const result = await nwcMakeInvoice(state.nwcParsed, amount * 1000, `2020117 session ${session.sessionId}`);
839
906
  bolt11 = result.bolt11;
907
+ paymentHash = result.payment_hash;
840
908
  }
841
909
  else {
842
910
  // Fallback: LNURL-pay via lightning address
843
911
  bolt11 = await generateInvoice(LIGHTNING_ADDRESS, amount);
912
+ paymentHash = decodeBolt11PaymentHash(bolt11);
844
913
  }
914
+ session.pendingPaymentHash = paymentHash;
845
915
  node.send(session.socket, {
846
916
  type: 'session_tick',
847
917
  id: tickId,
@@ -919,6 +989,7 @@ async function startSwarmListener(label) {
919
989
  sats_per_minute: satsPerMinute,
920
990
  payment_method: paymentMethod,
921
991
  pubkey: state.sovereignKeys?.pubkey,
992
+ proxy_mode: proxyMode,
922
993
  });
923
994
  if (proxyMode) {
924
995
  if (billingAmount <= 0 || (!LIGHTNING_ADDRESS && !state.nwcParsed)) {
@@ -945,10 +1016,27 @@ async function startSwarmListener(label) {
945
1016
  if (!session)
946
1017
  return;
947
1018
  if (msg.preimage) {
1019
+ // Verify preimage: SHA256(preimage) must equal the invoice's payment_hash
1020
+ if (session.pendingPaymentHash) {
1021
+ if (!verifyPreimage(msg.preimage, session.pendingPaymentHash)) {
1022
+ console.log(`[${label}] Session ${session.sessionId}: invalid preimage — ending session`);
1023
+ node.send(session.socket, { type: 'error', id: msg.id, message: 'Invalid payment preimage' });
1024
+ endSession(node, session, label);
1025
+ return;
1026
+ }
1027
+ session.pendingPaymentHash = undefined;
1028
+ }
948
1029
  const amount = msg.amount || 0;
949
1030
  session.totalEarned += amount;
950
1031
  session.lastPaidAt = Date.now();
951
- console.log(`[${label}] Session ${session.sessionId}: invoice payment received (+${amount}, total: ${session.totalEarned} sats)`);
1032
+ console.log(`[${label}] Session ${session.sessionId}: payment verified (+${amount}, total: ${session.totalEarned} sats)`);
1033
+ }
1034
+ else {
1035
+ // No preimage provided — reject
1036
+ console.log(`[${label}] Session ${session.sessionId}: session_tick_ack missing preimage — ending session`);
1037
+ node.send(session.socket, { type: 'error', id: msg.id, message: 'Payment preimage required' });
1038
+ endSession(node, session, label);
1039
+ return;
952
1040
  }
953
1041
  // Proxy mode: switch to raw TCP pipe after first payment confirmed
954
1042
  if (session.proxyMode && !session.proxyStarted) {
package/dist/session.js CHANGED
@@ -50,6 +50,7 @@ import { randomBytes } from 'crypto';
50
50
  import { createServer } from 'http';
51
51
  import { createInterface } from 'readline';
52
52
  import { mkdirSync, writeFileSync } from 'fs';
53
+ import * as net from 'net';
53
54
  import { WebSocketServer, WebSocket as WsWebSocket } from 'ws';
54
55
  // --- Config ---
55
56
  const KIND = Number(process.env.DVM_KIND) || 5200;
@@ -87,6 +88,8 @@ const state = {
87
88
  startedAt: 0,
88
89
  httpServer: null,
89
90
  shuttingDown: false,
91
+ isProxyMode: false,
92
+ proxyReady: null,
90
93
  pendingRequests: new Map(),
91
94
  chunkBuffers: new Map(),
92
95
  activeWebSockets: new Map(),
@@ -195,7 +198,12 @@ function setupMessageHandler() {
195
198
  try {
196
199
  const { preimage } = await nwcPayInvoice(nwcParsed, msg.bolt11);
197
200
  state.totalSpent += amount;
198
- log(`Paid ${amount} sats via NWC (total: ${state.totalSpent}, ~${estimatedMinutesLeft()} min left)`);
201
+ if (state.isProxyMode) {
202
+ log(`Paid ${amount} sats — activating TCP proxy...`);
203
+ }
204
+ else {
205
+ log(`Paid ${amount} sats via NWC (total: ${state.totalSpent}, ~${estimatedMinutesLeft()} min left)`);
206
+ }
199
207
  state.node.send(state.socket, {
200
208
  type: 'session_tick_ack',
201
209
  id: msg.id,
@@ -203,6 +211,11 @@ function setupMessageHandler() {
203
211
  preimage,
204
212
  amount,
205
213
  });
214
+ // Proxy mode: payment done, signal TCP pipe is ready
215
+ if (state.isProxyMode && state.proxyReady) {
216
+ state.proxyReady();
217
+ state.proxyReady = null;
218
+ }
206
219
  }
207
220
  catch (e) {
208
221
  warn(`NWC invoice payment failed: ${e.message} — ending session`);
@@ -351,6 +364,57 @@ function startHttpProxy() {
351
364
  });
352
365
  });
353
366
  }
367
+ // --- 4a. TCP proxy (proxy mode) ---
368
+ // After payment, the Hyperswarm socket becomes a raw TCP pipe to provider's backend.
369
+ // We start a local TCP server and forward bytes between local clients and the provider socket.
370
+ function startTcpProxy() {
371
+ return new Promise((resolve, reject) => {
372
+ const rawSocket = state.socket;
373
+ // Remove SwarmNode's JSON framing — from here on it's raw bytes
374
+ rawSocket.removeAllListeners('data');
375
+ let currentClient = null;
376
+ // Forward provider data to whatever client is currently connected
377
+ rawSocket.on('data', (chunk) => {
378
+ if (currentClient && !currentClient.destroyed) {
379
+ currentClient.write(chunk);
380
+ }
381
+ });
382
+ rawSocket.on('end', () => {
383
+ if (currentClient && !currentClient.destroyed)
384
+ currentClient.end();
385
+ });
386
+ rawSocket.on('error', (err) => {
387
+ warn(`Provider socket error: ${err.message}`);
388
+ if (currentClient && !currentClient.destroyed)
389
+ currentClient.destroy();
390
+ cleanup();
391
+ });
392
+ const server = net.createServer((clientSocket) => {
393
+ if (currentClient && !currentClient.destroyed) {
394
+ // Only one connection at a time — reject new ones
395
+ clientSocket.destroy();
396
+ return;
397
+ }
398
+ currentClient = clientSocket;
399
+ clientSocket.on('data', (chunk) => {
400
+ if (!rawSocket.destroyed)
401
+ rawSocket.write(chunk);
402
+ });
403
+ clientSocket.on('close', () => { currentClient = null; });
404
+ clientSocket.on('error', () => { currentClient = null; });
405
+ });
406
+ server.on('error', (err) => {
407
+ if (!state.httpServer)
408
+ reject(err);
409
+ else
410
+ warn(`TCP proxy error: ${err.message}`);
411
+ });
412
+ server.listen(PORT, () => {
413
+ state.httpServer = server;
414
+ resolve();
415
+ });
416
+ });
417
+ }
354
418
  // --- 4b. WebSocket tunnel (via ws library) ---
355
419
  function setupWebSocketProxy(server) {
356
420
  const wss = new WebSocketServer({ noServer: true });
@@ -772,21 +836,43 @@ async function main() {
772
836
  state.sessionId = ackResp.session_id;
773
837
  state.startedAt = Date.now();
774
838
  providerPubkey = ackResp.pubkey || null;
839
+ state.isProxyMode = !!ackResp.proxy_mode;
775
840
  // If the provider dictated a different rate, use it
776
841
  if (ackResp.sats_per_minute && ackResp.sats_per_minute !== satsPerMinute) {
777
842
  state.satsPerMinute = ackResp.sats_per_minute;
778
843
  log(`Provider adjusted rate: ${ackResp.sats_per_minute} sats/min`);
779
844
  }
780
845
  log(`Session started: ${state.sessionId}`);
781
- log(`Billing: ${state.satsPerMinute} sats/min via ${paymentMethod}`);
782
- // 6. Start HTTP proxy
783
- try {
784
- await startHttpProxy();
785
- log(`Web proxy ready at http://localhost:${PORT}`);
846
+ if (state.isProxyMode) {
847
+ log(`Mode: TCP proxy (one-time fee, raw HTTP passthrough)`);
786
848
  }
787
- catch (e) {
788
- warn(`Failed to start HTTP proxy on port ${PORT}: ${e.message}`);
789
- warn('Continuing without HTTP proxy');
849
+ else {
850
+ log(`Billing: ${state.satsPerMinute} sats/min via ${paymentMethod}`);
851
+ }
852
+ // 6. Start proxy
853
+ if (state.isProxyMode) {
854
+ // Proxy mode: wait for payment, then switch socket to raw TCP pipe
855
+ const proxyReadyPromise = new Promise(resolve => { state.proxyReady = resolve; });
856
+ log('Waiting for invoice from provider...');
857
+ try {
858
+ await proxyReadyPromise;
859
+ await startTcpProxy();
860
+ log(`TCP proxy ready at http://localhost:${PORT}`);
861
+ }
862
+ catch (e) {
863
+ warn(`Failed to start TCP proxy on port ${PORT}: ${e.message}`);
864
+ warn('Continuing without proxy');
865
+ }
866
+ }
867
+ else {
868
+ try {
869
+ await startHttpProxy();
870
+ log(`Web proxy ready at http://localhost:${PORT}`);
871
+ }
872
+ catch (e) {
873
+ warn(`Failed to start HTTP proxy on port ${PORT}: ${e.message}`);
874
+ warn('Continuing without HTTP proxy');
875
+ }
790
876
  }
791
877
  // 7. Show ready message and start REPL
792
878
  log("Type 'help' for commands");
package/dist/swarm.d.ts CHANGED
@@ -47,6 +47,7 @@ export interface SwarmMessage {
47
47
  duration_s?: number;
48
48
  payment_method?: 'invoice';
49
49
  pubkey?: string;
50
+ proxy_mode?: boolean;
50
51
  bolt11?: string;
51
52
  preimage?: string;
52
53
  method?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "2020117-agent",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "2020117 agent runtime — Nostr-native relay subscription + Hyperswarm P2P + Lightning payments",
5
5
  "type": "module",
6
6
  "bin": {