panal-mcp 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,7 +37,7 @@ Reinicia el cliente y pregunta *«¿qué agentes hay en Panal?»*.
37
37
  | `panal_get_agent` | Ficha completa: precio, skills, estado y nombre único leído de la cadena |
38
38
  | `panal_get_task` | Estado de un encargo en el escrow |
39
39
  | `panal_marketplace_stats` | Cifras del marketplace |
40
- | `panal_wallet` | Saldo y presupuesto restante *(escritura)* |
40
+ | `panal_wallet` | Saldo en cada moneda (MON y $PANAL), presupuesto restante y lo que el escrow te debe *(escritura)* |
41
41
  | `panal_quote_hire` | Presupuesta un encargo sin pagar, y comprueba que el agente responde *(escritura)* |
42
42
  | `panal_hire` | Contrata y bloquea el pago *(escritura)* |
43
43
  | `panal_send_brief` | Reenvía el encargo si no llegó al contratar *(escritura)* |
@@ -46,7 +46,7 @@ Reinicia el cliente y pregunta *«¿qué agentes hay en Panal?»*.
46
46
  | `panal_quote_ask` / `panal_ask` | Pregunta suelta a un agente, sin abrir un encargo *(escritura)* |
47
47
  | `panal_cancel_task` | Recupera tu dinero si nadie empezó y venció el plazo *(escritura)* |
48
48
  | `panal_open_dispute` | Abre disputa si lo entregado no vale *(escritura)* |
49
- | `panal_withdraw` | Retira lo que el escrow te debe *(escritura)* |
49
+ | `panal_withdraw` | Retira lo que el escrow te debe, en todas las monedas *(escritura)* |
50
50
 
51
51
  Las cuatro últimas existen por lo mismo: un encargo puede torcerse, y hasta que las
52
52
  hubo la única salida era esperar. El escrow es de **pago tirado** (*pull*), así que lo
@@ -66,7 +66,9 @@ Las herramientas de escritura mueven dinero real en mainnet, así que están **a
66
66
  "MCP_ENABLE_WRITES": "true",
67
67
  "MCP_PRIVATE_KEY": "0x…",
68
68
  "MCP_MAX_PER_TASK_WEI": "1000000000000000000",
69
- "MCP_DAILY_BUDGET_WEI": "5000000000000000000"
69
+ "MCP_DAILY_BUDGET_WEI": "5000000000000000000",
70
+ "MCP_MAX_PER_TASK_PANAL_WEI": "3000000000000000000",
71
+ "MCP_DAILY_BUDGET_PANAL_WEI": "10000000000000000000"
70
72
  }
71
73
  }
72
74
  }
@@ -79,8 +81,17 @@ Las herramientas de escritura mueven dinero real en mainnet, así que están **a
79
81
  |---|---|---|
80
82
  | `MCP_ENABLE_WRITES` | `false` | Interruptor general |
81
83
  | `MCP_PRIVATE_KEY` | — | Wallet del cliente que paga |
82
- | `MCP_MAX_PER_TASK_WEI` | `1e18` (1 MON) | Tope por encargo |
83
- | `MCP_DAILY_BUDGET_WEI` | `5e18` (5 MON) | Tope por día UTC, persistido en disco |
84
+ | `MCP_MAX_PER_TASK_WEI` | `1e18` (1 MON) | Tope por encargo, **en MON** |
85
+ | `MCP_DAILY_BUDGET_WEI` | `5e18` (5 MON) | Tope por día UTC **en MON**, persistido en disco |
86
+ | `MCP_MAX_PER_TASK_PANAL_WEI` | `1e18` (1 $PANAL) | Tope por encargo, **en $PANAL** |
87
+ | `MCP_DAILY_BUDGET_PANAL_WEI` | `5e18` (5 $PANAL) | Tope por día UTC **en $PANAL** |
88
+
89
+ **Cada moneda lleva su cuenta.** Panal cobra en MON nativo y en $PANAL, que no valen lo
90
+ mismo y no tienen tipo de cambio entre sí: sumarlos en un solo número sería inventarse
91
+ la conversión. Con un contador único, tres consultas pagadas en $PANAL agotaban un
92
+ presupuesto puesto pensando en MON y bloqueaban una contratación que iba sobrada. Si un
93
+ agente cobra en un token que no es ninguno de los dos, el servidor **se niega** en vez de
94
+ tirar del presupuesto de otra moneda.
84
95
  | `MCP_TASK_DEADLINE_HOURS` | `24` | Plazo de entrega |
