@panal/sdk 0.20.0 → 0.22.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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Panal — cuánto vale en dólares lo que se paga en MON o en $PANAL.
3
+ * ───────────────────────────────────────────────────────────────────────────
4
+ * POR QUÉ ESTÁ AQUÍ
5
+ * Nadie piensa en MON ni en $PANAL. Quien contrata quiere saber cuánto paga, y
6
+ * un agente si el encargo le cubre lo que le cuesta: un vídeo que cuesta $1-2
7
+ * de generar no se puede hacer por 100.000 $PANAL si eso son $0,60. Ver
8
+ * contracts/BOARD.md §11.
9
+ *
10
+ * SOLO FUERA DE LA CADENA. Los contratos no leen ningún precio y las cantidades
11
+ * on-chain siguen en MON o $PANAL: lo que se bloquea es lo que cobra el agente,
12
+ * se mueva el precio o no. Los dólares son para enseñar y para decidir.
13
+ *
14
+ * DE DÓNDE SALE CADA PRECIO
15
+ *
16
+ * MON → USD Pyth en Monad, EN LA CADENA (`getPriceUnsafe`), con su
17
+ * `publishTime`. Si no responde o está viejo, CoinGecko.
18
+ * $PANAL → MON La curva de nad.fun, EN LA CADENA (`getAmountOut` del
19
+ * mercado de $PANAL). Si no responde, la API de nad.fun.
20
+ * $PANAL → USD Lo anterior multiplicado.
21
+ *
22
+ * En la cadena primero: es el mismo RPC que ya usan la web, la app y los
23
+ * agentes, y no necesita claves. La API de nad.fun solo deja entrar a
24
+ * panal.lat, así que para la app y los agentes es un respaldo que puede fallar
25
+ * por CORS; desde Node no hay CORS.
26
+ *
27
+ * DOS PRECIOS DE $PANAL
28
+ * - El de MERCADO, el que se enseña: el punto medio entre comprar y vender
29
+ * una cantidad pequeña. Coincide con el que enseña nad.fun.
30
+ * - El de VENTA (`valorDeVenta`): lo que daría vender ESA cantidad, con la
31
+ * comisión de la curva y el impacto en el precio. Para un agente es el
32
+ * honrado, porque es lo que cobraría de verdad.
33
+ *
34
+ * NUNCA UN NÚMERO VIEJO SIN DECIRLO. Cada precio lleva cuándo se publicó, y
35
+ * `enDolares` no devuelve nada si tiene más de cinco minutos.
36
+ */
37
+ import { formatUnits, parseEther } from 'viem';
38
+ /** Pyth en Monad mainnet (contrato «pull»: alguien publica y queda guardado). */
39
+ export const PYTH_MONAD = '0x2880aB155794e7179c9eE2e38200202908C17B43';
40
+ /** El feed `Crypto.MON/USD` de Pyth. */
41
+ export const PYTH_MON_USD = '0x31491744e2dbf6df7fcf4ac0820d18a609b49076d45066d3568424e62f686cd1';
42
+ /**
43
+ * El mercado de $PANAL en la curva V2 de nad.fun. Si $PANAL se gradúa a un
44
+ * pool, `getAmountOut` deja de responder aquí y se usa la API, que sigue al
45
+ * token donde esté.
46
+ */
47
+ export const MERCADO_PANAL = '0x9f3832732923252A21044F21eE6bd87F09514ae4';
48
+ /** $PANAL en Monad mainnet. */
49
+ export const PANAL_TOKEN_MAINNET = '0x2e2e44e7fa6178822d4397299f719e89d1a67777';
50
+ /** Pasado esto, un precio no se enseña (BOARD.md §14, decisión 11). */
51
+ export const PRECIO_MAX_EDAD_S = 5 * 60;
52
+ const COINGECKO_MON = 'https://api.coingecko.com/api/v3/simple/price?ids=monad&vs_currencies=usd&include_last_updated_at=true';
53
+ const NADFUN_MERCADO = 'https://api.nad.fun/agent/market/';
54
+ const pythAbi = [
55
+ {
56
+ type: 'function',
57
+ name: 'getPriceUnsafe',
58
+ stateMutability: 'view',
59
+ inputs: [{ name: 'id', type: 'bytes32' }],
60
+ outputs: [
61
+ {
62
+ type: 'tuple',
63
+ components: [
64
+ { name: 'price', type: 'int64' },
65
+ { name: 'conf', type: 'uint64' },
66
+ { name: 'expo', type: 'int32' },
67
+ { name: 'publishTime', type: 'uint256' },
68
+ ],
69
+ },
70
+ ],
71
+ },
72
+ ];
73
+ const curvaAbi = [
74
+ {
75
+ type: 'function',
76
+ name: 'getAmountOut',
77
+ stateMutability: 'view',
78
+ inputs: [
79
+ { name: 'token', type: 'address' },
80
+ { name: 'amountIn', type: 'uint256' },
81
+ { name: 'isBuy', type: 'bool' },
82
+ ],
83
+ outputs: [{ type: 'uint256' }],
84
+ },
85
+ ];
86
+ const ahoraS = (o) => o?.ahora ?? Math.floor(Date.now() / 1000);
87
+ async function getJson(url, o) {
88
+ const f = o?.fetch ?? fetch;
89
+ const r = await f(url, { signal: AbortSignal.timeout(o?.timeoutMs ?? 6000) });
90
+ if (!r.ok)
91
+ throw new Error(`HTTP ${r.status}`);
92
+ return r.json();
93
+ }
94
+ const numero = (v) => {
95
+ const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN;
96
+ return Number.isFinite(n) && n > 0 ? n : null;
97
+ };
98
+ /** MON → USD de Pyth en la cadena. */
99
+ async function monDePyth(lector) {
100
+ try {
101
+ const p = (await lector.readContract({
102
+ address: PYTH_MONAD,
103
+ abi: pythAbi,
104
+ functionName: 'getPriceUnsafe',
105
+ args: [PYTH_MON_USD],
106
+ }));
107
+ const valor = Number(p.price) * 10 ** p.expo;
108
+ return valor > 0 ? { valor, actualizado: Number(p.publishTime), fuente: 'pyth' } : null;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ /** MON → USD de CoinGecko. */
115
+ async function monDeCoingecko(o) {
116
+ try {
117
+ const j = (await getJson(COINGECKO_MON, o));
118
+ const valor = numero(j.monad?.usd);
119
+ const actualizado = numero(j.monad?.last_updated_at);
120
+ return valor && actualizado ? { valor, actualizado, fuente: 'coingecko' } : null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * $PANAL → MON en la curva: el punto medio entre comprar con 1 MON y vender
128
+ * 1.000 $PANAL. Las dos cantidades son ínfimas frente a las reservas, así que
129
+ * el impacto no cuenta y la media geométrica quita la comisión de la curva.
130
+ */
131
+ async function panalDeCurva(lector, token, ahora) {
132
+ try {
133
+ const [comprados, monVenta] = await Promise.all([
134
+ lector.readContract({
135
+ address: MERCADO_PANAL,
136
+ abi: curvaAbi,
137
+ functionName: 'getAmountOut',
138
+ args: [token, parseEther('1'), true],
139
+ }),
140
+ lector.readContract({
141
+ address: MERCADO_PANAL,
142
+ abi: curvaAbi,
143
+ functionName: 'getAmountOut',
144
+ args: [token, parseEther('1000'), false],
145
+ }),
146
+ ]);
147
+ const compra = 1 / Number(formatUnits(comprados, 18)); // MON por $PANAL al comprar
148
+ const venta = Number(formatUnits(monVenta, 18)) / 1000; // MON por $PANAL al vender
149
+ const valor = Math.sqrt(compra * venta);
150
+ return Number.isFinite(valor) && valor > 0 ? { valor, actualizado: ahora, fuente: 'nadfun-curva' } : null;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ /** $PANAL → MON de la API de nad.fun. */
157
+ async function panalDeApi(token, ahora, o) {
158
+ try {
159
+ const j = (await getJson(`${NADFUN_MERCADO}${token}`, o));
160
+ const valor = numero(j.market_info?.price_native ?? j.price_native);
161
+ return valor ? { valor, actualizado: ahora, fuente: 'nadfun-api' } : null;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ /**
168
+ * Lee los precios de MON y de $PANAL.
169
+ *
170
+ * Nunca lanza: si una fuente cae se usa la otra, y si caen las dos ese precio
171
+ * queda en `null`. Un precio viejo SÍ se devuelve, con su fecha: quien enseña
172
+ * decide con `esFresco`, y `enDolares` ya lo hace.
173
+ */
174
+ export async function leerPrecios(lector, opciones) {
175
+ const ahora = ahoraS(opciones);
176
+ const token = opciones?.panalToken ?? PANAL_TOKEN_MAINNET;
177
+ const [pyth, curva] = await Promise.all([monDePyth(lector), panalDeCurva(lector, token, ahora)]);
178
+ // CoinGecko solo si Pyth no responde o se ha quedado viejo.
179
+ const mon = pyth && esFresco(pyth, ahora) ? pyth : masReciente(pyth, await monDeCoingecko(opciones));
180
+ const panalEnMon = curva ?? (await panalDeApi(token, ahora, opciones));
181
+ const panal = mon && panalEnMon
182
+ ? {
183
+ valor: mon.valor * panalEnMon.valor,
184
+ actualizado: Math.min(mon.actualizado, panalEnMon.actualizado),
185
+ fuente: panalEnMon.fuente,
186
+ }
187
+ : null;
188
+ return { mon, panalEnMon, panal };
189
+ }
190
+ function masReciente(a, b) {
191
+ if (!a)
192
+ return b;
193
+ if (!b)
194
+ return a;
195
+ return b.actualizado > a.actualizado ? b : a;
196
+ }
197
+ /** Si un precio se puede enseñar: existe y tiene menos de cinco minutos. */
198
+ export function esFresco(p, ahora, maxEdadS = PRECIO_MAX_EDAD_S) {
199
+ if (!p)
200
+ return false;
201
+ return (ahora ?? Math.floor(Date.now() / 1000)) - p.actualizado <= maxEdadS;
202
+ }
203
+ /**
204
+ * Cuántos dólares son `cantidad` unidades mínimas de `moneda` (18 decimales),
205
+ * con el precio de mercado. `null` si no hay precio o tiene más de 5 minutos.
206
+ */
207
+ export function enDolares(cantidad, moneda, precios, ahora) {
208
+ const p = moneda === 'MON' ? precios?.mon : precios?.panal;
209
+ if (!esFresco(p, ahora))
210
+ return null;
211
+ return { usd: Number(formatUnits(cantidad, 18)) * p.valor, actualizado: p.actualizado, fuente: p.fuente };
212
+ }
213
+ /**
214
+ * Lo que daría vender `cantidad` $PANAL ahora en la curva, en MON (wei): con
215
+ * la comisión y el impacto en el precio. Es lo que de verdad cobraría quien
216
+ * los vendiera. `null` si la curva no responde.
217
+ */
218
+ export async function valorDeVenta(lector, cantidad, panalToken = PANAL_TOKEN_MAINNET) {
219
+ if (cantidad <= 0n)
220
+ return 0n;
221
+ try {
222
+ return (await lector.readContract({
223
+ address: MERCADO_PANAL,
224
+ abi: curvaAbi,
225
+ functionName: 'getAmountOut',
226
+ args: [panalToken, cantidad, false],
227
+ }));
228
+ }
229
+ catch {
230
+ return null;
231
+ }
232
+ }
233
+ /**
234
+ * «$0.075», «$0.0006», «$1.50». Por debajo de un dólar, dos cifras
235
+ * significativas: con decimales fijos, 100 $PANAL saldrían «$0.00».
236
+ */
237
+ export function formatearDolares(usd, locale = 'en-US') {
238
+ if (!Number.isFinite(usd) || usd < 0)
239
+ return '';
240
+ if (usd > 0 && usd < 0.000001) {
241
+ return `< ${new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD', maximumFractionDigits: 6 }).format(0.000001)}`;
242
+ }
243
+ const opciones = usd >= 1
244
+ ? { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }
245
+ : { style: 'currency', currency: 'USD', maximumSignificantDigits: 2 };
246
+ return new Intl.NumberFormat(locale, opciones).format(usd);
247
+ }