@panal/sdk 0.24.0 → 0.26.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/dist/client.js CHANGED
@@ -15,16 +15,18 @@
15
15
  * Sin configuración apunta a Monad mainnet, que es donde Panal está desplegado
16
16
  * y en uso: el caso de "quiero probar esto ahora" no debería exigir un .env.
17
17
  */
18
- import { createPublicClient, createWalletClient, formatEther, getAddress, http, keccak256, parseEventLogs, toBytes, verifyMessage } from 'viem';
18
+ import { createPublicClient, createWalletClient, formatEther, getAddress, http, keccak256, parseAbi, parseEventLogs, toBytes, verifyMessage } from 'viem';
19
19
  import { erc20Abi, escrowAbi, namesAbi, registryAbi } from './abis.js';
20
20
  import { leerX402 } from './agent-card.js';
21
- import { assertPublicUrl, fetchLimited, rutaDeAgente } from './net.js';
21
+ import { assertPublicUrl, fetchBytesLimited, fetchLimited, rutaDeAgente, sinBarraFinal } from './net.js';
22
+ import { MAX_FILE_BYTES, appendAttachmentsManifest, attachmentFrom, parseAttachmentsManifest, parseFilesManifest, sanitizeFileName, verifyFileBytes, } from './files.js';
23
+ import { accesoSignMessage, cifrarArchivo, cifrarTexto, claveDesdeFirma, comprobarClavePublicada, concederAcceso, descifrarArchivo, descifrarTexto, esArchivoCifrado, esTextoCifrado, mensajeParaDerivarClave, mensajeParaPublicarClave, nombreEnBuzon, puedeAbrir, } from './cifrado.js';
22
24
  import { X402Error, payAndAsk, quoteAsk } from './x402.js';
23
25
  import { descend, newEnvelope, remainingBudget } from './envelope.js';
24
26
  import { NATIVE_CURRENCY, addressesFor, chainFor } from './chains.js';
25
27
  import { leerPrecios, valorDeVenta } from './precios.js';
26
28
  import { panalTablonAbi } from './tablon-abi.js';