85
96
  | `MCP_SPEND_FILE` | `.panal-mcp/spend.json` | Dónde se guarda el gasto del día |
86
97
  | `RPC_URL` | RPC público de Monad | Tu propio RPC |
package/dist/limits.d.ts CHANGED
@@ -52,11 +52,20 @@ export declare class QuoteBook {
52
52
  private prune;
53
53
  }
54
54
  /**
55
- * Registro de gasto diario, persistido en disco.
55
+ * Registro de gasto diario, persistido en disco y SEPARADO POR MONEDA.
56
56
  *
57
57
  * Un tope que se borra al reiniciar no es un tope: bastaría con reiniciar el
58
58
  * servidor para volver a gastar el presupuesto entero. Se guarda el día UTC
59
- * junto al importe, así que el contador se reinicia solo al cambiar de día.
59
+ * junto a los importes, así que el contador se reinicia solo al cambiar de día.
60
+ *
61
+ * POR QUÉ POR MONEDA
62
+ *
63
+ * Antes había un solo número. Panal cobra en dos monedas —MON nativo y $PANAL—
64
+ * y no hay tipo de cambio entre ellas, así que sumarlas era inventárselo: tres
65
+ * consultas de $PANAL agotaban un presupuesto puesto pensando en MON, y al
66
+ * revés, un tope generoso en $PANAL abría la mano con el MON sin que nadie lo
67
+ * hubiera decidido. La cuenta protegía de más, que es el lado bueno del fallo,
68
+ * pero un tope que no significa lo que dice no se puede ajustar.
60
69
  */
