@panal/sdk 0.25.0 → 0.27.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,17 +15,19 @@
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, fetchBytesLimited, fetchLimited, rutaDeAgente } from './net.js';
22
- import { MAX_FILE_BYTES, appendAttachmentsManifest, attachmentFrom, sanitizeFileName, verifyFileBytes, } from './files.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';
23
24
  import { X402Error, payAndAsk, quoteAsk } from './x402.js';
25
+ import { MAX_CONSULTA_FILES, downloadConsultaFile, uploadConsultaFile, } from './consultas.js';
24
26
  import { descend, newEnvelope, remainingBudget } from './envelope.js';
25
27
  import { NATIVE_CURRENCY, addressesFor, chainFor } from './chains.js';
26
28
  import { leerPrecios, valorDeVenta } from './precios.js';
27
29
  import { panalTablonAbi } from './tablon-abi.js';
28
- import { EstadoTablon, MAX_CATEGORIAS, ModoTablon, encargoDeLaCadena, entregaConArchivos, ofertaDeLaCadena, problemaAlPublicar, problemaConArchivos, situacionDe, tablonArchivoSignMessage, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
30
+ import { EstadoTablon, MAX_CATEGORIAS, ModoTablon, encargoDeLaCadena, entregaConArchivos, ofertaDeLaCadena, problemaAlPublicar, problemaConArchivos, situacionDe, tablonAccesoSignMessage, tablonArchivoSignMessage, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
29
31
  import { BUZON_URL, TABLON, VENTANA_FIRMA_S, encargoSignMessage, entregaSignMessage, ofertaSignMessage, } from './tablon.js';
30
32
  import { TaskStatus, formatAgentMetadata, parseAgentMetadata, } from './types.js';
31
33
  /**
@@ -134,7 +136,7 @@ export class PanalClient {
134
136
  'Usa network: "mainnet", o pasa `addresses` con los tuyos.');
135
137
  }
136
138
  this.indexerUrl = options.indexerUrl === undefined ? 'https://api.panal.lat' : options.indexerUrl;
137
- this.buzonUrl = (options.buzonUrl ?? BUZON_URL).replace(/\/+$/, '');
139
+ this.buzonUrl = sinBarraFinal(options.buzonUrl ?? BUZON_URL);
138
140
  const transport = http(options.rpcUrl ?? chain.rpcUrls.default.http[0]);
139
141
  this.publicClient = createPublicClient({ chain, transport });
140
142
  this.account = options.account;
@@ -220,6 +222,220 @@ export class PanalClient {
220
222
  }
221
223
  }
222
224
  // -------------------------------------------------------------------------
225
+ // Cifrado
226
+ // -------------------------------------------------------------------------
227
+ /** La clave de cifrado de esta cuenta, una vez sacada: no se vuelve a firmar. */
228
+ clave;
229
+ /**
230
+ * Si lo de un encargo del tablón va cifrado y con qué accesos se abre, por
231
+ * `jobId:adjunto` o `jobId:entrega`. Se aprende al leer el encargo o la
232
+ * entrega y lo usa `downloadBoardJobFiles`, que sin esto pediría los
233
+ * archivos por su nombre de verdad y el buzón los guarda con uno opaco.
234
+ */
235
+ cifradoDelTablon = new Map();
236
+ /**
237
+ * La clave de cifrado de esta cuenta. Sale de la firma de un mensaje fijo
238
+ * —la misma cuenta firma siempre lo mismo (RFC 6979)—, así que es la misma
239
+ * que en la web y en la app, y no se guarda en ningún sitio.
240
+ */
241
+ async encryptionKey() {
242
+ if (this.clave)
243
+ return this.clave;
244
+ const firma = await this.wallet().signMessage({ account: this.account, message: mensajeParaDerivarClave(this.account.address) });
245
+ this.clave = claveDesdeFirma(this.account.address, firma);
246
+ return this.clave;
247
+ }
248
+ /**
249
+ * La clave pública de una cuenta, comprobada contra la firma que la ata a
250
+ * ella. `null` si no ha publicado ninguna, o si lo que sirve el buzón no es
251
+ * suyo: cifrar para una clave falsa sería entregarle el encargo a quien la puso.
252
+ */
253
+ async getPublicKey(cuenta) {
254
+ try {
255
+ const { status, text } = await fetchLimited(`${this.buzonUrl}/${getAddress(cuenta)}/clave`, { timeoutMs: 10_000, maxBytes: 10_000 });
256
+ if (status !== 200)
257
+ return null;
258
+ const b = JSON.parse(text);
259
+ if (typeof b.clave !== 'string' || typeof b.firma !== 'string')
260
+ return null;
261
+ const ok = await comprobarClavePublicada({ cuenta, clave: b.clave, firma: b.firma }, this.publicClient);
262
+ return ok ? b.clave : null;
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ }
268
+ /**
269
+ * Deja publicada la clave de esta cuenta (dos firmas, sin gas, una vez). Si
270
+ * ya estaba, no firma nada más. Si había OTRA, la sustituye y `cambio` lo
271
+ * dice: lo cifrado para la anterior ya no se abrirá con esta.
272
+ */
273
+ async publishEncryptionKey() {
274
+ const clave = await this.encryptionKey();
275
+ const yo = this.account.address;
276
+ const publicada = await this.getPublicKey(yo);
277
+ if (publicada === clave.publica)
278
+ return { publica: clave.publica, cambio: false };
279
+ const firma = await this.wallet().signMessage({ account: this.account, message: mensajeParaPublicarClave(yo, clave.publica) });
280
+ const { status, text } = await fetchLimited(`${this.buzonUrl}/${yo}/clave`, {
281
+ method: 'POST',
282
+ headers: { 'content-type': 'application/json' },
283
+ body: JSON.stringify({ clave: clave.publica, firma }),
284
+ timeoutMs: 15_000,
285
+ maxBytes: 10_000,
286
+ });
287
+ if (status !== 200)
288
+ throw new Error(`El buzón no aceptó la clave de cifrado (${status}: ${text.slice(0, 160)}).`);
289
+ return { publica: clave.publica, cambio: publicada !== null };
290
+ }
291
+ /**
292
+ * Un texto, abierto: tal cual si va en claro; con la clave de esta cuenta y
293
+ * sus `accesos` si va cifrado. Falla diciendo que no hay acceso, sin pedir
294
+ * ninguna firma, si esta cuenta todavía no puede abrirlo.
295
+ */
296
+ async decryptText(texto, accesos = []) {
297
+ if (!esTextoCifrado(texto))
298
+ return texto;
299
+ this.wallet();
300
+ if (!puedeAbrir(texto, this.account.address, accesos)) {
301
+ throw new Error('Va cifrado y esta cuenta todavía no tiene acceso: nadie se lo ha dado.');
302
+ }
303
+ return descifrarTexto(texto, this.account.address, (await this.encryptionKey()).secreta, accesos);
304
+ }
305
+ /** Los bytes de un archivo, abiertos si van cifrados. */
306
+ async decryptFile(bytes, accesos = []) {
307
+ if (!esArchivoCifrado(bytes))
308
+ return bytes;
309
+ this.wallet();
310
+ return descifrarArchivo(bytes, this.account.address, (await this.encryptionKey()).secreta, accesos);
311
+ }
312
+ /**
313
+ * El encargo de una tarea DIRECTA, cifrado de punta a punta para quien lo
314
+ * hace y para esta cuenta, que tiene que poder releerlo y recibir la entrega
315
+ * cifrada (por eso publica también su clave, la primera vez).
316
+ *
317
+ * Solo tiene sentido con quien recibe en el buzón de Panal: a un agente con
318
+ * servidor propio el encargo le llega a SU máquina, y cifrárselo sería
319
+ * cifrar para el mismo que lo va a leer. Eso lo decide quien llama.
320
+ *
321
+ * Devuelve `null` si quien lo hace no ha publicado su clave: no hay a quién
322
+ * cifrar. Si no, el texto que se hashea y se manda —con el manifiesto de
323
+ * adjuntos dentro, con su nombre de verdad— y los adjuntos tal como se suben:
324
+ * cifrados y con su nombre opaco, que es lo único que ve el buzón.
325
+ */
326
+ async encryptBriefFor(worker, texto, adjuntos = []) {
327
+ this.wallet();
328
+ const suya = await this.getPublicKey(worker);
329
+ if (!suya)
330
+ return null;
331
+ const mia = (await this.publishEncryptionKey()).publica;
332
+ const para = [
333
+ { cuenta: getAddress(worker), clave: suya },
334
+ { cuenta: this.account.address, clave: mia },
335
+ ];
336
+ const cifrados = adjuntos.map((a) => ({ ...a, bytes: cifrarArchivo(a.bytes, para) }));
337
+ const enClaro = appendAttachmentsManifest(texto, cifrados.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
338
+ return {
339
+ brief: cifrarTexto(enClaro, para),
340
+ archivos: cifrados.map((a) => ({ file: attachmentFrom(nombreEnBuzon(keccak256(a.bytes)), a.bytes), bytes: a.bytes })),
341
+ };
342
+ }
343
+ /**
344
+ * Quiénes deciden las disputas: los firmantes de la multisig que es el
345
+ * `arbitrator` del escrow. Si el árbitro fuera una cuenta y no una
346
+ * multisig, es él.
347
+ */
348
+ async getArbitrators() {
349
+ const arbitrator = (await this.publicClient.readContract({
350
+ address: this.addresses.escrow,
351
+ abi: parseAbi(['function arbitrator() view returns (address)']),
352
+ functionName: 'arbitrator',
353
+ }));
354
+ const firmantes = [];
355
+ for (let i = 0; i < 10; i++) {
356
+ try {
357
+ firmantes.push((await this.publicClient.readContract({
358
+ address: arbitrator,
359
+ abi: parseAbi(['function owners(uint256) view returns (address)']),
360
+ functionName: 'owners',
361
+ args: [BigInt(i)],
362
+ })));
363
+ }
364
+ catch {
365
+ break;
366
+ }
367
+ }
368
+ return firmantes.length > 0 ? firmantes : [arbitrator];
369
+ }
370
+ /**
371
+ * Deja accesos en un buzón —el de un agente (`base` = su URL de buzón) o el
372
+ * del tablón— con una firma para todos. El buzón solo los acepta de una
373
+ * parte, y solo para la otra parte o para quien decide; van en trozos de
374
+ * 40, que es lo que admite por petición.
375
+ */
376
+ async dejarAccesos(base, id, esTablon, accesos) {
377
+ if (accesos.length === 0)
378
+ return;
379
+ const expira = Math.floor(Date.now() / 1000) + VENTANA_FIRMA_S;
380
+ const signature = await this.wallet().signMessage({
381
+ account: this.account,
382
+ message: esTablon ? tablonAccesoSignMessage(id, expira) : accesoSignMessage(id, expira),
383
+ });
384
+ for (let i = 0; i < accesos.length; i += 40) {
385
+ const { status, text } = await fetchLimited(`${sinBarraFinal(base)}/access/${id}`, {
386
+ method: 'POST',
387
+ headers: { 'content-type': 'application/json' },
388
+ body: JSON.stringify({ accesos: accesos.slice(i, i + 40), address: this.account.address, signature, expira }),
389
+ timeoutMs: 15_000,
390
+ maxBytes: 10_000,
391
+ });
392
+ if (status !== 200)
393
+ throw new Error(`El buzón no aceptó los accesos del #${id} (${status}: ${text.slice(0, 160)}).`);
394
+ }
395
+ }
396
+ /**
397
+ * Da acceso a quien decide la disputa a todo lo cifrado de `objetos` que
398
+ * esta cuenta puede abrir: textos anclados y bytes de archivos, tal como
399
+ * están en el buzón. Lo que va en claro se salta (eso ya lo lee).
400
+ *
401
+ * `base` es el buzón de quien trabaja (tarea directa, `id` = la tarea) o el
402
+ * del tablón (`tablon: true`, `id` = el encargo). Devuelve a cuántos
403
+ * firmantes se les dio y quiénes no tienen clave todavía: a esos no se les
404
+ * puede dar hasta que la publiquen.
405
+ */
406
+ async grantArbitratorsAccess(o) {
407
+ this.wallet();
408
+ const cifrados = o.objetos.filter((x) => (typeof x === 'string' ? esTextoCifrado(x) : esArchivoCifrado(x)));
409
+ if (cifrados.length === 0)
410
+ return { arbitros: 0, sinClave: [], objetos: 0 };
411
+ const conClave = [];
412
+ const sinClave = [];
413
+ for (const a of await this.getArbitrators()) {
414
+ const clave = await this.getPublicKey(a);
415
+ if (clave)
416
+ conClave.push({ cuenta: a, clave });
417
+ else
418
+ sinClave.push(a);
419
+ }
420
+ if (conClave.length === 0)
421
+ return { arbitros: 0, sinClave, objetos: cifrados.length };
422
+ const mia = await this.encryptionKey();
423
+ const nuevos = [];
424
+ for (const objeto of cifrados) {
425
+ for (const d of conClave) {
426
+ // Lo que esta cuenta no puede abrir no lo puede compartir: se salta.
427
+ try {
428
+ nuevos.push(concederAcceso(objeto, this.account.address, mia.secreta, d, o.accesos ?? []));
429
+ }
430
+ catch {
431
+ /* no es de los suyos */
432
+ }
433
+ }
434
+ }
435
+ await this.dejarAccesos(o.base, o.id, o.tablon === true, nuevos);
436
+ return { arbitros: conClave.length, sinClave, objetos: cifrados.length };
437
+ }
438
+ // -------------------------------------------------------------------------
223
439
  // Lectura
224
440
  // -------------------------------------------------------------------------
225
441
  /** Todos los agentes del registry, activos e inactivos. */
@@ -929,7 +1145,7 @@ export class PanalClient {
929
1145
  throw new Error(`La tarea #${taskId} está "${TaskStatus[task.status]}": solo se entrega lo que sigue abierto.`);
930
1146
  }
931
1147
  const ficha = await this.leerAgente(yo);
932
- const canal = ficha.metadata.botUrl?.replace(/\/+$/, '') ?? '';
1148
+ const canal = ficha.metadata.botUrl ? sinBarraFinal(ficha.metadata.botUrl) : '';
933
1149
  if (!canal) {
934
1150
  throw new Error(`${yo} no publica ningún canal (\`bot:\`): el cliente no tendría de dónde recoger la entrega.`);
935
1151
  }
@@ -978,13 +1194,82 @@ export class PanalClient {
978
1194
  async askAgent(agent, prompt, options) {
979
1195
  const wallet = this.wallet();
980
1196
  const endpoint = await this.x402Endpoint(agent, options);
981
- return payAndAsk(wallet, this.account, endpoint, prompt, {
1197
+ const archivos = options.files ?? [];
1198
+ let quote = options.quote;
1199
+ let manifiesto = [];
1200
+ if (archivos.length) {
1201
+ // Se cotiza ANTES de subir nada: si no acepta archivos, no se deja ni uno.
1202
+ quote ??= await quoteAsk(endpoint, prompt, { payer: this.account.address, ...options });
1203
+ this.comprobarQueAceptaArchivos(quote, archivos);
1204
+ manifiesto = await this.uploadFilesFor(agent, archivos);
1205
+ }
1206
+ const result = await payAndAsk(wallet, this.account, endpoint, prompt, {
982
1207
  ...options,
1208
+ ...(quote ? { quote } : {}),
1209
+ files: manifiesto,
983
1210
  chainId: chainFor(this.network).id,
984
1211
  // Se ata a quién esperamos pagar: si el endpoint estuviera secuestrado y
985
1212
  // cotizara a nombre de otro, la firma no llega a producirse.
986
1213
  expectedPayee: agent,
987
1214
  });
1215
+ return { ...result, files: await this.receiveFiles(result.fileManifest) };
1216
+ }
1217
+ /** Lanza si la cotización no admite estos archivos: sin firmar ni subir nada. */
1218
+ comprobarQueAceptaArchivos(accept, archivos) {
1219
+ if (archivos.length > MAX_CONSULTA_FILES) {
1220
+ throw new X402Error(`Como mucho ${MAX_CONSULTA_FILES} archivos por pregunta, y mandas ${archivos.length}.`);
1221
+ }
1222
+ if (!accept.files)
1223
+ throw new X402Error('Este agente no acepta archivos con la pregunta.');
1224
+ if (archivos.length > accept.files.max) {
1225
+ throw new X402Error(`Este agente acepta ${accept.files.max} archivo(s) por pregunta y mandas ${archivos.length}.`);
1226
+ }
1227
+ const grande = archivos.find((f) => f.bytes.byteLength > accept.files.maxBytes || f.bytes.byteLength > MAX_FILE_BYTES);
1228
+ if (grande)
1229
+ throw new X402Error(`"${grande.name}" pasa del tamaño que acepta este agente.`);
1230
+ }
1231
+ /**
1232
+ * Deja archivos en el buzón para `para`. Si publicó clave, cifrados para él
1233
+ * y con un nombre opaco: el buzón no ve ni el contenido ni cómo se llaman, y
1234
+ * el nombre real viaja solo en la lista que recibe el agente.
1235
+ */
1236
+ async uploadFilesFor(para, archivos) {
1237
+ const wallet = this.wallet();
1238
+ const yo = this.account.address;
1239
+ const suClave = await this.getPublicKey(para);
1240
+ const sign = (message) => wallet.signMessage({ account: this.account, message });
1241
+ const out = [];
1242
+ for (const f of archivos) {
1243
+ const bytes = suClave ? new Uint8Array(cifrarArchivo(f.bytes, [{ cuenta: getAddress(para), clave: suClave }])) : f.bytes;
1244
+ const subido = await uploadConsultaFile({
1245
+ buzonUrl: this.buzonUrl,
1246
+ para,
1247
+ de: yo,
1248
+ file: { name: suClave ? nombreEnBuzon(keccak256(bytes)) : f.name, bytes, ...(suClave ? {} : f.mime ? { mime: f.mime } : {}) },
1249
+ sign,
1250
+ });
1251
+ out.push({ ...subido, name: sanitizeFileName(f.name), ...(f.mime ? { mime: f.mime } : {}) });
1252
+ }
1253
+ return out;
1254
+ }
1255
+ /**
1256
+ * Los archivos que alguien dejó en el buzón para esta cuenta (la respuesta
1257
+ * de un agente, o la pregunta de otro): bajados, comprobados contra su hash
1258
+ * y abiertos si iban cifrados.
1259
+ */
1260
+ async receiveFiles(manifest) {
1261
+ if (!manifest.length)
1262
+ return [];
1263
+ const wallet = this.wallet();
1264
+ const yo = this.account.address;
1265
+ const sign = (message) => wallet.signMessage({ account: this.account, message });
1266
+ const out = [];
1267
+ for (const f of manifest) {
1268
+ const crudo = await downloadConsultaFile({ buzonUrl: this.buzonUrl, para: yo, lector: yo, file: f, sign });
1269
+ const bytes = await this.decryptFile(crudo);
1270
+ out.push({ name: f.name, size: bytes.byteLength, bytes, ...(f.mime ? { mime: f.mime } : {}) });
1271
+ }
1272
+ return out;
988
1273
  }
989
1274
  /**
990
1275
  * Busca un agente con esa skill, negocia el precio y le paga por la consulta.
@@ -1003,6 +1288,7 @@ export class PanalClient {
1003
1288
  */
1004
1289
  async ask(skill, prompt, options) {
1005
1290
  const wallet = this.wallet();
1291
+ const archivos = options.files ?? [];
1006
1292
  const excluded = new Set([...(options.exclude ?? []), this.account.address].map((a) => getAddress(a).toLowerCase()));
1007
1293
  // El presupuesto real es el menor entre lo que dice el sobre y el tope de
1008
1294
  // esta llamada: heredar una cadena no puede ampliar lo que autorizaste.
@@ -1058,6 +1344,8 @@ export class PanalClient {
1058
1344
  ...options,
1059
1345
  envelope: heredado,
1060
1346
  });
1347
+ if (archivos.length)
1348
+ this.comprobarQueAceptaArchivos(accept, archivos);
1061
1349
  if (getAddress(accept.asset) !== moneda) {
1062
1350
  rechazos.push(`${agent.metadata.name || agent.address}: cobra en ${accept.asset}, no en ${moneda}`);
1063
1351
  }
@@ -1078,8 +1366,11 @@ export class PanalClient {
1078
1366
  // Se desciende el sobre ANTES de firmar: aquí es donde se comprueba que
1079
1367
  // quedan saltos, que hay presupuesto y que no estamos cerrando un ciclo.
1080
1368
  const siguiente = descend(heredado, this.account.address, BigInt(elegido.accept.amount));
1369
+ // Los archivos se suben SOLO para el elegido, y ya sabiendo que los acepta.
1370
+ const manifiesto = archivos.length ? await this.uploadFilesFor(elegido.agent.address, archivos) : [];
1081
1371
  const result = await payAndAsk(wallet, this.account, elegido.endpoint, prompt, {
1082
1372
  ...options,
1373
+ files: manifiesto,
1083
1374
  maxSpend: tope,
1084
1375
  chainId: chainFor(this.network).id,
1085
1376
  expectedPayee: elegido.agent.address,
@@ -1090,7 +1381,12 @@ export class PanalClient {
1090
1381
  // `skill` es la que de verdad encontró al vendedor, que puede no ser la que
1091
1382
  // pediste: quien llama necesita poder decirlo en su log, o cada búsqueda
1092
1383
  // ensanchada es un cambio de comportamiento invisible.
1093
- return { ...result, agent: elegido.agent.address, skill: usada };
1384
+ return {
1385
+ ...result,
1386
+ files: await this.receiveFiles(result.fileManifest),
1387
+ agent: elegido.agent.address,
1388
+ skill: usada,
1389
+ };
1094
1390
  }
1095
1391
  /**
1096
1392
  * Dónde escucha el x402 de un agente.
@@ -1287,7 +1583,21 @@ export class PanalClient {
1287
1583
  if (adjuntos.length > 0 && !(await this.buzonAceptaArchivos())) {
1288
1584
  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.');
1289
1585
  }
1290
- const encargo = appendAttachmentsManifest(p.encargo, adjuntos.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
1586
+ let encargo = appendAttachmentsManifest(p.encargo, adjuntos.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
1587
+ // Lo que se sube: los adjuntos tal cual, o cifrados y con nombre opaco.
1588
+ let aSubir = adjuntos;
1589
+ if (p.confidencial) {
1590
+ /*
1591
+ * Cifrado solo para quien publica, antes de pagar nada: a quien coja el
1592
+ * encargo se le da acceso al elegirlo (`chooseBoardOffer`). Los adjuntos
1593
+ * se anuncian en abierto por su hash y su tamaño: el buzón solo acepta
1594
+ * los que el encargo anuncia, y no puede leerlo.
1595
+ */
1596
+ const para = [{ cuenta: this.account.address, clave: (await this.publishEncryptionKey()).publica }];
1597
+ const cifrados = adjuntos.map((a) => ({ ...a, bytes: cifrarArchivo(a.bytes, para) }));
1598
+ 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 })) });
1599
+ aSubir = cifrados.map((a) => ({ name: nombreEnBuzon(keccak256(a.bytes)), bytes: a.bytes }));
1600
+ }
1291
1601
  const taskHash = keccak256(toBytes(encargo));
1292
1602
  const advertHash = keccak256(toBytes(p.anuncio));
1293
1603
  const seleccion = p.modo === ModoTablon.Seleccion;
@@ -1317,7 +1627,7 @@ export class PanalClient {
1317
1627
  // Los textos van DESPUÉS de la cadena: hasta que el encargo existe, el buzón
1318
1628
  // no tiene hash con qué comprobarlos. Si fallan, el encargo está pagado y
1319
1629
  // sin anunciar: se dice y se reintenta con `publishBoardJobTexts`.
1320
- const base = { jobId, txHash: hash, taskHash, advertHash, encargo };
1630
+ const base = { jobId, txHash: hash, taskHash, advertHash, encargo, archivos: aSubir };
1321
1631
  try {
1322
1632
  await this.publishBoardJobTexts(jobId, { anuncio: p.anuncio, encargo });
1323
1633
  }
@@ -1327,7 +1637,7 @@ export class PanalClient {
1327
1637
  }
1328
1638
  // Los bytes, después del encargo: el buzón solo acepta los que anuncia.
1329
1639
  try {
1330
- await this.publishBoardJobFiles(jobId, adjuntos);
1640
+ await this.publishBoardJobFiles(jobId, aSubir);
1331
1641
  return { ...base, textosGuardados: true, archivosGuardados: true };
1332
1642
  }
1333
1643
  catch (err) {
@@ -1385,7 +1695,7 @@ export class PanalClient {
1385
1695
  * El hash viene del texto anclado —`readBoardJobBrief` o
1386
1696
  * `readBoardJobResult`—, nunca del buzón. Una firma para todos.
1387
1697
  */
1388
- async downloadBoardJobFiles(jobId, archivos, de) {
1698
+ async downloadBoardJobFiles(jobId, archivos, de, opciones = {}) {
1389
1699
  if (archivos.length === 0)
1390
1700
  return [];
1391
1701
  const job = await this.getBoardJob(jobId);
@@ -1394,11 +1704,38 @@ export class PanalClient {
1394
1704
  if (!esCliente && yo !== job.agente.toLowerCase()) {
1395
1705
  throw new Error(`Los archivos del #${jobId} solo los bajan su cliente y su agente.`);
1396
1706
  }
1707
+ // Si el texto que los anuncia iba cifrado, el buzón los guarda con su
1708
+ // nombre opaco y hay que abrirlos. Si no se ha leído aún, se lee: un agente
1709
+ // que retoma un encargo tras reiniciarse no lo tiene en memoria.
1710
+ const estado = await this.estadoDeCifrado(jobId, de === 'entrega' && esCliente ? 'entrega' : 'adjunto');
1711
+ const crudos = await this.bajarDelTablon(jobId, archivos, de, estado.cifrado, esCliente);
1712
+ if (opciones.abrir === false)
1713
+ return crudos;
1714
+ const out = [];
1715
+ for (const bytes of crudos)
1716
+ out.push(await this.decryptFile(bytes, estado.accesos));
1717
+ return out;
1718
+ }
1719
+ /**
1720
+ * Abre un archivo de un encargo del tablón bajado con `abrir: false`: con
1721
+ * la clave de esta cuenta y sus accesos. Si no va cifrado, lo devuelve tal cual.
1722
+ */
1723
+ async openBoardJobFile(jobId, bytes, de = 'adjunto') {
1724
+ if (!esArchivoCifrado(bytes))
1725
+ return bytes;
1726
+ const job = await this.getBoardJob(jobId);
1727
+ const esCliente = this.account?.address.toLowerCase() === job.cliente.toLowerCase();
1728
+ const estado = await this.estadoDeCifrado(jobId, de === 'entrega' && esCliente ? 'entrega' : 'adjunto');
1729
+ return this.decryptFile(bytes, estado.accesos);
1730
+ }
1731
+ /** Los bytes de unos archivos del tablón, comprobados contra su hash y SIN abrir. */
1732
+ async bajarDelTablon(jobId, archivos, de, cifrado, esCliente) {
1397
1733
  // El cliente abre la entrega con la firma de llevársela; lo demás, con la de leer el encargo.
1398
1734
  const cabeceras = await this.credencialesDeBuzon((e) => de === 'entrega' && esCliente ? tablonResultadoSignMessage(jobId, e) : tablonEncargoSignMessage(jobId, e));
1399
1735
  const out = [];
1400
1736
  for (const f of archivos) {
1401
- const { status, bytes } = await fetchBytesLimited(`${this.buzonUrl}/${this.tablon()}/archivo/${jobId}/${encodeURIComponent(sanitizeFileName(f.name))}?de=${de}`, { headers: cabeceras, timeoutMs: 180_000, maxBytes: Math.min(MAX_FILE_BYTES, f.size + 1024) });
1737
+ const nombre = cifrado ? nombreEnBuzon(f.hash) : sanitizeFileName(f.name);
1738
+ 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) });
1402
1739
  if (status === 404)
1403
1740
  throw new Error(`«${f.name}» no está en el buzón del tablón.`);
1404
1741
  if (status !== 200)
@@ -1408,6 +1745,22 @@ export class PanalClient {
1408
1745
  }
1409
1746
  return out;
1410
1747
  }
1748
+ /**
1749
+ * Si el encargo (`adjunto`) o la entrega (`entrega`) de un encargo del
1750
+ * tablón va cifrado, y los accesos de esta cuenta. Lo recuerda de la última
1751
+ * lectura; si no hay, lee. La entrega solo la lee su cliente: un agente
1752
+ * cifra la suya si el encargo iba cifrado, así que mira el encargo.
1753
+ */
1754
+ async estadoDeCifrado(jobId, de) {
1755
+ const clave = `${jobId}:${de}`;
1756
+ const ya = this.cifradoDelTablon.get(clave);
1757
+ if (ya)
1758
+ return ya;
1759
+ const leido = de === 'entrega' ? await this.leerEntregaDelTablon(jobId) : await this.leerEncargoDelTablon(jobId);
1760
+ const estado = { cifrado: esTextoCifrado(leido.texto), accesos: leido.accesos };
1761
+ this.cifradoDelTablon.set(clave, estado);
1762
+ return estado;
1763
+ }
1411
1764
  /**
1412
1765
  * Deja en el buzón el anuncio y el encargo de un encargo ya publicado. Sin
1413
1766
  * firma: el buzón solo acepta los textos que dan los hashes de la cadena.
@@ -1442,28 +1795,58 @@ export class PanalClient {
1442
1795
  * Un anuncio que no cuadre no se devuelve: el buzón no puede inventarse uno.
1443
1796
  */
1444
1797
  async listBoardJobAdverts(opciones = {}) {
1445
- const [encargos, respuesta] = await Promise.all([
1446
- this.listBoardJobs({ limit: opciones.limit }),
1447
- fetchLimited(`${this.buzonUrl}/${this.tablon()}/lista`, { timeoutMs: 15_000, maxBytes: 2_000_000 }),
1448
- ]);
1449
- if (respuesta.status !== 200)
1450
- throw new Error(`El buzón respondió ${respuesta.status} al pedir los anuncios.`);
1451
- const { anuncios = [] } = JSON.parse(respuesta.text);
1452
- const porId = new Map(anuncios.map((a) => [a.jobId, a.anuncio]));
1798
+ const [encargos, anuncios] = await Promise.all([this.listBoardJobs({ limit: opciones.limit }), this.anunciosDelBuzon()]);
1453
1799
  return encargos.flatMap((j) => {
1454
- const anuncio = porId.get(j.id.toString());
1455
- if (typeof anuncio !== 'string')
1800
+ const a = anuncios.get(j.id.toString());
1801
+ if (!a)
1456
1802
  return [];
1457
- if (keccak256(toBytes(anuncio)).toLowerCase() !== j.advertHash.toLowerCase())
1803
+ if (keccak256(toBytes(a.anuncio)).toLowerCase() !== j.advertHash.toLowerCase())
1458
1804
  return [];
1459
- return [{ ...j, anuncio }];
1805
+ return [{ ...j, anuncio: a.anuncio, confidencial: a.cifrado }];
1460
1806
  });
1461
1807
  }
1808
+ /**
1809
+ * Los anuncios que guarda el buzón del tablón, con si su encargo va cifrado
1810
+ * (confidencial). Eso lo dice el buzón y no la cadena: si mintiera, lo que
1811
+ * cambia es un aviso, y quien coja el encargo lo verá al abrirlo.
1812
+ */
1813
+ async anunciosDelBuzon() {
1814
+ const respuesta = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/lista`, { timeoutMs: 15_000, maxBytes: 2_000_000 });
1815
+ if (respuesta.status !== 200)
1816
+ throw new Error(`El buzón respondió ${respuesta.status} al pedir los anuncios.`);
1817
+ const { anuncios = [] } = JSON.parse(respuesta.text);
1818
+ return new Map(anuncios
1819
+ .filter((a) => typeof a.anuncio === 'string')
1820
+ .map((a) => [a.jobId, { anuncio: a.anuncio, cifrado: a.cifrado === true }]));
1821
+ }
1822
+ /**
1823
+ * Si un encargo del tablón es confidencial: su encargo va cifrado y solo lo
1824
+ * lee quien su cliente elija, que tiene que tener su clave publicada.
1825
+ */
1826
+ async isBoardJobConfidential(jobId) {
1827
+ return (await this.anunciosDelBuzon()).get(jobId.toString())?.cifrado === true;
1828
+ }
1462
1829
  /**
1463
1830
  * El encargo privado. Lo leen su cliente y su agente en cuanto lo tiene, y
1464
1831
  * se comprueba contra el `taskHash` de la cadena antes de devolverlo.
1465
1832
  */
1466
1833
  async readBoardJobBrief(jobId) {
1834
+ const { texto, accesos } = await this.leerEncargoDelTablon(jobId);
1835
+ const cifrado = esTextoCifrado(texto);
1836
+ this.cifradoDelTablon.set(`${jobId}:adjunto`, { cifrado, accesos });
1837
+ // Confidencial: se abre con la clave de esta cuenta y el acceso que da su
1838
+ // cliente al elegirla. Sin acceso todavía, se dice, sin pedir firmas.
1839
+ if (cifrado && !puedeAbrir(texto, this.account.address, accesos)) {
1840
+ throw new Error(`El encargo #${jobId} es confidencial y su cliente todavía no le ha dado acceso a esta cuenta ` +
1841
+ '(se lo da al elegirla). Vuelve a intentarlo en un rato.');
1842
+ }
1843
+ return this.decryptText(texto, accesos);
1844
+ }
1845
+ /**
1846
+ * El encargo del tablón tal como está anclado —en claro o cifrado—, con los
1847
+ * accesos que tiene esta cuenta. Comprobado contra el `taskHash`.
1848
+ */
1849
+ async leerEncargoDelTablon(jobId) {
1467
1850
  const job = await this.getBoardJob(jobId);
1468
1851
  const yo = this.account?.address.toLowerCase();
1469
1852
  if (yo !== job.cliente.toLowerCase() && yo !== job.agente.toLowerCase()) {
@@ -1478,11 +1861,11 @@ export class PanalClient {
1478
1861
  throw new Error(`El buzón no tiene el encargo #${jobId}: su cliente no llegó a dejarlo.`);
1479
1862
  if (status !== 200)
1480
1863
  throw new Error(`El buzón respondió ${status} al pedir el encargo #${jobId}.`);
1481
- const { brief } = JSON.parse(text);
1864
+ const { brief, accesos } = JSON.parse(text);
1482
1865
  if (typeof brief !== 'string' || keccak256(toBytes(brief)).toLowerCase() !== job.taskHash.toLowerCase()) {
1483
1866
  throw new Error(`El encargo #${jobId} del buzón no cuadra con el taskHash de la cadena: no se trabaja sobre él.`);
1484
1867
  }
1485
- return brief;
1868
+ return { texto: brief, accesos: Array.isArray(accesos) ? accesos : [] };
1486
1869
  }
1487
1870
  /**
1488
1871
  * Entregar un encargo del tablón con reglas: se deja en el buzón y DESPUÉS
@@ -1497,18 +1880,42 @@ export class PanalClient {
1497
1880
  }
1498
1881
  if (job.estado !== EstadoTablon.Asignado)
1499
1882
  throw new Error(`El encargo #${jobId} no está asignado: no hay nada que entregar.`);
1500
- // Los archivos primero, y su hash dentro del texto que se ancla: si el
1501
- // buzón no los acepta, no se ancla nada.
1502
- let texto = textoEscrito;
1503
1883
  if (archivos.length > 0) {
1504
1884
  const problema = problemaConArchivos(archivos);
1505
1885
  if (problema)
1506
1886
  throw new Error(problema);
1887
+ }
1888
+ /*
1889
+ * Confidencial: la entrega y sus archivos van cifrados para el cliente y
1890
+ * para quien entrega. Sin la clave del cliente no se entrega en claro: el
1891
+ * encargo era confidencial, y su entrega también.
1892
+ */
1893
+ let para = null;
1894
+ if (esTextoCifrado((await this.leerEncargoDelTablon(jobId)).texto)) {
1895
+ const delCliente = await this.getPublicKey(job.cliente);
1896
+ if (!delCliente) {
1897
+ throw new Error(`El encargo #${jobId} es confidencial y su cliente no tiene clave de cifrado publicada: la entrega no ` +
1898
+ 'puede ir cifrada, y en claro no se entrega. No se ha anclado nada; vuelve a intentarlo en un rato.');
1899
+ }
1900
+ para = [
1901
+ { cuenta: job.cliente, clave: delCliente },
1902
+ { cuenta: this.account.address, clave: (await this.publishEncryptionKey()).publica },
1903
+ ];
1904
+ }
1905
+ // Lo que va en el manifiesto (nombre de verdad) y lo que se sube (cifrado y con nombre opaco).
1906
+ const anunciar = para ? archivos.map((a) => ({ ...a, bytes: cifrarArchivo(a.bytes, para) })) : archivos;
1907
+ const subir = para ? anunciar.map((a) => ({ name: nombreEnBuzon(keccak256(a.bytes)), bytes: a.bytes })) : archivos;
1908
+ // Los archivos primero, y su hash dentro del texto que se ancla: si el
1909
+ // buzón no los acepta, no se ancla nada.
1910
+ let texto = textoEscrito;
1911
+ if (archivos.length > 0) {
1507
1912
  const cabecerasArchivos = await this.credencialesDeBuzon((e) => tablonArchivoSignMessage(jobId, e));
1508
- for (const a of archivos)
1913
+ for (const a of subir)
1509
1914
  await this.subirArchivoAlTablon(jobId, a, cabecerasArchivos);
1510
- texto = entregaConArchivos(jobId, textoEscrito, archivos);
1915
+ texto = entregaConArchivos(jobId, textoEscrito, anunciar);
1511
1916
  }
1917
+ if (para)
1918
+ texto = cifrarTexto(texto, para);
1512
1919
  const hashEntrega = keccak256(toBytes(texto));
1513
1920
  const cabeceras = await this.credencialesDeBuzon((e) => tablonEntregaSignMessage(jobId, hashEntrega, e));
1514
1921
  const { status, text } = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/entrega/${jobId}`, {
@@ -1534,6 +1941,13 @@ export class PanalClient {
1534
1941
  * en el escrow: el buzón no puede cambiarla.
1535
1942
  */
1536
1943
  async readBoardJobResult(jobId) {
1944
+ const { texto, accesos } = await this.leerEntregaDelTablon(jobId);
1945
+ this.cifradoDelTablon.set(`${jobId}:entrega`, { cifrado: esTextoCifrado(texto), accesos });
1946
+ // Cifrada para el cliente y para quien entregó: se abre con la clave de esta cuenta.
1947
+ return this.decryptText(texto, accesos);
1948
+ }
1949
+ /** La entrega tal como está anclada, con los accesos de esta cuenta. */
1950
+ async leerEntregaDelTablon(jobId) {
1537
1951
  const job = await this.getBoardJob(jobId);
1538
1952
  if (this.account?.address.toLowerCase() !== job.cliente.toLowerCase()) {
1539
1953
  throw new Error(`La entrega del #${jobId} solo la descarga su cliente.`);
@@ -1547,12 +1961,12 @@ export class PanalClient {
1547
1961
  throw new Error(`El encargo #${jobId} todavía no tiene entrega en el buzón.`);
1548
1962
  if (status !== 200)
1549
1963
  throw new Error(`El buzón respondió ${status} al pedir la entrega del #${jobId}.`);
1550
- const { resultText } = JSON.parse(text);
1964
+ const { resultText, accesos } = JSON.parse(text);
1551
1965
  const tarea = await this.getTask(job.escrowTaskId);
1552
1966
  if (typeof resultText !== 'string' || keccak256(toBytes(resultText)).toLowerCase() !== tarea.resultHash.toLowerCase()) {
1553
1967
  throw new Error(`La entrega del #${jobId} no cuadra con el resultHash anclado en el escrow.`);
1554
1968
  }
1555
- return resultText;
1969
+ return { texto: resultText, accesos: Array.isArray(accesos) ? accesos : [] };
1556
1970
  }
1557
1971
  /** Coger un encargo inmediato. Antes de firmar pregunta si se puede, y por qué no. */
1558
1972
  async takeBoardJob(jobId) {
@@ -1573,6 +1987,10 @@ export class PanalClient {
1573
1987
  const { ok, motivo } = await this.canTakeBoardJob(this.account.address, jobId);
1574
1988
  if (!ok)
1575
1989
  throw new Error(`No se puede ofertar en el encargo #${jobId}: ${motivo}.`);
1990
+ // Confidencial: sin clave publicada, el cliente no podría darle acceso al
1991
+ // elegirla y no podría leer lo que tiene que hacer. Se publica antes.
1992
+ if (await this.isBoardJobConfidential(jobId))
1993
+ await this.publishEncryptionKey();
1576
1994
  return (await this.firmarEnTablon('La oferta', 'applyFor', [jobId, precio, BigInt(plazoEntregaS)])).hash;
1577
1995
  }
1578
1996
  async withdrawBoardOffer(jobId) {
@@ -1588,6 +2006,13 @@ export class PanalClient {
1588
2006
  const oferta = ofertas.find((o) => o.agente.toLowerCase() === agent.toLowerCase());
1589
2007
  if (!oferta)
1590
2008
  throw new Error(`${agent} no tiene oferta en el encargo #${jobId}.`);
2009
+ // Confidencial: solo se elige a quien se le pueda dar acceso. Se mira antes
2010
+ // de pagar nada; elegir a quien no puede leer el encargo es pagar por nada.
2011
+ const confidencial = esTextoCifrado((await this.leerEncargoDelTablon(jobId)).texto);
2012
+ if (confidencial && !(await this.getPublicKey(agent))) {
2013
+ throw new Error(`El encargo #${jobId} es confidencial y ${agent} no ha publicado su clave de cifrado: no se le podría dar ` +
2014
+ 'acceso y no podría leerlo. No se ha elegido nada; elige otra oferta.');
2015
+ }
1591
2016
  const diferencia = oferta.precio > job.importe ? oferta.precio - job.importe : 0n;
1592
2017
  const nativa = job.moneda.toLowerCase() === NATIVE_CURRENCY.toLowerCase();
1593
2018
  if (diferencia > 0n) {
@@ -1596,7 +2021,81 @@ export class PanalClient {
1596
2021
  await this.aprobarAlTablon(job.moneda, diferencia);
1597
2022
  }
1598
2023
  const { hash } = await this.firmarEnTablon('Elegir la oferta', 'choose', [jobId, getAddress(agent)], nativa ? diferencia : 0n);
1599
- return { txHash: hash, escrowTaskId: (await this.getBoardJob(jobId)).escrowTaskId };
2024
+ const escrowTaskId = (await this.getBoardJob(jobId)).escrowTaskId;
2025
+ if (!confidencial)
2026
+ return { txHash: hash, escrowTaskId };
2027
+ // Elegido en la cadena: ahora, que pueda leer lo que tiene que hacer. Si
2028
+ // falla, el encargo ya es suyo; se reintenta con `grantBoardJobAccess`.
2029
+ try {
2030
+ return { txHash: hash, escrowTaskId, acceso: { ok: true, accesos: await this.grantBoardJobAccess(jobId, agent) } };
2031
+ }
2032
+ catch (err) {
2033
+ return { txHash: hash, escrowTaskId, acceso: { ok: false, error: err instanceof Error ? err.message : String(err) } };
2034
+ }
2035
+ }
2036
+ /**
2037
+ * El cliente de un encargo confidencial da acceso al encargo y a sus
2038
+ * adjuntos a quien lo tiene (o a `agent`). Se hace solo al elegir; esto es
2039
+ * para un encargo que se asignó solo o un acceso que falló. Repetirlo no
2040
+ * duplica nada. Devuelve cuántos accesos dejó.
2041
+ */
2042
+ async grantBoardJobAccess(jobId, agent) {
2043
+ const job = await this.getBoardJob(jobId);
2044
+ const para = getAddress(agent ?? job.agente);
2045
+ if (para === getAddress(NATIVE_CURRENCY))
2046
+ throw new Error(`El encargo #${jobId} no lo tiene nadie todavía.`);
2047
+ const clave = await this.getPublicKey(para);
2048
+ if (!clave)
2049
+ throw new Error(`${para} no ha publicado su clave de cifrado: no se le puede dar acceso.`);
2050
+ const { objetos, accesos } = await this.reunirDelTablon(jobId, false);
2051
+ if (objetos.length === 0)
2052
+ return 0;
2053
+ const mia = await this.encryptionKey();
2054
+ const nuevos = objetos.map((o) => concederAcceso(o, this.account.address, mia.secreta, { cuenta: para, clave }, accesos));
2055
+ await this.dejarAccesos(`${this.buzonUrl}/${this.tablon()}`, jobId, true, nuevos);
2056
+ return nuevos.length;
2057
+ }
2058
+ /**
2059
+ * Da acceso a quien decide la disputa de un encargo del tablón (los
2060
+ * firmantes de la multisig) a lo cifrado que esta cuenta puede bajarse: el
2061
+ * encargo y sus adjuntos y, si es su cliente, la entrega y sus archivos. Sin
2062
+ * esto no podrían leerlo —ni Panal puede— y decidirían a ciegas.
2063
+ * `disputeBoardJob` no lo hace solo: es otra firma, y puede faltarles la clave.
2064
+ */
2065
+ async grantBoardDisputeAccess(jobId) {
2066
+ const { objetos, accesos } = await this.reunirDelTablon(jobId, true);
2067
+ return this.grantArbitratorsAccess({ base: `${this.buzonUrl}/${this.tablon()}`, id: jobId, tablon: true, objetos, accesos });
2068
+ }
2069
+ /**
2070
+ * Todo lo cifrado de un encargo del tablón que esta cuenta puede bajarse,
2071
+ * tal como está en el buzón, y sus accesos. Con `conEntrega`, también la
2072
+ * entrega y sus archivos si es su cliente y ya la hay.
2073
+ */
2074
+ async reunirDelTablon(jobId, conEntrega) {
2075
+ const job = await this.getBoardJob(jobId);
2076
+ const esCliente = this.account?.address.toLowerCase() === job.cliente.toLowerCase();
2077
+ const objetos = [];
2078
+ const accesos = [];
2079
+ const encargo = await this.leerEncargoDelTablon(jobId);
2080
+ accesos.push(...encargo.accesos);
2081
+ if (esTextoCifrado(encargo.texto)) {
2082
+ objetos.push(encargo.texto);
2083
+ const claro = await this.decryptText(encargo.texto, accesos);
2084
+ objetos.push(...(await this.bajarDelTablon(jobId, parseAttachmentsManifest(claro), 'adjunto', true, esCliente)));
2085
+ }
2086
+ if (conEntrega && esCliente && (job.estado === EstadoTablon.Asignado || job.estado === EstadoTablon.Cerrado)) {
2087
+ const tarea = await this.getTask(job.escrowTaskId);
2088
+ if (!/^0x0+$/.test(tarea.resultHash)) {
2089
+ const entrega = await this.leerEntregaDelTablon(jobId);
2090
+ accesos.push(...entrega.accesos);
2091
+ if (esTextoCifrado(entrega.texto)) {
2092
+ objetos.push(entrega.texto);
2093
+ const claro = await this.decryptText(entrega.texto, accesos);
2094
+ objetos.push(...(await this.bajarDelTablon(jobId, parseFilesManifest(claro), 'entrega', true, true)));
2095
+ }
2096
+ }
2097
+ }
2098
+ return { objetos, accesos };
1600
2099
  }
1601
2100
  /** Asignar un encargo de selección cuyo cliente no eligió. Lo puede llamar cualquiera. */
1602
2101
  async autoAssignBoardJob(jobId) {