27
- import { EstadoTablon, MAX_CATEGORIAS, ModoTablon, encargoDeLaCadena, ofertaDeLaCadena, problemaAlPublicar, situacionDe, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
29
+ import { EstadoTablon, MAX_CATEGORIAS, ModoTablon, encargoDeLaCadena, entregaConArchivos, ofertaDeLaCadena, problemaAlPublicar, problemaConArchivos, situacionDe, tablonAccesoSignMessage, tablonArchivoSignMessage, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
28
30
  import { BUZON_URL, TABLON, VENTANA_FIRMA_S, encargoSignMessage, entregaSignMessage, ofertaSignMessage, } from './tablon.js';
29
31
  import { TaskStatus, formatAgentMetadata, parseAgentMetadata, } from './types.js';
30
32
  /**
@@ -133,7 +135,7 @@ export class PanalClient {
133
135
  'Usa network: "mainnet", o pasa `addresses` con los tuyos.');
134
136
  }
135
137
  this.indexerUrl = options.indexerUrl === undefined ? 'https://api.panal.lat' : options.indexerUrl;
136
- this.buzonUrl = (options.buzonUrl ?? BUZON_URL).replace(/\/+$/, '');
138
+ this.buzonUrl = sinBarraFinal(options.buzonUrl ?? BUZON_URL);
137
139
  const transport = http(options.rpcUrl ?? chain.rpcUrls.default.http[0]);
138
140
  this.publicClient = createPublicClient({ chain, transport });
139
141
  this.account = options.account;
@@ -219,6 +221,220 @@ export class PanalClient {
219
221
  }
220
222
  }
221
223
  // -------------------------------------------------------------------------
224
+ // Cifrado
225
+ // -------------------------------------------------------------------------
226
+ /** La clave de cifrado de esta cuenta, una vez sacada: no se vuelve a firmar. */
227
+ clave;
228
+ /**
229
+ * Si lo de un encargo del tablón va cifrado y con qué accesos se abre, por
230
+ * `jobId:adjunto` o `jobId:entrega`. Se aprende al leer el encargo o la
231
+ * entrega y lo usa `downloadBoardJobFiles`, que sin esto pediría los
232
+ * archivos por su nombre de verdad y el buzón los guarda con uno opaco.
233
+ */
234
+ cifradoDelTablon = new Map();
235
+ /**
236
+ * La clave de cifrado de esta cuenta. Sale de la firma de un mensaje fijo
237
+ * —la misma cuenta firma siempre lo mismo (RFC 6979)—, así que es la misma
238
+ * que en la web y en la app, y no se guarda en ningún sitio.
239
+ */
240
+ async encryptionKey() {
241
+ if (this.clave)
242
+ return this.clave;
243
+ const firma = await this.wallet().signMessage({ account: this.account, message: mensajeParaDerivarClave(this.account.address) });
244
+ this.clave = claveDesdeFirma(this.account.address, firma);
245
+ return this.clave;
246
+ }
247
+ /**
248
+ * La clave pública de una cuenta, comprobada contra la firma que la ata a
249
+ * ella. `null` si no ha publicado ninguna, o si lo que sirve el buzón no es
250
+ * suyo: cifrar para una clave falsa sería entregarle el encargo a quien la puso.
251
+ */
252
+ async getPublicKey(cuenta) {
253
+ try {
254
+ const { status, text } = await fetchLimited(`${this.buzonUrl}/${getAddress(cuenta)}/clave`, { timeoutMs: 10_000, maxBytes: 10_000 });
255
+ if (status !== 200)
256
+ return null;
257
+ const b = JSON.parse(text);
258
+ if (typeof b.clave !== 'string' || typeof b.firma !== 'string')
259
+ return null;
260
+ const ok = await comprobarClavePublicada({ cuenta, clave: b.clave, firma: b.firma }, this.publicClient);
261
+ return ok ? b.clave : null;
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ /**
268
+ * Deja publicada la clave de esta cuenta (dos firmas, sin gas, una vez). Si
269
+ * ya estaba, no firma nada más. Si había OTRA, la sustituye y `cambio` lo
270
+ * dice: lo cifrado para la anterior ya no se abrirá con esta.
271
+ */
272
+ async publishEncryptionKey() {
273
+ const clave = await this.encryptionKey();
274
+ const yo = this.account.address;
275
+ const publicada = await this.getPublicKey(yo);
276
+ if (publicada === clave.publica)
277
+ return { publica: clave.publica, cambio: false };
278
+ const firma = await this.wallet().signMessage({ account: this.account, message: mensajeParaPublicarClave(yo, clave.publica) });
279
+ const { status, text } = await fetchLimited(`${this.buzonUrl}/${yo}/clave`, {
280
+ method: 'POST',
281
+ headers: { 'content-type': 'application/json' },
282
+ body: JSON.stringify({ clave: clave.publica, firma }),
283
+ timeoutMs: 15_000,
284
+ maxBytes: 10_000,
285
+ });
286
+ if (status !== 200)
287
+ throw new Error(`El buzón no aceptó la clave de cifrado (${status}: ${text.slice(0, 160)}).`);
288
+ return { publica: clave.publica, cambio: publicada !== null };
289
+ }
290
+ /**
291
+ * Un texto, abierto: tal cual si va en claro; con la clave de esta cuenta y
292
+ * sus `accesos` si va cifrado. Falla diciendo que no hay acceso, sin pedir
293
+ * ninguna firma, si esta cuenta todavía no puede abrirlo.
294
+ */
295
+ async decryptText(texto, accesos = []) {
296
+ if (!esTextoCifrado(texto))
297
+ return texto;
298
+ this.wallet();
299
+ if (!puedeAbrir(texto, this.account.address, accesos)) {
300
+ throw new Error('Va cifrado y esta cuenta todavía no tiene acceso: nadie se lo ha dado.');
301
+ }
302
+ return descifrarTexto(texto, this.account.address, (await this.encryptionKey()).secreta, accesos);
303
+ }
304
+ /** Los bytes de un archivo, abiertos si van cifrados. */
305
+ async decryptFile(bytes, accesos = []) {
306
+ if (!esArchivoCifrado(bytes))
307
+ return bytes;
308
+ this.wallet();
309
+ return descifrarArchivo(bytes, this.account.address, (await this.encryptionKey()).secreta, accesos);
310
+ }
311
+ /**
312
+ * El encargo de una tarea DIRECTA, cifrado de punta a punta para quien lo
313
+ * hace y para esta cuenta, que tiene que poder releerlo y recibir la entrega
314
+ * cifrada (por eso publica también su clave, la primera vez).
315
+ *
316
+ * Solo tiene sentido con quien recibe en el buzón de Panal: a un agente con
317
+ * servidor propio el encargo le llega a SU máquina, y cifrárselo sería
318
+ * cifrar para el mismo que lo va a leer. Eso lo decide quien llama.
319
+ *
320
+ * Devuelve `null` si quien lo hace no ha publicado su clave: no hay a quién
321
+ * cifrar. Si no, el texto que se hashea y se manda —con el manifiesto de
322
+ * adjuntos dentro, con su nombre de verdad— y los adjuntos tal como se suben:
323
+ * cifrados y con su nombre opaco, que es lo único que ve el buzón.
324
+ */
325
+ async encryptBriefFor(worker, texto, adjuntos = []) {
326
+ this.wallet();
327
+ const suya = await this.getPublicKey(worker);
328
+ if (!suya)
329
+ return null;
330
+ const mia = (await this.publishEncryptionKey()).publica;
331
+ const para = [
332
+ { cuenta: getAddress(worker), clave: suya },
333
+ { cuenta: this.account.address, clave: mia },
334
+ ];
335
+ const cifrados = adjuntos.map((a) => ({ ...a, bytes: cifrarArchivo(a.bytes, para) }));
336
+ const enClaro = appendAttachmentsManifest(texto, cifrados.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
337
+ return {
338
+ brief: cifrarTexto(enClaro, para),
339
+ archivos: cifrados.map((a) => ({ file: attachmentFrom(nombreEnBuzon(keccak256(a.bytes)), a.bytes), bytes: a.bytes })),
340
+ };
341
+ }
342
+ /**
343
+ * Quiénes deciden las disputas: los firmantes de la multisig que es el
344
+ * `arbitrator` del escrow. Si el árbitro fuera una cuenta y no una
345
+ * multisig, es él.
346
+ */
347
+ async getArbitrators() {
348
+ const arbitrator = (await this.publicClient.readContract({
349
+ address: this.addresses.escrow,
350
+ abi: parseAbi(['function arbitrator() view returns (address)']),
351
+ functionName: 'arbitrator',
352
+ }));
353
+ const firmantes = [];
354
+ for (let i = 0; i < 10; i++) {
355
+ try {
356
+ firmantes.push((await this.publicClient.readContract({
357
+ address: arbitrator,
358
+ abi: parseAbi(['function owners(uint256) view returns (address)']),
359
+ functionName: 'owners',
360
+ args: [BigInt(i)],
361
+ })));
362
+ }
363
+ catch {
364
+ break;
365
+ }
366
+ }
367
+ return firmantes.length > 0 ? firmantes : [arbitrator];
368
+ }
369
+ /**
370
+ * Deja accesos en un buzón —el de un agente (`base` = su URL de buzón) o el
371
+ * del tablón— con una firma para todos. El buzón solo los acepta de una
372
+ * parte, y solo para la otra parte o para quien decide; van en trozos de
373
+ * 40, que es lo que admite por petición.
374
+ */
375
+ async dejarAccesos(base, id, esTablon, accesos) {
376
+ if (accesos.length === 0)
377
+ return;
378
+ const expira = Math.floor(Date.now() / 1000) + VENTANA_FIRMA_S;
379
+ const signature = await this.wallet().signMessage({
380
+ account: this.account,
381
+ message: esTablon ? tablonAccesoSignMessage(id, expira) : accesoSignMessage(id, expira),
382
+ });
383
+ for (let i = 0; i < accesos.length; i += 40) {
384
+ const { status, text } = await fetchLimited(`${sinBarraFinal(base)}/access/${id}`, {
385
+ method: 'POST',
386
+ headers: { 'content-type': 'application/json' },
387
+ body: JSON.stringify({ accesos: accesos.slice(i, i + 40), address: this.account.address, signature, expira }),
388
+ timeoutMs: 15_000,
389
+ maxBytes: 10_000,
390
+ });
391
+ if (status !== 200)
392
+ throw new Error(`El buzón no aceptó los accesos del #${id} (${status}: ${text.slice(0, 160)}).`);
393
+ }
394
+ }
395
+ /**
396
+ * Da acceso a quien decide la disputa a todo lo cifrado de `objetos` que
397
+ * esta cuenta puede abrir: textos anclados y bytes de archivos, tal como
398
+ * están en el buzón. Lo que va en claro se salta (eso ya lo lee).
399
+ *
400
+ * `base` es el buzón de quien trabaja (tarea directa, `id` = la tarea) o el
401
+ * del tablón (`tablon: true`, `id` = el encargo). Devuelve a cuántos
402
+ * firmantes se les dio y quiénes no tienen clave todavía: a esos no se les
403
+ * puede dar hasta que la publiquen.
404
+ */
405
+ async grantArbitratorsAccess(o) {
406
+ this.wallet();
407
+ const cifrados = o.objetos.filter((x) => (typeof x === 'string' ? esTextoCifrado(x) : esArchivoCifrado(x)));
408
+ if (cifrados.length === 0)
409
+ return { arbitros: 0, sinClave: [], objetos: 0 };
410
+ const conClave = [];
411
+ const sinClave = [];
412
+ for (const a of await this.getArbitrators()) {
413
+ const clave = await this.getPublicKey(a);
414
+ if (clave)
415
+ conClave.push({ cuenta: a, clave });
416
+ else
417
+ sinClave.push(a);
418
+ }
419
+ if (conClave.length === 0)
420
+ return { arbitros: 0, sinClave, objetos: cifrados.length };
421
+ const mia = await this.encryptionKey();
422
+ const nuevos = [];
423
+ for (const objeto of cifrados) {
424
+ for (const d of conClave) {
425
+ // Lo que esta cuenta no puede abrir no lo puede compartir: se salta.
426
+ try {
427
+ nuevos.push(concederAcceso(objeto, this.account.address, mia.secreta, d, o.accesos ?? []));
428
+ }
429
+ catch {
430
+ /* no es de los suyos */
431
+ }
432
+ }
433
+ }
434
+ await this.dejarAccesos(o.base, o.id, o.tablon === true, nuevos);
435
+ return { arbitros: conClave.length, sinClave, objetos: cifrados.length };
436
+ }
437
+ // -------------------------------------------------------------------------
222
438
  // Lectura
223
439
  // -------------------------------------------------------------------------
224
440
  /** Todos los agentes del registry, activos e inactivos. */
@@ -928,7 +1144,7 @@ export class PanalClient {
928
1144
  throw new Error(`La tarea #${taskId} está "${TaskStatus[task.status]}": solo se entrega lo que sigue abierto.`);
929
1145
  }
930
1146
  const ficha = await this.leerAgente(yo);
931
- const canal = ficha.metadata.botUrl?.replace(/\/+$/, '') ?? '';
1147
+ const canal = ficha.metadata.botUrl ? sinBarraFinal(ficha.metadata.botUrl) : '';
932
1148
  if (!canal) {
933
1149
  throw new Error(`${yo} no publica ningún canal (\`bot:\`): el cliente no tendría de dónde recoger la entrega.`);
934
1150
  }
@@ -1278,7 +1494,30 @@ export class PanalClient {
1278
1494
  const moneda = p.moneda ?? NATIVE_CURRENCY;
1279
1495
  const nativa = moneda.toLowerCase() === NATIVE_CURRENCY.toLowerCase();
1280
1496
  await this.assertFunds(moneda, p.importe, nativa);
1281
- const taskHash = keccak256(toBytes(p.encargo));
1497
+ // Los adjuntos se anuncian DENTRO del encargo, con su hash: el `taskHash`
1498
+ // que va a la cadena los cubre desde el momento del pago.
1499
+ const adjuntos = p.adjuntos ?? [];
1500
+ // Antes de cobrar: un buzón anterior a los archivos aceptaría el encargo
1501
+ // y no sus bytes, y el agente cobraría por algo que no puede abrir.
1502
+ if (adjuntos.length > 0 && !(await this.buzonAceptaArchivos())) {
1503
+ throw new Error('El buzón del tablón todavía no acepta archivos: no se ha publicado nada. Publica sin adjuntos o inténtalo más tarde.');
1504
+ }
1505
+ let encargo = appendAttachmentsManifest(p.encargo, adjuntos.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
1506
+ // Lo que se sube: los adjuntos tal cual, o cifrados y con nombre opaco.
1507
+ let aSubir = adjuntos;
1508
+ if (p.confidencial) {
1509
+ /*
1510
+ * Cifrado solo para quien publica, antes de pagar nada: a quien coja el
1511
+ * encargo se le da acceso al elegirlo (`chooseBoardOffer`). Los adjuntos
1512
+ * se anuncian en abierto por su hash y su tamaño: el buzón solo acepta
1513
+ * los que el encargo anuncia, y no puede leerlo.
1514
+ */
1515
+ const para = [{ cuenta: this.account.address, clave: (await this.publishEncryptionKey()).publica }];
1516
+ const cifrados = adjuntos.map((a) => ({ ...a, bytes: cifrarArchivo(a.bytes, para) }));
1517
+ encargo = cifrarTexto(appendAttachmentsManifest(p.encargo, cifrados.map((a) => attachmentFrom(a.name, a.bytes, a.mime))), para, { anunciar: cifrados.map((a) => ({ hash: keccak256(a.bytes), size: a.bytes.byteLength })) });
1518
+ aSubir = cifrados.map((a) => ({ name: nombreEnBuzon(keccak256(a.bytes)), bytes: a.bytes }));
1519
+ }
1520
+ const taskHash = keccak256(toBytes(encargo));
1282
1521
  const advertHash = keccak256(toBytes(p.anuncio));
1283
1522
  const seleccion = p.modo === ModoTablon.Seleccion;
1284
1523
  const params = {
@@ -1307,15 +1546,140 @@ export class PanalClient {
1307
1546
  // Los textos van DESPUÉS de la cadena: hasta que el encargo existe, el buzón
1308
1547
  // no tiene hash con qué comprobarlos. Si fallan, el encargo está pagado y
1309
1548
  // sin anunciar: se dice y se reintenta con `publishBoardJobTexts`.
1549
+ const base = { jobId, txHash: hash, taskHash, advertHash, encargo, archivos: aSubir };
1550
+ try {
1551
+ await this.publishBoardJobTexts(jobId, { anuncio: p.anuncio, encargo });
1552
+ }
1553
+ catch (err) {
1554
+ const error = err instanceof Error ? err.message : String(err);
1555
+ return { ...base, textosGuardados: false, archivosGuardados: adjuntos.length === 0, error };
1556
+ }
1557
+ // Los bytes, después del encargo: el buzón solo acepta los que anuncia.
1310
1558
  try {
1311
- await this.publishBoardJobTexts(jobId, { anuncio: p.anuncio, encargo: p.encargo });
1312
- return { jobId, txHash: hash, taskHash, advertHash, textosGuardados: true };
1559
+ await this.publishBoardJobFiles(jobId, aSubir);
1560
+ return { ...base, textosGuardados: true, archivosGuardados: true };
1313
1561
  }
1314
1562
  catch (err) {
1315
1563
  const error = err instanceof Error ? err.message : String(err);
1316
- return { jobId, txHash: hash, taskHash, advertHash, textosGuardados: false, error };
1564
+ return { ...base, textosGuardados: true, archivosGuardados: false, error };
1565
+ }
1566
+ }
1567
+ /**
1568
+ * Si el buzón del tablón ya guarda archivos: el que conoce la ruta contesta
1569
+ * 400 a un POST sin credenciales; uno anterior, 404.
1570
+ */
1571
+ async buzonAceptaArchivos() {
1572
+ try {
1573
+ const { status } = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/archivo/0`, { method: 'POST', timeoutMs: 10_000, maxBytes: 10_000 });
1574
+ return status !== 404;
1575
+ }
1576
+ catch {
1577
+ return false;
1578
+ }
1579
+ }
1580
+ /**
1581
+ * Sube archivos al buzón del tablón, con una firma para todos. Su cliente,
1582
+ * los adjuntos de un encargo ya publicado (solo entran los que el encargo
1583
+ * anuncia); su agente, los de la entrega (lo que hace `deliverBoardJob`, o
1584
+ * para volver a subirlos al retomar). Repetirlo no ocupa más.
1585
+ */
1586
+ async publishBoardJobFiles(jobId, archivos) {
1587
+ if (archivos.length === 0)
1588
+ return;
1589
+ const cabeceras = await this.credencialesDeBuzon((e) => tablonArchivoSignMessage(jobId, e));
1590
+ for (const a of archivos)
1591
+ await this.subirArchivoAlTablon(jobId, a, cabeceras);
1592
+ }
1593
+ async subirArchivoAlTablon(jobId, a, cabeceras) {
1594
+ const nombre = sanitizeFileName(a.name);
1595
+ const { status, text } = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/archivo/${jobId}`, {
1596
+ method: 'POST',
1597
+ headers: {
1598
+ ...cabeceras,
1599
+ 'content-type': a.mime || 'application/octet-stream',
1600
+ 'x-panal-filename': encodeURIComponent(nombre),
1601
+ },
1602
+ body: a.bytes,
1603
+ timeoutMs: 180_000,
1604
+ maxBytes: 10_000,
1605
+ });
1606
+ if (status !== 200) {
1607
+ throw new Error(`El buzón no aceptó «${nombre}» del #${jobId} (${status}: ${text.slice(0, 160)}).`);
1317
1608
  }
1318
1609
  }
1610
+ /**
1611
+ * Los archivos de un encargo del tablón, comprobados byte a byte contra su
1612
+ * hash. `adjunto`: los que el cliente mandó con el encargo (los bajan él y
1613
+ * su agente). `entrega`: los que entregó el agente (los baja su cliente).
1614
+ * El hash viene del texto anclado —`readBoardJobBrief` o
1615
+ * `readBoardJobResult`—, nunca del buzón. Una firma para todos.
1616
+ */
1617
+ async downloadBoardJobFiles(jobId, archivos, de, opciones = {}) {
1618
+ if (archivos.length === 0)
1619
+ return [];
1620
+ const job = await this.getBoardJob(jobId);
1621
+ const yo = this.wallet() && this.account.address.toLowerCase();
1622
+ const esCliente = yo === job.cliente.toLowerCase();
1623
+ if (!esCliente && yo !== job.agente.toLowerCase()) {
1624
+ throw new Error(`Los archivos del #${jobId} solo los bajan su cliente y su agente.`);
1625
+ }
1626
+ // Si el texto que los anuncia iba cifrado, el buzón los guarda con su
1627
+ // nombre opaco y hay que abrirlos. Si no se ha leído aún, se lee: un agente
1628
+ // que retoma un encargo tras reiniciarse no lo tiene en memoria.
1629
+ const estado = await this.estadoDeCifrado(jobId, de === 'entrega' && esCliente ? 'entrega' : 'adjunto');
1630
+ const crudos = await this.bajarDelTablon(jobId, archivos, de, estado.cifrado, esCliente);
1631
+ if (opciones.abrir === false)
1632
+ return crudos;
1633
+ const out = [];
1634
+ for (const bytes of crudos)
1635
+ out.push(await this.decryptFile(bytes, estado.accesos));
1636
+ return out;
1637
+ }
1638
+ /**
1639
+ * Abre un archivo de un encargo del tablón bajado con `abrir: false`: con
1640
+ * la clave de esta cuenta y sus accesos. Si no va cifrado, lo devuelve tal cual.
1641
+ */
1642
+ async openBoardJobFile(jobId, bytes, de = 'adjunto') {
1643
+ if (!esArchivoCifrado(bytes))
1644
+ return bytes;
1645
+ const job = await this.getBoardJob(jobId);
1646
+ const esCliente = this.account?.address.toLowerCase() === job.cliente.toLowerCase();
1647
+ const estado = await this.estadoDeCifrado(jobId, de === 'entrega' && esCliente ? 'entrega' : 'adjunto');
1648
+ return this.decryptFile(bytes, estado.accesos);
1649
+ }
1650
+ /** Los bytes de unos archivos del tablón, comprobados contra su hash y SIN abrir. */
1651
+ async bajarDelTablon(jobId, archivos, de, cifrado, esCliente) {
1652
+ // El cliente abre la entrega con la firma de llevársela; lo demás, con la de leer el encargo.
1653
+ const cabeceras = await this.credencialesDeBuzon((e) => de === 'entrega' && esCliente ? tablonResultadoSignMessage(jobId, e) : tablonEncargoSignMessage(jobId, e));
1654
+ const out = [];
1655
+ for (const f of archivos) {
1656
+ const nombre = cifrado ? nombreEnBuzon(f.hash) : sanitizeFileName(f.name);
1657
+ const { status, bytes } = await fetchBytesLimited(`${this.buzonUrl}/${this.tablon()}/archivo/${jobId}/${encodeURIComponent(nombre)}?de=${de}`, { headers: cabeceras, timeoutMs: 180_000, maxBytes: Math.min(MAX_FILE_BYTES, f.size + 1024) });
1658
+ if (status === 404)
1659
+ throw new Error(`«${f.name}» no está en el buzón del tablón.`);
1660
+ if (status !== 200)
1661
+ throw new Error(`El buzón respondió ${status} al pedir «${f.name}».`);
1662
+ verifyFileBytes(f, bytes);
1663
+ out.push(bytes);
1664
+ }
1665
+ return out;
1666
+ }
1667
+ /**
1668
+ * Si el encargo (`adjunto`) o la entrega (`entrega`) de un encargo del
1669
+ * tablón va cifrado, y los accesos de esta cuenta. Lo recuerda de la última
1670
+ * lectura; si no hay, lee. La entrega solo la lee su cliente: un agente
1671
+ * cifra la suya si el encargo iba cifrado, así que mira el encargo.
1672
+ */
1673
+ async estadoDeCifrado(jobId, de) {
1674
+ const clave = `${jobId}:${de}`;
1675
+ const ya = this.cifradoDelTablon.get(clave);
1676
+ if (ya)
1677
+ return ya;
1678
+ const leido = de === 'entrega' ? await this.leerEntregaDelTablon(jobId) : await this.leerEncargoDelTablon(jobId);
1679
+ const estado = { cifrado: esTextoCifrado(leido.texto), accesos: leido.accesos };
1680
+ this.cifradoDelTablon.set(clave, estado);
1681
+ return estado;
1682
+ }
1319
1683
  /**
1320
1684
  * Deja en el buzón el anuncio y el encargo de un encargo ya publicado. Sin
1321
1685
  * firma: el buzón solo acepta los textos que dan los hashes de la cadena.
@@ -1350,28 +1714,58 @@ export class PanalClient {
1350
1714
  * Un anuncio que no cuadre no se devuelve: el buzón no puede inventarse uno.
1351
1715
  */
1352
1716
  async listBoardJobAdverts(opciones = {}) {
1353
- const [encargos, respuesta] = await Promise.all([
1354
- this.listBoardJobs({ limit: opciones.limit }),
1355
- fetchLimited(`${this.buzonUrl}/${this.tablon()}/lista`, { timeoutMs: 15_000, maxBytes: 2_000_000 }),
1356
- ]);
1357
- if (respuesta.status !== 200)
1358
- throw new Error(`El buzón respondió ${respuesta.status} al pedir los anuncios.`);
1359
- const { anuncios = [] } = JSON.parse(respuesta.text);
1360
- const porId = new Map(anuncios.map((a) => [a.jobId, a.anuncio]));
1717
+ const [encargos, anuncios] = await Promise.all([this.listBoardJobs({ limit: opciones.limit }), this.anunciosDelBuzon()]);
1361
1718
  return encargos.flatMap((j) => {
1362
- const anuncio = porId.get(j.id.toString());
1363
- if (typeof anuncio !== 'string')
1719
+ const a = anuncios.get(j.id.toString());
1720
+ if (!a)
1364
1721
  return [];
1365
- if (keccak256(toBytes(anuncio)).toLowerCase() !== j.advertHash.toLowerCase())
1722
+ if (keccak256(toBytes(a.anuncio)).toLowerCase() !== j.advertHash.toLowerCase())
1366
1723
  return [];
1367
- return [{ ...j, anuncio }];
1724
+ return [{ ...j, anuncio: a.anuncio, confidencial: a.cifrado }];
1368
1725
  });
1369
1726
  }
1727
+ /**
1728
+ * Los anuncios que guarda el buzón del tablón, con si su encargo va cifrado
1729
+ * (confidencial). Eso lo dice el buzón y no la cadena: si mintiera, lo que
1730
+ * cambia es un aviso, y quien coja el encargo lo verá al abrirlo.
1731
+ */
1732
+ async anunciosDelBuzon() {
1733
+ const respuesta = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/lista`, { timeoutMs: 15_000, maxBytes: 2_000_000 });
1734
+ if (respuesta.status !== 200)
1735
+ throw new Error(`El buzón respondió ${respuesta.status} al pedir los anuncios.`);
1736
+ const { anuncios = [] } = JSON.parse(respuesta.text);
1737
+ return new Map(anuncios
1738
+ .filter((a) => typeof a.anuncio === 'string')
1739
+ .map((a) => [a.jobId, { anuncio: a.anuncio, cifrado: a.cifrado === true }]));
1740
+ }
1741
+ /**
1742
+ * Si un encargo del tablón es confidencial: su encargo va cifrado y solo lo
1743
+ * lee quien su cliente elija, que tiene que tener su clave publicada.
1744
+ */
1745
+ async isBoardJobConfidential(jobId) {
1746
+ return (await this.anunciosDelBuzon()).get(jobId.toString())?.cifrado === true;
1747
+ }
1370
1748
  /**
1371
1749
  * El encargo privado. Lo leen su cliente y su agente en cuanto lo tiene, y
1372
1750
  * se comprueba contra el `taskHash` de la cadena antes de devolverlo.
1373
1751
  */
1374
1752
  async readBoardJobBrief(jobId) {
1753
+ const { texto, accesos } = await this.leerEncargoDelTablon(jobId);
1754
+ const cifrado = esTextoCifrado(texto);
1755
+ this.cifradoDelTablon.set(`${jobId}:adjunto`, { cifrado, accesos });
1756
+ // Confidencial: se abre con la clave de esta cuenta y el acceso que da su
1757
+ // cliente al elegirla. Sin acceso todavía, se dice, sin pedir firmas.
1758
+ if (cifrado && !puedeAbrir(texto, this.account.address, accesos)) {
1759
+ throw new Error(`El encargo #${jobId} es confidencial y su cliente todavía no le ha dado acceso a esta cuenta ` +
1760
+ '(se lo da al elegirla). Vuelve a intentarlo en un rato.');
1761
+ }
1762
+ return this.decryptText(texto, accesos);
1763
+ }
1764
+ /**
1765
+ * El encargo del tablón tal como está anclado —en claro o cifrado—, con los
1766
+ * accesos que tiene esta cuenta. Comprobado contra el `taskHash`.
1767
+ */
1768
+ async leerEncargoDelTablon(jobId) {
1375
1769
  const job = await this.getBoardJob(jobId);
1376
1770
  const yo = this.account?.address.toLowerCase();
1377
1771
  if (yo !== job.cliente.toLowerCase() && yo !== job.agente.toLowerCase()) {
@@ -1386,18 +1780,18 @@ export class PanalClient {
1386
1780
  throw new Error(`El buzón no tiene el encargo #${jobId}: su cliente no llegó a dejarlo.`);
1387
1781
  if (status !== 200)
1388
1782
  throw new Error(`El buzón respondió ${status} al pedir el encargo #${jobId}.`);
1389
- const { brief } = JSON.parse(text);
1783
+ const { brief, accesos } = JSON.parse(text);
1390
1784
  if (typeof brief !== 'string' || keccak256(toBytes(brief)).toLowerCase() !== job.taskHash.toLowerCase()) {
1391
1785
  throw new Error(`El encargo #${jobId} del buzón no cuadra con el taskHash de la cadena: no se trabaja sobre él.`);
1392
1786
  }
1393
- return brief;
1787
+ return { texto: brief, accesos: Array.isArray(accesos) ? accesos : [] };
1394
1788
  }
1395
1789
  /**
1396
1790
  * Entregar un encargo del tablón con reglas: se deja en el buzón y DESPUÉS
1397
1791
  * se ancla su hash en el escrow. Al revés, un fallo del buzón dejaría al
1398
1792
  * cliente con una entrega anclada que no puede descargar.
1399
1793
  */
1400
- async deliverBoardJob(jobId, texto) {
1794
+ async deliverBoardJob(jobId, textoEscrito, archivos = []) {
1401
1795
  this.wallet();
1402
1796
  const job = await this.getBoardJob(jobId);
1403
1797
  if (job.agente.toLowerCase() !== this.account.address.toLowerCase()) {
@@ -1405,6 +1799,42 @@ export class PanalClient {
1405
1799
  }
1406
1800
  if (job.estado !== EstadoTablon.Asignado)
1407
1801
  throw new Error(`El encargo #${jobId} no está asignado: no hay nada que entregar.`);
1802
+ if (archivos.length > 0) {
1803
+ const problema = problemaConArchivos(archivos);
1804
+ if (problema)
1805
+ throw new Error(problema);
1806
+ }
1807
+ /*
1808
+ * Confidencial: la entrega y sus archivos van cifrados para el cliente y
1809
+ * para quien entrega. Sin la clave del cliente no se entrega en claro: el
1810
+ * encargo era confidencial, y su entrega también.
1811
+ */
1812
+ let para = null;
1813
+ if (esTextoCifrado((await this.leerEncargoDelTablon(jobId)).texto)) {
1814
+ const delCliente = await this.getPublicKey(job.cliente);
1815
+ if (!delCliente) {
1816
+ throw new Error(`El encargo #${jobId} es confidencial y su cliente no tiene clave de cifrado publicada: la entrega no ` +
1817
+ 'puede ir cifrada, y en claro no se entrega. No se ha anclado nada; vuelve a intentarlo en un rato.');
1818
+ }
1819
+ para = [
1820
+ { cuenta: job.cliente, clave: delCliente },
1821
+ { cuenta: this.account.address, clave: (await this.publishEncryptionKey()).publica },
1822
+ ];
1823
+ }
1824
+ // Lo que va en el manifiesto (nombre de verdad) y lo que se sube (cifrado y con nombre opaco).
1825
+ const anunciar = para ? archivos.map((a) => ({ ...a, bytes: cifrarArchivo(a.bytes, para) })) : archivos;
1826
+ const subir = para ? anunciar.map((a) => ({ name: nombreEnBuzon(keccak256(a.bytes)), bytes: a.bytes })) : archivos;
1827
+ // Los archivos primero, y su hash dentro del texto que se ancla: si el
1828
+ // buzón no los acepta, no se ancla nada.
1829
+ let texto = textoEscrito;
1830
+ if (archivos.length > 0) {
1831
+ const cabecerasArchivos = await this.credencialesDeBuzon((e) => tablonArchivoSignMessage(jobId, e));
1832
+ for (const a of subir)
1833
+ await this.subirArchivoAlTablon(jobId, a, cabecerasArchivos);
1834
+ texto = entregaConArchivos(jobId, textoEscrito, anunciar);
1835
+ }
1836
+ if (para)
1837
+ texto = cifrarTexto(texto, para);
1408
1838
  const hashEntrega = keccak256(toBytes(texto));
1409
1839
  const cabeceras = await this.credencialesDeBuzon((e) => tablonEntregaSignMessage(jobId, hashEntrega, e));
1410
1840
  const { status, text } = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/entrega/${jobId}`, {
@@ -1422,13 +1852,21 @@ export class PanalClient {
1422
1852
  if (status !== 200) {
1423
1853
  throw new Error(`El buzón no aceptó la entrega del #${jobId} (${status}: ${text.slice(0, 160)}). No se ha anclado nada.`);
1424
1854
  }
1425
- return this.deliverResult(job.escrowTaskId, texto);
1855
+ const anclada = await this.deliverResult(job.escrowTaskId, texto);
1856
+ return { ...anclada, texto };
1426
1857
  }
1427
1858
  /**
1428
1859
  * La entrega, para su cliente. Se comprueba contra el `resultHash` anclado
1429
1860
  * en el escrow: el buzón no puede cambiarla.
1430
1861
  */
1431
1862
  async readBoardJobResult(jobId) {
1863
+ const { texto, accesos } = await this.leerEntregaDelTablon(jobId);
1864
+ this.cifradoDelTablon.set(`${jobId}:entrega`, { cifrado: esTextoCifrado(texto), accesos });
1865
+ // Cifrada para el cliente y para quien entregó: se abre con la clave de esta cuenta.
1866
+ return this.decryptText(texto, accesos);
1867
+ }
1868
+ /** La entrega tal como está anclada, con los accesos de esta cuenta. */
1869
+ async leerEntregaDelTablon(jobId) {
1432
1870
  const job = await this.getBoardJob(jobId);
1433
1871
  if (this.account?.address.toLowerCase() !== job.cliente.toLowerCase()) {
1434
1872
  throw new Error(`La entrega del #${jobId} solo la descarga su cliente.`);
@@ -1442,12 +1880,12 @@ export class PanalClient {
1442
1880
  throw new Error(`El encargo #${jobId} todavía no tiene entrega en el buzón.`);
1443
1881
  if (status !== 200)
1444
1882
  throw new Error(`El buzón respondió ${status} al pedir la entrega del #${jobId}.`);
1445
- const { resultText } = JSON.parse(text);
1883
+ const { resultText, accesos } = JSON.parse(text);
1446
1884
  const tarea = await this.getTask(job.escrowTaskId);
1447
1885
  if (typeof resultText !== 'string' || keccak256(toBytes(resultText)).toLowerCase() !== tarea.resultHash.toLowerCase()) {
1448
1886
  throw new Error(`La entrega del #${jobId} no cuadra con el resultHash anclado en el escrow.`);
1449
1887
  }
1450
- return resultText;
1888
+ return { texto: resultText, accesos: Array.isArray(accesos) ? accesos : [] };
1451
1889
  }
1452
1890
  /** Coger un encargo inmediato. Antes de firmar pregunta si se puede, y por qué no. */
1453
1891
  async takeBoardJob(jobId) {
@@ -1468,6 +1906,10 @@ export class PanalClient {
1468
1906
  const { ok, motivo } = await this.canTakeBoardJob(this.account.address, jobId);
1469
1907
  if (!ok)
1470
1908
  throw new Error(`No se puede ofertar en el encargo #${jobId}: ${motivo}.`);
1909
+ // Confidencial: sin clave publicada, el cliente no podría darle acceso al
1910
+ // elegirla y no podría leer lo que tiene que hacer. Se publica antes.
1911
+ if (await this.isBoardJobConfidential(jobId))
1912
+ await this.publishEncryptionKey();
1471
1913
  return (await this.firmarEnTablon('La oferta', 'applyFor', [jobId, precio, BigInt(plazoEntregaS)])).hash;
1472
1914
  }
1473
1915
  async withdrawBoardOffer(jobId) {
@@ -1483,6 +1925,13 @@ export class PanalClient {
1483
1925
  const oferta = ofertas.find((o) => o.agente.toLowerCase() === agent.toLowerCase());
1484
1926
  if (!oferta)
1485
1927
  throw new Error(`${agent} no tiene oferta en el encargo #${jobId}.`);
1928
+ // Confidencial: solo se elige a quien se le pueda dar acceso. Se mira antes
1929
+ // de pagar nada; elegir a quien no puede leer el encargo es pagar por nada.
1930
+ const confidencial = esTextoCifrado((await this.leerEncargoDelTablon(jobId)).texto);
1931
+ if (confidencial && !(await this.getPublicKey(agent))) {
1932
+ throw new Error(`El encargo #${jobId} es confidencial y ${agent} no ha publicado su clave de cifrado: no se le podría dar ` +
1933
+ 'acceso y no podría leerlo. No se ha elegido nada; elige otra oferta.');
1934
+ }
1486
1935
  const diferencia = oferta.precio > job.importe ? oferta.precio - job.importe : 0n;
1487
1936
  const nativa = job.moneda.toLowerCase() === NATIVE_CURRENCY.toLowerCase();
1488
1937
  if (diferencia > 0n) {
@@ -1491,7 +1940,81 @@ export class PanalClient {
1491
1940
  await this.aprobarAlTablon(job.moneda, diferencia);
1492
1941
  }
1493
1942
  const { hash } = await this.firmarEnTablon('Elegir la oferta', 'choose', [jobId, getAddress(agent)], nativa ? diferencia : 0n);
1494
- return { txHash: hash, escrowTaskId: (await this.getBoardJob(jobId)).escrowTaskId };
1943
+ const escrowTaskId = (await this.getBoardJob(jobId)).escrowTaskId;
1944
+ if (!confidencial)
1945
+ return { txHash: hash, escrowTaskId };
1946
+ // Elegido en la cadena: ahora, que pueda leer lo que tiene que hacer. Si
1947
+ // falla, el encargo ya es suyo; se reintenta con `grantBoardJobAccess`.
1948
+ try {
1949
+ return { txHash: hash, escrowTaskId, acceso: { ok: true, accesos: await this.grantBoardJobAccess(jobId, agent) } };
1950
+ }
1951
+ catch (err) {
1952
+ return { txHash: hash, escrowTaskId, acceso: { ok: false, error: err instanceof Error ? err.message : String(err) } };
1953
+ }
1954
+ }
1955
+ /**
1956
+ * El cliente de un encargo confidencial da acceso al encargo y a sus
1957
+ * adjuntos a quien lo tiene (o a `agent`). Se hace solo al elegir; esto es
1958
+ * para un encargo que se asignó solo o un acceso que falló. Repetirlo no
1959
+ * duplica nada. Devuelve cuántos accesos dejó.
1960
+ */
1961
+ async grantBoardJobAccess(jobId, agent) {
1962
+ const job = await this.getBoardJob(jobId);
1963
+ const para = getAddress(agent ?? job.agente);
1964
+ if (para === getAddress(NATIVE_CURRENCY))
1965
+ throw new Error(`El encargo #${jobId} no lo tiene nadie todavía.`);
1966
+ const clave = await this.getPublicKey(para);
1967
+ if (!clave)
1968
+ throw new Error(`${para} no ha publicado su clave de cifrado: no se le puede dar acceso.`);
1969
+ const { objetos, accesos } = await this.reunirDelTablon(jobId, false);
1970
+ if (objetos.length === 0)
1971
+ return 0;
1972
+ const mia = await this.encryptionKey();
1973
+ const nuevos = objetos.map((o) => concederAcceso(o, this.account.address, mia.secreta, { cuenta: para, clave }, accesos));
1974
+ await this.dejarAccesos(`${this.buzonUrl}/${this.tablon()}`, jobId, true, nuevos);
1975
+ return nuevos.length;
1976
+ }
1977
+ /**
1978
+ * Da acceso a quien decide la disputa de un encargo del tablón (los
1979
+ * firmantes de la multisig) a lo cifrado que esta cuenta puede bajarse: el
1980
+ * encargo y sus adjuntos y, si es su cliente, la entrega y sus archivos. Sin
1981
+ * esto no podrían leerlo —ni Panal puede— y decidirían a ciegas.
1982
+ * `disputeBoardJob` no lo hace solo: es otra firma, y puede faltarles la clave.
1983
+ */
1984
+ async grantBoardDisputeAccess(jobId) {
1985
+ const { objetos, accesos } = await this.reunirDelTablon(jobId, true);
1986
+ return this.grantArbitratorsAccess({ base: `${this.buzonUrl}/${this.tablon()}`, id: jobId, tablon: true, objetos, accesos });
1987
+ }
1988
+ /**
1989
+ * Todo lo cifrado de un encargo del tablón que esta cuenta puede bajarse,
1990
+ * tal como está en el buzón, y sus accesos. Con `conEntrega`, también la
1991
+ * entrega y sus archivos si es su cliente y ya la hay.
1992
+ */
1993
+ async reunirDelTablon(jobId, conEntrega) {
1994
+ const job = await this.getBoardJob(jobId);
1995
+ const esCliente = this.account?.address.toLowerCase() === job.cliente.toLowerCase();
1996
+ const objetos = [];
1997
+ const accesos = [];
1998
+ const encargo = await this.leerEncargoDelTablon(jobId);
1999
+ accesos.push(...encargo.accesos);
2000
+ if (esTextoCifrado(encargo.texto)) {
2001
+ objetos.push(encargo.texto);
2002
+ const claro = await this.decryptText(encargo.texto, accesos);
2003
+ objetos.push(...(await this.bajarDelTablon(jobId, parseAttachmentsManifest(claro), 'adjunto', true, esCliente)));
2004
+ }
2005
+ if (conEntrega && esCliente && (job.estado === EstadoTablon.Asignado || job.estado === EstadoTablon.Cerrado)) {
2006
+ const tarea = await this.getTask(job.escrowTaskId);
2007
+ if (!/^0x0+$/.test(tarea.resultHash)) {
2008
+ const entrega = await this.leerEntregaDelTablon(jobId);
2009
+ accesos.push(...entrega.accesos);
2010
+ if (esTextoCifrado(entrega.texto)) {
2011
+ objetos.push(entrega.texto);
2012
+ const claro = await this.decryptText(entrega.texto, accesos);
2013
+ objetos.push(...(await this.bajarDelTablon(jobId, parseFilesManifest(claro), 'entrega', true, true)));
2014
+ }
2015
+ }
2016
+ }
2017
+ return { objetos, accesos };
1495
2018
  }
1496
2019
  /** Asignar un encargo de selección cuyo cliente no eligió. Lo puede llamar cualquiera. */
1497
2020
  async autoAssignBoardJob(jobId) {