61
70
  export declare class SpendLedger {
62
71
  private readonly file;
@@ -64,12 +73,31 @@ export declare class SpendLedger {
64
73
  constructor(file: string, onWarn: (msg: string) => void);
65
74
  static defaultFile(): string;
66
75
  private static utcDay;
67
- spentToday(): bigint;
68
- record(wei: bigint): void;
76
+ /** Todo lo gastado hoy, por moneda. Vacío si el archivo es de otro día. */
77
+ private leer;
78
+ spentToday(currency: Address): bigint;
79
+ record(currency: Address, wei: bigint): void;
69
80
  }
70
- export interface Limits {
81
+ /** El tope de una moneda concreta. */
82
+ export interface CurrencyLimit {
71
83
  maxPerTaskWei: bigint;
72
84
  dailyBudgetWei: bigint;
85
+ /** El nombre de la variable de entorno, para poder decirle a alguien cuál subir. */
86
+ envMaxPerTask: string;
87
+ envDailyBudget: string;
88
+ }
89
+ export interface Limits {
73
90
  deadlineHours: number;
91
+ /** Topes por moneda, indexados por dirección en minúsculas. */
92
+ porMoneda: Map<string, CurrencyLimit>;
74
93
  }
94
+ /**
95
+ * El tope de una moneda, o `null` si no hay ninguno puesto para ella.
96
+ *
97
+ * `null` significa NO HAY PRESUPUESTO, y quien llame debe negarse. Es
98
+ * deliberado: la alternativa sería reutilizar el tope de otra moneda, y eso
99
+ * exige un tipo de cambio que nadie tiene. Un agente que cobre en un token
100
+ * desconocido no se contrata hasta que alguien decida cuánto vale.
101
+ */
102
+ export declare function limitFor(limits: Limits, currency: Address): CurrencyLimit | null;
75
103
  export declare function limitsFromEnv(): Limits;
package/dist/limits.js CHANGED
@@ -57,12 +57,25 @@ export class QuoteBook {
57
57
  this.quotes.delete(id);
58
58
  }
59
59
  }
60
+ /** Clave de un contador: la dirección de la moneda, en minúsculas. */
61
+ function clave(currency) {
62
+ return currency.toLowerCase();
63
+ }
60
64
  /**
61
- * Registro de gasto diario, persistido en disco.
65
+ * Registro de gasto diario, persistido en disco y SEPARADO POR MONEDA.
62
66
  *
63
67
  * Un tope que se borra al reiniciar no es un tope: bastaría con reiniciar el
64
68
  * servidor para volver a gastar el presupuesto entero. Se guarda el día UTC
65
- * junto al importe, así que el contador se reinicia solo al cambiar de día.
69
+ * junto a los importes, así que el contador se reinicia solo al cambiar de día.
70
+ *
71
+ * POR QUÉ POR MONEDA
72
+ *
73
+ * Antes había un solo número. Panal cobra en dos monedas —MON nativo y $PANAL—
74
+ * y no hay tipo de cambio entre ellas, así que sumarlas era inventárselo: tres
75
+ * consultas de $PANAL agotaban un presupuesto puesto pensando en MON, y al
76
+ * revés, un tope generoso en $PANAL abría la mano con el MON sin que nadie lo
77
+ * hubiera decidido. La cuenta protegía de más, que es el lado bueno del fallo,
78
+ * pero un tope que no significa lo que dice no se puede ajustar.
66
79
  */
67
80
  export class SpendLedger {
68
81
  file;
@@ -77,27 +90,55 @@ export class SpendLedger {
77
90
  static utcDay() {
78
91
  return new Date().toISOString().slice(0, 10);
79
92
  }
80
- spentToday() {
93
+ /** Todo lo gastado hoy, por moneda. Vacío si el archivo es de otro día. */
94
+ leer() {
95
+ const out = new Map();
81
96
  try {
82
97
  const raw = JSON.parse(readFileSync(this.file, 'utf8'));
83
98
  if (raw.day !== SpendLedger.utcDay())
84
- return 0n;
85
- return BigInt(raw.spentWei ?? '0');
99
+ return out;
100
+ // Formato antiguo: un solo número, sin moneda. Era el gasto de todo, pero
101
+ // en la práctica el que llevaba la cuenta era el nativo, así que se
102
+ // adopta ahí. Perderlo dejaría el tope del día a cero tras actualizar.
103
+ if (typeof raw.spentWei === 'string' && !raw.spent) {
104
+ try {
105
+ out.set(clave(NATIVO), BigInt(raw.spentWei));
106
+ }
107
+ catch {
108
+ /* ilegible: se ignora */
109
+ }
110
+ return out;
111
+ }
112
+ for (const [k, v] of Object.entries(raw.spent ?? {})) {
113
+ try {
114
+ out.set(k.toLowerCase(), BigInt(v));
115
+ }
116
+ catch {
117
+ /* una entrada corrupta no invalida las demás */
118
+ }
119
+ }
86
120
  }
87
121
  catch {
88
122
  // Sin archivo, ilegible o corrupto: se empieza de cero. Nunca lanza,
89
123
  // porque un ledger roto no debe dejar el servidor inservible.
90
- return 0n;
91
124
  }
125
+ return out;
92
126
  }
93
- record(wei) {
94
- const total = this.spentToday() + wei;
127
+ spentToday(currency) {
128
+ return this.leer().get(clave(currency)) ?? 0n;
129
+ }
130
+ record(currency, wei) {
131
+ const todo = this.leer();
132
+ todo.set(clave(currency), (todo.get(clave(currency)) ?? 0n) + wei);
133
+ const spent = {};
134
+ for (const [k, v] of todo)
135
+ spent[k] = v.toString();
95
136
  try {
96
137
  mkdirSync(dirname(this.file), { recursive: true });
97
138
  // Escritura atómica: un corte a media escritura dejaría un JSON truncado
98
139
  // que se leería como 0 y borraría el tope del día.
99
140
  const tmp = `${this.file}.tmp`;
100
- writeFileSync(tmp, JSON.stringify({ day: SpendLedger.utcDay(), spentWei: total.toString() }, null, 2));
141
+ writeFileSync(tmp, JSON.stringify({ day: SpendLedger.utcDay(), spent }, null, 2));
101
142
  renameSync(tmp, this.file);
102
143
  }
103
144
  catch (err) {
@@ -106,6 +147,21 @@ export class SpendLedger {
106
147
  }
107
148
  }
108
149
  }
150
+ /** La moneda nativa en el escrow de Panal: la dirección cero. */
151
+ const NATIVO = '0x0000000000000000000000000000000000000000';
152
+ /** $PANAL en Monad mainnet. */
153
+ const PANAL = '0x2e2e44e7fa6178822d4397299f719e89d1a67777';
154
+ /**
155
+ * El tope de una moneda, o `null` si no hay ninguno puesto para ella.
156
+ *
157
+ * `null` significa NO HAY PRESUPUESTO, y quien llame debe negarse. Es
158
+ * deliberado: la alternativa sería reutilizar el tope de otra moneda, y eso
159
+ * exige un tipo de cambio que nadie tiene. Un agente que cobre en un token
160
+ * desconocido no se contrata hasta que alguien decida cuánto vale.
161
+ */
162
+ export function limitFor(limits, currency) {
163
+ return limits.porMoneda.get(clave(currency)) ?? null;
164
+ }
109
165
  export function limitsFromEnv() {
110
166
  const parse = (name, fallback) => {
111
167
  const raw = process.env[name]?.trim();
@@ -120,9 +176,22 @@ export function limitsFromEnv() {
120
176
  }
121
177
  };
122
178
  const hours = Number(process.env.MCP_TASK_DEADLINE_HOURS?.trim() || '24');
123
- return {
124
- maxPerTaskWei: parse('MCP_MAX_PER_TASK_WEI', 10n ** 18n), // 1 MON / $PANAL
179
+ const porMoneda = new Map();
180
+ // MON conserva los nombres de siempre: quien ya tenía un .env sigue
181
+ // significando exactamente lo que creía.
182
+ porMoneda.set(clave(NATIVO), {
183
+ maxPerTaskWei: parse('MCP_MAX_PER_TASK_WEI', 10n ** 18n),
125
184
  dailyBudgetWei: parse('MCP_DAILY_BUDGET_WEI', 5n * 10n ** 18n),
126
- deadlineHours: Number.isFinite(hours) && hours > 0 ? hours : 24,
127
- };
185
+ envMaxPerTask: 'MCP_MAX_PER_TASK_WEI',
186
+ envDailyBudget: 'MCP_DAILY_BUDGET_WEI',
187
+ });
188
+ // $PANAL estrena las suyas, con los mismos números de antes. No se copian de
189
+ // MON porque no valen lo mismo: son dos presupuestos que se ajustan aparte.
190
+ porMoneda.set(clave(PANAL), {
191
+ maxPerTaskWei: parse('MCP_MAX_PER_TASK_PANAL_WEI', 10n ** 18n),
192
+ dailyBudgetWei: parse('MCP_DAILY_BUDGET_PANAL_WEI', 5n * 10n ** 18n),
193
+ envMaxPerTask: 'MCP_MAX_PER_TASK_PANAL_WEI',
194
+ envDailyBudget: 'MCP_DAILY_BUDGET_PANAL_WEI',
195
+ });
196
+ return { deadlineHours: Number.isFinite(hours) && hours > 0 ? hours : 24, porMoneda };
128
197
  }
package/dist/server.d.ts CHANGED
@@ -13,8 +13,14 @@
13
13
  * MCP_PRIVATE_KEY=0x… wallet dedicada del CLIENTE, con fondos
14
14
  *
15
15
  * Topes (todos opcionales, con valores conservadores por defecto):
16
- * MCP_MAX_PER_TASK_WEI por encargo (default 1e18 = 1 MON/$PANAL)
17
- * MCP_DAILY_BUDGET_WEI por día UTC (default 5e18)
16
+ * MCP_MAX_PER_TASK_WEI por encargo, en MON (default 1e18)
17
+ * MCP_DAILY_BUDGET_WEI por día UTC, en MON (default 5e18)
18
+ * MCP_MAX_PER_TASK_PANAL_WEI por encargo, en $PANAL (default 1e18)
19
+ * MCP_DAILY_BUDGET_PANAL_WEI por día UTC, en $PANAL (default 5e18)
20
+ *
21
+ * Cada moneda lleva su cuenta. MON y $PANAL no valen lo mismo y no hay tipo de
22
+ * cambio entre ellos, así que un solo contador para los dos era una suma
23
+ * inventada: gastar en una agotaba el presupuesto de la otra.
18
24
  * MCP_TASK_DEADLINE_HOURS plazo de entrega (default 24)
19
25
  * MCP_SPEND_FILE dónde persistir el gasto del día
20
26
  *
package/dist/server.js CHANGED
@@ -13,8 +13,14 @@
13
13
  * MCP_PRIVATE_KEY=0x… wallet dedicada del CLIENTE, con fondos
14
14
  *
15
15
  * Topes (todos opcionales, con valores conservadores por defecto):
16
- * MCP_MAX_PER_TASK_WEI por encargo (default 1e18 = 1 MON/$PANAL)
17
- * MCP_DAILY_BUDGET_WEI por día UTC (default 5e18)
16
+ * MCP_MAX_PER_TASK_WEI por encargo, en MON (default 1e18)
17
+ * MCP_DAILY_BUDGET_WEI por día UTC, en MON (default 5e18)
18
+ * MCP_MAX_PER_TASK_PANAL_WEI por encargo, en $PANAL (default 1e18)
19
+ * MCP_DAILY_BUDGET_PANAL_WEI por día UTC, en $PANAL (default 5e18)
20
+ *
21
+ * Cada moneda lleva su cuenta. MON y $PANAL no valen lo mismo y no hay tipo de
22
+ * cambio entre ellos, así que un solo contador para los dos era una suma
23
+ * inventada: gastar en una agotaba el presupuesto de la otra.
18
24
  * MCP_TASK_DEADLINE_HOURS plazo de entrega (default 24)
19
25
  * MCP_SPEND_FILE dónde persistir el gasto del día
20
26
  *
@@ -28,8 +34,8 @@ import { createInterface } from 'node:readline';
28
34
  import { fileURLToPath } from 'node:url';
29
35
  import { formatEther, isAddress, keccak256, parseEther, toBytes } from 'viem';
30
36
  import { privateKeyToAccount } from 'viem/accounts';
31
- import { NATIVE_CURRENCY, TaskStatus, createPanalClient, } from '@panal/sdk';
32
- import { QuoteBook, SpendLedger, limitsFromEnv } from './limits.js';
37
+ import { NATIVE_CURRENCY, TaskStatus, createPanalClient, erc20Abi, } from '@panal/sdk';
38
+ import { QuoteBook, SpendLedger, limitFor, limitsFromEnv } from './limits.js';
33
39
  import { briefSignMessage, expiraEn, fetchAgentLimits, fetchResultText, pushBrief, resultSignMessage, } from './fetch-result.js';
34
40
  const SERVER_NAME = 'panal';
35
41
  /**
@@ -88,8 +94,67 @@ function writesBlockedReason() {
88
94
  // ---------------------------------------------------------------------------
89
95
  // Presentación
90
96
  // ---------------------------------------------------------------------------
97
+ /** ¿Es la moneda nativa de la cadena, o un token? */
98
+ function esNativa(currency) {
99
+ return currency.toLowerCase() === NATIVE_CURRENCY.toLowerCase();
100
+ }
91
101
  function symbolOf(currency) {
92
- return currency.toLowerCase() === NATIVE_CURRENCY.toLowerCase() ? 'MON' : '$PANAL';
102
+ return esNativa(currency) ? 'MON' : '$PANAL';
103
+ }
104
+ /** Las monedas que este servidor sabe manejar: las que tienen presupuesto. */
105
+ function monedasConocidas() {
106
+ return [...limits.porMoneda.keys()];
107
+ }
108
+ /**
109
+ * Saldo de una wallet en una moneda: a la cadena si es la nativa, al contrato
110
+ * del token si es un ERC-20.
111
+ *
112
+ * `null` es «no se pudo leer», y se distingue de cero a propósito. Enseñar 0
113
+ * cuando la consulta falla es afirmar que no hay fondos, que es una respuesta
114
+ * distinta: llevaría a no intentar un pago que sí se podía hacer.
115
+ */
116
+ async function saldoDe(owner, currency) {
117
+ try {
118
+ if (esNativa(currency))
119
+ return await panal.publicClient.getBalance({ address: owner });
120
+ return (await panal.publicClient.readContract({
121
+ address: currency,
122
+ abi: erc20Abi,
123
+ functionName: 'balanceOf',
124
+ args: [owner],
125
+ }));
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ /**
132
+ * ¿Cabe este gasto en el presupuesto de SU moneda? Devuelve el motivo, o null.
133
+ *
134
+ * Cada moneda lleva su cuenta. Sumar MON y $PANAL en un solo número exigiría un
135
+ * tipo de cambio que nadie tiene, y el resultado era que tres consultas de
136
+ * $PANAL agotaban un tope puesto pensando en MON.
137
+ *
138
+ * Sin presupuesto para una moneda, se dice que no. La alternativa —tirar del
139
+ * tope de otra— es exactamente la conversión a ojo que este cambio elimina.
140
+ */
141
+ function revisarPresupuesto(currency, amount, symbol) {
142
+ const lim = limitFor(limits, currency);
143
+ if (!lim) {
144
+ return (`That agent charges in a token this server has no budget for (${currency}). ` +
145
+ 'Only MON and $PANAL have caps configured; there is no exchange rate to reuse another one.');
146
+ }
147
+ if (amount > lim.maxPerTaskWei) {
148
+ return (`That costs ${formatEther(amount)} ${symbol} and the per-item cap for ${symbol} is ` +
149
+ `${formatEther(lim.maxPerTaskWei)}. Raise ${lim.envMaxPerTask} if that is intended.`);
150
+ }
151
+ const spent = ledger.spentToday(currency);
152
+ if (spent + amount > lim.dailyBudgetWei) {
153
+ return (`That would blow today's ${symbol} budget: ${formatEther(spent)} of ` +
154
+ `${formatEther(lim.dailyBudgetWei)} spent and this costs ${formatEther(amount)} ${symbol}. ` +
155
+ `Raise ${lim.envDailyBudget} if that is intended.`);
156
+ }
157
+ return null;
93
158
  }
94
159
  /** Días desde un timestamp en segundos. */
95
160
  function daysSince(ts) {
@@ -336,28 +401,54 @@ const READ_TOOLS = [
336
401
  const WRITE_TOOLS = [
337
402
  {
338
403
  name: 'panal_wallet',
339
- description: 'State of the wallet this server would pay with: address, balance, and how much of today\'s budget ' +
340
- 'is left. Call it before hiring to know whether there are funds.',
404
+ description: 'State of the wallet this server would pay with: address, the balance of EVERY currency Panal ' +
405
+ 'takes (MON and $PANAL), and how much of today\'s budget is left. Call it before hiring to know ' +
406
+ 'whether there are funds.',
341
407
  inputSchema: { type: 'object', properties: {} },
342
408
  handler: async () => {
343
409
  const blocked = writesBlockedReason();
344
410
  if (blocked)
345
411
  return blocked;
346
412
  const address = account.address;
347
- const [balance, spent] = [await panal.publicClient.getBalance({ address }), ledger.spentToday()];
348
- const left = limits.dailyBudgetWei > spent ? limits.dailyBudgetWei - spent : 0n;
349
- // Lo acreditado en el escrow no aparece en el balance de la wallet: es
350
- // pull payment, así que un reembolso se queda ahí quieto hasta que se
351
- // reclama. Sin decirlo, el dinero devuelto es indistinguible del perdido.
352
- const pending = await panal.getPendingWithdrawal(address).catch(() => 0n);
413
+ const monedas = monedasConocidas();
414
+ // Un saldo por moneda, no solo el nativo. Antes se leía únicamente MON y
415
+ // justo debajo se listaban los topes de $PANAL: quien lo leía daba por
416
+ // comprobado un saldo que nadie había mirado, y podía contratar en $PANAL
417
+ // sin tener con qué pagar. Las lecturas son independientes, así que van
418
+ // en paralelo.
419
+ const [saldos, pendientes] = await Promise.all([
420
+ Promise.all(monedas.map((m) => saldoDe(address, m))),
421
+ // Lo acreditado en el escrow no aparece en el balance de la wallet: es
422
+ // pull payment, así que un reembolso se queda ahí quieto hasta que se
423
+ // reclama. Sin decirlo, el dinero devuelto es indistinguible del
424
+ // perdido — y también hay que preguntarlo moneda a moneda.
425
+ Promise.all(monedas.map((m) => panal.getPendingWithdrawal(address, m).catch(() => null))),
426
+ ]);
427
+ const importe = (v) => (v === null ? 'could not be read' : formatEther(v));
428
+ const balances = monedas.map((m, i) => ` ${symbolOf(m)}: ${importe(saldos[i] ?? null)}`);
429
+ // Un presupuesto por moneda: MON y $PANAL no valen lo mismo y no hay
430
+ // tipo de cambio, así que se enseñan por separado o no se entienden.
431
+ const presupuestos = monedas.map((moneda) => {
432
+ const lim = limitFor(limits, moneda);
433
+ const spent = ledger.spentToday(moneda);
434
+ const left = lim.dailyBudgetWei > spent ? lim.dailyBudgetWei - spent : 0n;
435
+ return (` ${symbolOf(moneda)}: per job ${formatEther(lim.maxPerTaskWei)} · today ${formatEther(spent)} of ` +
436
+ `${formatEther(lim.dailyBudgetWei)} spent · ${formatEther(left)} left`);
437
+ });
438
+ // Solo se nombra lo que hay que cobrar, o lo que no se pudo comprobar. Un
439
+ // «0» por cada moneda sería ruido en la respuesta más leída del servidor.
440
+ const enEscrow = monedas
441
+ .map((m, i) => ({ sym: symbolOf(m), v: pendientes[i] ?? null }))
442
+ .filter(({ v }) => v === null || v > 0n)
443
+ .map(({ sym, v }) => ` ${sym}: ${importe(v)}`);
353
444
  return [
354
445
  `Wallet: ${address}`,
355
- `Balance: ${formatEther(balance)} MON`,
356
- `Per-job cap: ${formatEther(limits.maxPerTaskWei)}`,
357
- `Today's budget: spent ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} · ${formatEther(left)} left`,
358
- pending > 0n
359
- ? `Waiting in the escrow: ${formatEther(pending)} MON — collect it with panal_withdraw`
360
- : null,
446
+ 'Balance:',
447
+ ...balances,
448
+ 'Caps and budgets (each currency is counted on its own):',
449
+ ...presupuestos,
450
+ enEscrow.length ? 'Waiting in the escrow — collect it with panal_withdraw:' : null,
451
+ ...enEscrow,
361
452
  ]
362
453
  .filter(Boolean)
363
454
  .join('\n');
@@ -390,15 +481,9 @@ const WRITE_TOOLS = [
390
481
  return `${agent.metadata.name || address} is delisted and does not take jobs.`;
391
482
  const amount = agent.pricePerTask;
392
483
  const symbol = symbolOf(agent.currency);
393
- if (amount > limits.maxPerTaskWei) {
394
- return (`That agent charges ${formatEther(amount)} ${symbol} and this server per-job cap is ` +
395
- `${formatEther(limits.maxPerTaskWei)}. It cannot be hired unless the person raises MCP_MAX_PER_TASK_WEI.`);
396
- }
397
- const spent = ledger.spentToday();
398
- if (spent + amount > limits.dailyBudgetWei) {
399
- return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
400
- `and this costs ${formatEther(amount)} ${symbol}.`);
401
- }
484
+ const negativa = revisarPresupuesto(agent.currency, amount, symbol);
485
+ if (negativa)
486
+ return negativa;
402
487
  // El tope del brief se pregunta ANTES de presupuestar. Si no cabe, el
403
488
  // agente lo rechazaría con un 400 al recibirlo — pero eso ocurre ya con
404
489
  // el pago bloqueado, porque el encargo se entrega después de crear la
@@ -476,22 +561,17 @@ const WRITE_TOOLS = [
476
561
  const quote = redeemed.quote;
477
562
  // Los mismos topes que contratar. Una consulta es barata, pero nada
478
563
  // impide encadenar mil: el presupuesto del día es lo que lo impide.
479
- if (quote.amount > limits.maxPerTaskWei) {
480
- return (`That question costs ${formatEther(quote.amount)} ${quote.symbol} and this server per-item cap is ` +
481
- `${formatEther(limits.maxPerTaskWei)}. Raise MCP_MAX_PER_TASK_WEI if that is intended.`);
482
- }
483
- const spent = ledger.spentToday();
484
- if (spent + quote.amount > limits.dailyBudgetWei) {
485
- return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
486
- `and this costs ${formatEther(quote.amount)} ${quote.symbol}.`);
487
- }
564
+ const negativa = revisarPresupuesto(quote.currency, quote.amount, quote.symbol);
565
+ if (negativa)
566
+ return negativa;
488
567
  try {
489
568
  // `maxSpend` va atado al importe del presupuesto que la persona
490
569
  // aprobó, no al tope del servidor: si el agente sube el precio entre
491
570
  // el presupuesto y el sí, la firma no se produce.
492
571
  const res = await panal.askAgent(quote.worker, quote.brief, { maxSpend: quote.amount });
493
- // Se registra lo REALMENTE pagado, que es lo que devuelve el SDK.
494
- ledger.record(res.paid);
572
+ // Se registra lo REALMENTE pagado, y en SU moneda: un gasto anotado en
573
+ // el contador equivocado agota un presupuesto que nadie tocó.
574
+ ledger.record(quote.currency, res.paid);
495
575
  log(`consulta pagada a ${quote.worker}: ${formatEther(res.paid)} ${quote.symbol}`);
496
576
  return [
497
577
  `Paid ${formatEther(res.paid)} ${quote.symbol} to ${quote.agentName}.`,
@@ -541,14 +621,13 @@ const WRITE_TOOLS = [
541
621
  const quote = redeemed.quote;
542
622
  // Los topes se re-evalúan al contratar: entre el presupuesto y el "sí"
543
623
  // pueden haber pasado minutos y otros encargos.
544
- const spent = ledger.spentToday();
545
- if (spent + quote.amount > limits.dailyBudgetWei) {
546
- return `Today's budget ran out in the meantime (${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)}).`;
547
- }
624
+ const negativa = revisarPresupuesto(quote.currency, quote.amount, quote.symbol);
625
+ if (negativa)
626
+ return negativa;
548
627
  const deadline = BigInt(Math.floor(Date.now() / 1000) + limits.deadlineHours * 3600);
549
628
  try {
550
629
  const result = await panal.hire({ agent: quote.worker, brief: quote.brief, deadline });
551
- ledger.record(result.amount);
630
+ ledger.record(quote.currency, result.amount);
552
631
  log(`contratada #${result.taskId} a ${quote.worker} por ${formatEther(result.amount)} ${quote.symbol}`);
553
632
  // Contratar sin entregar el encargo deja la tarea a medias: el pago
554
633
  // bloqueado y el agente esperando un texto que nadie le manda. Se hace
@@ -808,9 +887,9 @@ const WRITE_TOOLS = [
808
887
  },
809
888
  {
810
889
  name: 'panal_withdraw',
811
- description: 'Collect what the escrow owes this wallet — refunds from cancelled jobs. The escrow is pull ' +
812
- 'payment: it credits balances and never pushes them, so money sits there until this is called. ' +
813
- 'Call panal_wallet first to see whether there is anything to collect.',
890
+ description: 'Collect what the escrow owes this wallet — refunds from cancelled jobs — in EVERY currency it ' +
891
+ 'holds, one transaction each. The escrow is pull payment: it credits balances and never pushes ' +
892
+ 'them, so money sits there until this is called. Call panal_wallet first to see what is waiting.',
814
893
  inputSchema: {
815
894
  type: 'object',
816
895
  properties: {
@@ -826,11 +905,36 @@ const WRITE_TOOLS = [
826
905
  return 'I will not move funds without confirmed_by_user: true.';
827
906
  }
828
907
  try {
829
- const pending = await panal.getPendingWithdrawal(account.address);
830
- if (pending === 0n)
908
+ // Se retira MONEDA A MONEDA. El escrow lleva un saldo por cada una y
909
+ // `withdraw` solo vacía la que se le pide; preguntando únicamente por
910
+ // la nativa, un reembolso en $PANAL quedaba invisible y esta misma
911
+ // herramienta contestaba que no había nada que cobrar. El dinero seguía
912
+ // en el contrato, pero desde aquí no había forma de sacarlo.
913
+ const monedas = monedasConocidas();
914
+ const pendientes = [];
915
+ for (const moneda of monedas) {
916
+ const importe = await panal.getPendingWithdrawal(account.address, moneda);
917
+ if (importe > 0n)
918
+ pendientes.push({ moneda, importe });
919
+ }
920
+ if (!pendientes.length)
831
921
  return 'The escrow owes this wallet nothing: there is nothing to collect.';
832
- const hash = await panal.withdraw();
833
- return `Collected ${formatEther(pending)} MON from the escrow.\n tx: ${EXPLORER}/tx/${hash}`;
922
+ // Cada moneda es una transacción aparte, y si una falla se sigue con
923
+ // las demás: quedarse sin cobrar el $PANAL porque el MON se quedó sin
924
+ // gas es perder dos veces.
925
+ const lineas = [];
926
+ for (const { moneda, importe } of pendientes) {
927
+ const sym = symbolOf(moneda);
928
+ try {
929
+ const hash = await panal.withdraw(moneda);
930
+ lineas.push(`Collected ${formatEther(importe)} ${sym} from the escrow.\n tx: ${EXPLORER}/tx/${hash}`);
931
+ }
932
+ catch (err) {
933
+ lineas.push(`Could not collect the ${formatEther(importe)} ${sym}: ${err instanceof Error ? err.message : err}` +
934
+ '\n It is still credited in the escrow; calling this again will retry it.');
935
+ }
936
+ }
937
+ return lineas.join('\n');
834
938
  }
835
939
  catch (err) {
836
940
  return `Could not withdraw: ${err instanceof Error ? err.message : err}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panal-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Servidor MCP de Panal: busca y contrata agentes de IA on-chain desde Claude",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "build": "tsc -p tsconfig.json",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
43
  "start": "tsx src/server.ts",
44
- "test": "tsx test/mcp.test.ts",
44
+ "test": "tsx test/mcp.test.ts && tsx test/limites.test.ts && tsx test/monedas.test.ts",
45
45
  "test:limites": "tsx test/limites.test.ts"
46
46
  }
47
47
  }