@empereur-rouge/pms-sdk 0.1.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/index.js ADDED
@@ -0,0 +1,1028 @@
1
+ // src/wallet.ts
2
+ import { secp256k1 } from "@noble/curves/secp256k1";
3
+ import { x25519 } from "@noble/curves/ed25519";
4
+ import { sha256 as sha2562 } from "@noble/hashes/sha2";
5
+ import { hkdf } from "@noble/hashes/hkdf";
6
+ import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
7
+ import { wordlist } from "@scure/bip39/wordlists/english";
8
+
9
+ // src/utils.ts
10
+ import { sha256 } from "@noble/hashes/sha2";
11
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
12
+ function sha256Hash(data) {
13
+ return sha256(data);
14
+ }
15
+ function toHex(bytes) {
16
+ return bytesToHex(bytes);
17
+ }
18
+ function fromHex(hex) {
19
+ return hexToBytes(hex);
20
+ }
21
+ function encodeUtf8(str) {
22
+ return new TextEncoder().encode(str);
23
+ }
24
+ function computeBlockId(parents, payloadJson, nonce) {
25
+ const parentsStr = parents.sort().join(",");
26
+ const payloadStr = payloadJson ?? "";
27
+ const content = `${parentsStr}|${payloadStr}|${nonce}`;
28
+ const hash = sha256Hash(encodeUtf8(content));
29
+ return toHex(hash);
30
+ }
31
+ function parseAmount(amount) {
32
+ const [whole, frac = ""] = amount.split(".");
33
+ const fracPadded = frac.padEnd(8, "0").slice(0, 8);
34
+ return BigInt(whole) * 100000000n + BigInt(fracPadded);
35
+ }
36
+ function formatAmount(sats) {
37
+ const whole = sats / 100000000n;
38
+ const frac = sats % 100000000n;
39
+ const fracStr = frac.toString().padStart(8, "0");
40
+ return `${whole}.${fracStr}`;
41
+ }
42
+
43
+ // src/wallet.ts
44
+ var PmsWallet = class _PmsWallet {
45
+ /** Clé privée secp256k1 (32 bytes) - pour signatures */
46
+ _privateKey;
47
+ /** Clé publique secp256k1 non compressée (65 bytes: 04 + x + y) */
48
+ _publicKey;
49
+ /** Clé privée X25519 (32 bytes) - pour chiffrement */
50
+ _x25519PrivateKey;
51
+ /** Clé publique X25519 (32 bytes) */
52
+ _x25519PublicKey;
53
+ /** Phrase mnémonique (24 mots) si générée/importée */
54
+ _mnemonic;
55
+ /**
56
+ * Constructeur privé - utiliser les méthodes statiques.
57
+ */
58
+ constructor(privateKey, mnemonic) {
59
+ this._privateKey = privateKey;
60
+ this._publicKey = secp256k1.getPublicKey(privateKey, false);
61
+ this._x25519PrivateKey = hkdf(
62
+ sha2562,
63
+ privateKey,
64
+ new TextEncoder().encode("pms-x25519"),
65
+ // salt
66
+ new TextEncoder().encode("encryption"),
67
+ // info
68
+ 32
69
+ );
70
+ this._x25519PublicKey = x25519.getPublicKey(this._x25519PrivateKey);
71
+ this._mnemonic = mnemonic;
72
+ }
73
+ // ═══════════════════════════════════════════════════════════════════════
74
+ // Méthodes statiques de création
75
+ // ═══════════════════════════════════════════════════════════════════════
76
+ /**
77
+ * Génère un nouveau wallet avec une phrase de 24 mots.
78
+ */
79
+ static generate() {
80
+ const mnemonic = generateMnemonic(wordlist, 256);
81
+ return _PmsWallet.fromMnemonic(mnemonic);
82
+ }
83
+ /**
84
+ * Crée un wallet à partir d'une phrase mnémonique (12, 15, 18, 21 ou 24 mots).
85
+ * @throws Error si la phrase est invalide
86
+ */
87
+ static fromMnemonic(mnemonic) {
88
+ const normalized = mnemonic.trim().toLowerCase();
89
+ if (!validateMnemonic(normalized, wordlist)) {
90
+ throw new Error("Invalid mnemonic phrase");
91
+ }
92
+ const seed = mnemonicToSeedSync(normalized);
93
+ const privateKey = seed.slice(0, 32);
94
+ return new _PmsWallet(privateKey, normalized);
95
+ }
96
+ /**
97
+ * Crée un wallet à partir d'une clé privée hexadécimale.
98
+ */
99
+ static fromPrivateKey(privateKeyHex) {
100
+ const privateKey = fromHex(privateKeyHex);
101
+ if (privateKey.length !== 32) {
102
+ throw new Error("Private key must be 32 bytes");
103
+ }
104
+ return new _PmsWallet(privateKey);
105
+ }
106
+ /**
107
+ * Crée un wallet à partir d'une seed (32 bytes).
108
+ */
109
+ static fromSeed(seed) {
110
+ if (seed.length !== 32) {
111
+ throw new Error("Seed must be 32 bytes");
112
+ }
113
+ return new _PmsWallet(seed);
114
+ }
115
+ // ═══════════════════════════════════════════════════════════════════════
116
+ // Propriétés publiques
117
+ // ═══════════════════════════════════════════════════════════════════════
118
+ /**
119
+ * Adresse du wallet (clé publique hex).
120
+ * Format: "04" + 64 bytes hex = 130 caractères
121
+ */
122
+ get address() {
123
+ return toHex(this._publicKey);
124
+ }
125
+ /**
126
+ * Clé publique en bytes.
127
+ */
128
+ get publicKey() {
129
+ return this._publicKey;
130
+ }
131
+ /**
132
+ * Clé publique en hex.
133
+ */
134
+ get publicKeyHex() {
135
+ return toHex(this._publicKey);
136
+ }
137
+ /**
138
+ * Phrase mnémonique (si disponible).
139
+ * @returns undefined si le wallet a été créé depuis une clé privée
140
+ */
141
+ get mnemonic() {
142
+ return this._mnemonic;
143
+ }
144
+ // ═══════════════════════════════════════════════════════════════════════
145
+ // Clés X25519 (pour chiffrement)
146
+ // ═══════════════════════════════════════════════════════════════════════
147
+ /**
148
+ * Clé publique X25519 en hex (pour chiffrement).
149
+ * Utiliser cette clé comme destinataire pour encryptPayload().
150
+ */
151
+ get x25519PublicKeyHex() {
152
+ return toHex(this._x25519PublicKey);
153
+ }
154
+ /**
155
+ * Clé privée X25519 en hex (pour déchiffrement).
156
+ * ⚠️ Ne pas exposer cette clé publiquement !
157
+ */
158
+ get x25519PrivateKeyHex() {
159
+ return toHex(this._x25519PrivateKey);
160
+ }
161
+ // ═══════════════════════════════════════════════════════════════════════
162
+ // Méthodes de signature
163
+ // ═══════════════════════════════════════════════════════════════════════
164
+ /**
165
+ * Signe un message avec la clé privée.
166
+ * @param message - Message à signer (sera hashé avec SHA256)
167
+ * @returns Signature DER encodée en hex
168
+ */
169
+ sign(message) {
170
+ const hash = sha2562(message);
171
+ const sig = secp256k1.sign(hash, this._privateKey);
172
+ return sig.toDERHex();
173
+ }
174
+ /**
175
+ * Signe un message déjà hashé.
176
+ * @param hash - Hash 32 bytes du message
177
+ * @returns Signature DER encodée en hex
178
+ */
179
+ signHash(hash) {
180
+ if (hash.length !== 32) {
181
+ throw new Error("Hash must be 32 bytes");
182
+ }
183
+ const sig = secp256k1.sign(hash, this._privateKey);
184
+ return sig.toDERHex();
185
+ }
186
+ /**
187
+ * Exporte la clé privée en hex.
188
+ * ⚠️ À utiliser avec précaution !
189
+ */
190
+ exportPrivateKey() {
191
+ return toHex(this._privateKey);
192
+ }
193
+ // ═══════════════════════════════════════════════════════════════════════
194
+ // Méthodes statiques de vérification
195
+ // ═══════════════════════════════════════════════════════════════════════
196
+ /**
197
+ * Vérifie une signature.
198
+ * @param message - Message original
199
+ * @param signature - Signature DER hex
200
+ * @param publicKeyHex - Clé publique hex du signataire
201
+ */
202
+ static verify(message, signature, publicKeyHex) {
203
+ try {
204
+ const hash = sha2562(message);
205
+ const pubKey = fromHex(publicKeyHex);
206
+ const sig = secp256k1.Signature.fromDER(signature);
207
+ return secp256k1.verify(sig.toCompactRawBytes(), hash, pubKey);
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+ };
213
+ function isValidMnemonic(mnemonic) {
214
+ return validateMnemonic(mnemonic.trim().toLowerCase(), wordlist);
215
+ }
216
+
217
+ // src/types.ts
218
+ var DEFAULT_CONFIG = {
219
+ networkId: "pms-mainnet",
220
+ protocolVersion: 1,
221
+ timeout: 3e4,
222
+ seedNodes: [],
223
+ enableRacing: true
224
+ };
225
+
226
+ // src/crypto.ts
227
+ import { x25519 as x255192 } from "@noble/curves/ed25519";
228
+ import { gcm } from "@noble/ciphers/aes.js";
229
+ import { hkdf as hkdf2 } from "@noble/hashes/hkdf";
230
+ import { sha256 as sha2563 } from "@noble/hashes/sha2";
231
+ import { randomBytes, bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@noble/hashes/utils";
232
+ import { base64 } from "@scure/base";
233
+ var SCHEME = "x25519+aes256gcm";
234
+ var HKDF_SALT = new TextEncoder().encode("pms-dek-wrap");
235
+ var HKDF_INFO_KEK = new TextEncoder().encode("kek-v1");
236
+ var HKDF_INFO_KID = new TextEncoder().encode("kid-v1");
237
+ function fromBase64(str) {
238
+ return base64.decode(str);
239
+ }
240
+ function sha256Hex(data) {
241
+ return bytesToHex2(sha2563(data));
242
+ }
243
+ function decryptPayload(encrypted, recipientPrivateKeyHex) {
244
+ if (encrypted.scheme !== SCHEME) {
245
+ throw new Error(`Sch\xE9ma non support\xE9: ${encrypted.scheme}`);
246
+ }
247
+ const recipientSk = hexToBytes2(recipientPrivateKeyHex);
248
+ let dek = null;
249
+ for (const wrap of encrypted.recipients) {
250
+ const ephemeralPk = hexToBytes2(wrap.ephem_pub);
251
+ const sharedSecret = x255192.getSharedSecret(recipientSk, ephemeralPk);
252
+ const kek = hkdf2(sha2563, sharedSecret, HKDF_SALT, HKDF_INFO_KEK, 32);
253
+ const kidBytes = hkdf2(sha2563, sharedSecret, HKDF_SALT, HKDF_INFO_KID, 16);
254
+ const expectedKid = bytesToHex2(kidBytes);
255
+ if (expectedKid !== wrap.kid) {
256
+ continue;
257
+ }
258
+ try {
259
+ const kwNonce = fromBase64(wrap.kw_nonce_b64);
260
+ const wrappedKey = fromBase64(wrap.wrapped_key_b64);
261
+ const kwCipher = gcm(kek, kwNonce, new TextEncoder().encode(wrap.kid));
262
+ dek = kwCipher.decrypt(wrappedKey);
263
+ break;
264
+ } catch {
265
+ continue;
266
+ }
267
+ }
268
+ if (!dek) {
269
+ throw new Error("Aucun destinataire correspondant trouv\xE9 ou d\xE9ballage \xE9chou\xE9");
270
+ }
271
+ const nonce = fromBase64(encrypted.nonce_b64);
272
+ const ciphertext = fromBase64(encrypted.ciphertext_b64);
273
+ const aadBytes = new TextEncoder().encode(JSON.stringify(encrypted.aad));
274
+ const cipher = gcm(dek, nonce, aadBytes);
275
+ const plaintext = cipher.decrypt(ciphertext);
276
+ const gotCommitment = sha256Hex(plaintext);
277
+ if (gotCommitment !== encrypted.commitment) {
278
+ throw new Error("Commitment mismatch - donn\xE9es corrompues");
279
+ }
280
+ return new TextDecoder().decode(plaintext);
281
+ }
282
+
283
+ // src/client.ts
284
+ var PmsClient = class {
285
+ config;
286
+ knownNodes = /* @__PURE__ */ new Set();
287
+ lastNodeRefresh = 0;
288
+ NODE_REFRESH_INTERVAL = 6e4;
289
+ // 1 min
290
+ configCache = null;
291
+ CONFIG_TTL = 3e5;
292
+ // 5 min
293
+ /**
294
+ * Crée un nouveau client PMS.
295
+ * @param config - Configuration du client
296
+ * @param config.nodeUrl - URL du nœud principal
297
+ * @param config.enableRacing - Activer le racing pattern
298
+ * @param config.seedNodes - Liste des nœuds de seed
299
+ */
300
+ constructor(config) {
301
+ this.config = {
302
+ ...DEFAULT_CONFIG,
303
+ seedNodes: config.seedNodes ?? [],
304
+ enableRacing: config.enableRacing ?? true,
305
+ ...config
306
+ };
307
+ this.addKnownNode(this.config.nodeUrl);
308
+ this.config.seedNodes.forEach((url) => this.addKnownNode(url));
309
+ }
310
+ /**
311
+ * @internal
312
+ * Ajoute un nœud à la liste des nœuds connus.
313
+ */
314
+ addKnownNode(url) {
315
+ const normalized = url.replace(/\/$/, "");
316
+ if (normalized.startsWith("http")) {
317
+ this.knownNodes.add(normalized);
318
+ }
319
+ }
320
+ // ═══════════════════════════════════════════════════════════════════════
321
+ // Méthodes de lecture (GET)
322
+ // ═══════════════════════════════════════════════════════════════════════
323
+ /**
324
+ * Récupère les tips actuels du DAG.
325
+ */
326
+ async getTips() {
327
+ const res = await this.fetch("/v1/dag/tips", {
328
+ method: "POST",
329
+ body: JSON.stringify({ limit: 10 })
330
+ });
331
+ return res;
332
+ }
333
+ /**
334
+ * Récupère les informations publiques du coordinateur (clés).
335
+ */
336
+ async getCoordinatorInfo() {
337
+ return this.fetch("/v1/coordinator/info");
338
+ }
339
+ /**
340
+ * Récupère un bloc par son ID.
341
+ */
342
+ async getBlock(blockId) {
343
+ const wb = await this.fetch(`/v1/blocks/${blockId}`);
344
+ return {
345
+ id: wb.id,
346
+ parents: wb.parents,
347
+ nonce: wb.nonce,
348
+ payload: wb.payload_json ? JSON.parse(wb.payload_json) : void 0
349
+ };
350
+ }
351
+ /**
352
+ * Récupère les informations de supply.
353
+ * @param assetId - Optionnel: ID du token (undefined = PMS natif)
354
+ */
355
+ async getSupply(assetId) {
356
+ const query = assetId ? `?asset_id=${encodeURIComponent(assetId)}` : "";
357
+ return this.fetch(`/v1/supply${query}`);
358
+ }
359
+ /**
360
+ * Liste tous les tokens enregistrés.
361
+ */
362
+ async listTokens() {
363
+ const res = await this.fetch("/v1/tokens");
364
+ return res.tokens ?? [];
365
+ }
366
+ /**
367
+ * Récupère les métadonnées d'un token par son asset_id.
368
+ */
369
+ async getToken(assetId) {
370
+ return this.fetch(`/v1/tokens/${encodeURIComponent(assetId)}`);
371
+ }
372
+ /**
373
+ * Récupère les UTXOs d'une adresse.
374
+ */
375
+ async getUtxos(address) {
376
+ const res = await this.fetch(`/v1/wallet/${address}/utxos`);
377
+ return res.utxos ?? [];
378
+ }
379
+ // ═══════════════════════════════════════════════════════════════════════
380
+ // Préparation de Transaction (Server-Side)
381
+ // ═══════════════════════════════════════════════════════════════════════
382
+ /**
383
+ * Prépare une transaction de transfert via le serveur.
384
+ * Le serveur sélectionne les UTXOs et calcule les frais.
385
+ * Le client doit ensuite signer le `tx_hash` retourné.
386
+ *
387
+ * @param params - Paramètres de la transaction
388
+ * @param params.from - Adresse Bech32 de l'expéditeur
389
+ * @param params.to - Adresse Bech32 du destinataire
390
+ * @param params.amount - Montant à envoyer (décimal, ex: "100.5")
391
+ * @returns Transaction non-signée avec hash à signer
392
+ *
393
+ * @example
394
+ * ```typescript
395
+ * // 1. Préparer la transaction
396
+ * const prepared = await client.prepareTx({
397
+ * from: wallet.address,
398
+ * to: "pms1recipient...",
399
+ * amount: "100.0"
400
+ * });
401
+ *
402
+ * // 2. Signer le hash
403
+ * const signature = wallet.sign(fromHex(prepared.tx_hash));
404
+ *
405
+ * // 3. Soumettre via /wallet/tx/send (à implémenter)
406
+ * ```
407
+ */
408
+ async prepareTx(params) {
409
+ return this.fetch("/v1/tx/prepare", {
410
+ method: "POST",
411
+ body: JSON.stringify(params)
412
+ });
413
+ }
414
+ /**
415
+ * Récupère la balance d'une adresse.
416
+ */
417
+ /**
418
+ * Récupère la balance d'une adresse.
419
+ */
420
+ async getBalance(address) {
421
+ const res = await this.fetch("/v1/balance", {
422
+ method: "POST",
423
+ body: JSON.stringify({ address })
424
+ });
425
+ return res.balance;
426
+ }
427
+ /**
428
+ * Récupère la balance complète avec les UTXOs.
429
+ */
430
+ async getBalanceInfo(address) {
431
+ const utxos = await this.getUtxos(address);
432
+ let total = 0n;
433
+ for (const utxo of utxos) {
434
+ total += parseAmount(utxo.amount);
435
+ }
436
+ return {
437
+ address,
438
+ balance: formatAmount(total),
439
+ utxos
440
+ };
441
+ }
442
+ /**
443
+ * Récupère la liste des NFTs appartenant à une adresse.
444
+ *
445
+ * @param address - Adresse publique (hex) du propriétaire
446
+ * @returns Liste des token_ids possédés par cette adresse
447
+ *
448
+ * @example
449
+ * ```typescript
450
+ * const myNfts = await client.getNfts(myWallet.address);
451
+ * console.log(`Vous possédez ${myNfts.length} NFT(s)`);
452
+ * for (const tokenId of myNfts) {
453
+ * console.log(`- ${tokenId}`);
454
+ * }
455
+ * ```
456
+ */
457
+ async getNfts(address) {
458
+ const res = await this.fetch(`/v1/wallet/${address}/nfts`);
459
+ return res.token_ids ?? [];
460
+ }
461
+ /**
462
+ * Récupère les informations complètes d'un NFT par son token_id.
463
+ *
464
+ * @param tokenId - Identifiant unique du NFT (64 caractères hex)
465
+ * @returns NftResponse avec owner, exists et metadata
466
+ *
467
+ * @example
468
+ * ```typescript
469
+ * const nft = await client.getNft("abc123def456...");
470
+ * if (nft.exists) {
471
+ * console.log(`Owner: ${nft.owner}`);
472
+ * console.log(`Name: ${nft.metadata?.name}`);
473
+ * }
474
+ * ```
475
+ */
476
+ async getNft(tokenId) {
477
+ return this.fetch(`/v1/nft/${tokenId}`);
478
+ }
479
+ /**
480
+ * Récupère l'historique des transactions d'un wallet.
481
+ * Supporte le déchiffrement des récompenses (EncryptedReward) côté client.
482
+ *
483
+ * @param address - Adresse du wallet (bech32)
484
+ * @param options - Options (limit, decryptionWallet)
485
+ * @returns Historique complet
486
+ */
487
+ async getHistory(address, options) {
488
+ const limit = options?.limit ?? 100;
489
+ const res = await this.fetch("/wallet/history", {
490
+ method: "POST",
491
+ body: JSON.stringify({ bech32_addr: address, limit })
492
+ });
493
+ if (options?.decryptionWallet) {
494
+ const wallet = options.decryptionWallet;
495
+ for (const item of res.items) {
496
+ if (item.payload_type === "EncryptedReward") {
497
+ const encPayload = item.payload;
498
+ const decryptedOutputs = [];
499
+ for (const eOut of encPayload.encrypted_outputs) {
500
+ try {
501
+ const plaintext = decryptPayload(
502
+ eOut.encrypted,
503
+ wallet.x25519PrivateKeyHex
504
+ );
505
+ const output = JSON.parse(plaintext);
506
+ if (output.address === address) {
507
+ decryptedOutputs.push(output);
508
+ }
509
+ } catch (e) {
510
+ }
511
+ }
512
+ if (decryptedOutputs.length > 0) {
513
+ item.payload_type = "Reward";
514
+ const plain = {
515
+ fee_outputs: [],
516
+ reward_outputs: decryptedOutputs,
517
+ burned: encPayload.burned,
518
+ tx_block_id: encPayload.tx_block_id
519
+ };
520
+ item.payload = plain;
521
+ }
522
+ }
523
+ }
524
+ }
525
+ return res;
526
+ }
527
+ // ═══════════════════════════════════════════════════════════════════════
528
+ // Méthodes d'écriture (POST)
529
+ // ═══════════════════════════════════════════════════════════════════════
530
+ /**
531
+ * Récupère la configuration publique du nœud (frais, PoW, recipient, etc.)
532
+ */
533
+ async getNetworkConfig() {
534
+ if (this.configCache && Date.now() < this.configCache.expires) {
535
+ return this.configCache.data;
536
+ }
537
+ const cfg = await this.fetch("/v1/config");
538
+ this.configCache = { data: cfg, expires: Date.now() + this.CONFIG_TTL };
539
+ return cfg;
540
+ }
541
+ /**
542
+ * Récupère la configuration runtime du nœud (frais, PoW, etc.)
543
+ * @deprecated Use getNetworkConfig instead
544
+ */
545
+ async getRuntimeConfig() {
546
+ return this.getNetworkConfig();
547
+ }
548
+ /**
549
+ * @internal
550
+ * Soumet un bloc au réseau.
551
+ * Utilise le racing pattern si activé pour envoyer à plusieurs noeuds.
552
+ *
553
+ * ⚠️ API interne - préférez utiliser `send()`, `mintNft()`, ou `burnNft()`.
554
+ */
555
+ async submitBlock(wireBlock) {
556
+ if (this.config.enableRacing) {
557
+ return this.submitBlockRacing(wireBlock);
558
+ }
559
+ return this.fetch("/submit/block", {
560
+ method: "POST",
561
+ body: JSON.stringify(wireBlock)
562
+ });
563
+ }
564
+ /**
565
+ * Discovery & Racing Pattern:
566
+ * 1. Refresh node list if stale
567
+ * 2. Send to all known nodes in parallel
568
+ * 3. Return first success
569
+ */
570
+ async submitBlockRacing(wireBlock) {
571
+ this.refreshNodeList().catch((err) => console.debug("Node refresh failed:", err));
572
+ const targets = Array.from(this.knownNodes);
573
+ if (targets.length === 0) {
574
+ targets.push(this.config.nodeUrl.replace(/\/$/, ""));
575
+ }
576
+ const body = JSON.stringify(wireBlock);
577
+ const controller = new AbortController();
578
+ const promises = targets.map(async (baseUrl) => {
579
+ try {
580
+ const res = await this.fetchUrl(baseUrl, "/submit/block", {
581
+ method: "POST",
582
+ body,
583
+ signal: controller.signal
584
+ });
585
+ if (!res.block_id) res.block_id = wireBlock.id;
586
+ if (!res.status) res.status = "inserted";
587
+ return res;
588
+ } catch (err) {
589
+ throw err;
590
+ }
591
+ });
592
+ try {
593
+ const result = await Promise.any(promises);
594
+ controller.abort();
595
+ return result;
596
+ } catch (error) {
597
+ const aggregateError = error;
598
+ const errors = aggregateError.errors || [];
599
+ const errorMessages = errors.map((e) => e.message || String(e)).join("; ");
600
+ throw new Error(`Submit failed on all ${targets.length} nodes. Errors: ${errorMessages}`);
601
+ }
602
+ }
603
+ /**
604
+ * Rafraîchit la liste des noeuds connus depuis le registre
605
+ */
606
+ async refreshNodeList() {
607
+ if (Date.now() - this.lastNodeRefresh < this.NODE_REFRESH_INTERVAL) {
608
+ return;
609
+ }
610
+ try {
611
+ const res = await this.fetch("/v1/nodes");
612
+ res.nodes.forEach((node) => {
613
+ if (node.api_url) this.addKnownNode(node.api_url);
614
+ });
615
+ this.lastNodeRefresh = Date.now();
616
+ } catch (e) {
617
+ }
618
+ }
619
+ /**
620
+ * Envoie des tokens à une adresse.
621
+ * Construit automatiquement la transaction, la signe et la soumet.
622
+ */
623
+ async send(params) {
624
+ const { to, amount, wallet, memo: _memo } = params;
625
+ const utxos = await this.getUtxos(wallet.address);
626
+ if (utxos.length === 0) {
627
+ throw new Error("No UTXOs available");
628
+ }
629
+ const netConfig = await this.getNetworkConfig();
630
+ const baseFeeSats = parseAmount(netConfig.base_fee || "0");
631
+ const feeRateBps = BigInt(netConfig.fee_rate_bps || 0);
632
+ const amountSats = parseAmount(amount);
633
+ const variableFee = amountSats * feeRateBps / 10000n;
634
+ const fee = baseFeeSats + variableFee;
635
+ const totalNeeded = amountSats + fee;
636
+ let selectedSats = 0n;
637
+ const selectedUtxos = [];
638
+ for (const utxo of utxos) {
639
+ selectedUtxos.push(utxo);
640
+ selectedSats += parseAmount(utxo.amount || "0");
641
+ if (selectedSats >= totalNeeded) break;
642
+ }
643
+ if (selectedSats < totalNeeded) {
644
+ throw new Error(
645
+ `Insufficient balance: have ${formatAmount(selectedSats)}, need ${formatAmount(totalNeeded)} (incl. fee ${formatAmount(fee)})`
646
+ );
647
+ }
648
+ const outputs = [
649
+ { address: to, amount: formatAmount(amountSats) }
650
+ ];
651
+ if (fee > 0n) {
652
+ outputs.push({
653
+ address: netConfig.fee_recipient,
654
+ amount: formatAmount(fee)
655
+ });
656
+ }
657
+ const change = selectedSats - amountSats - fee;
658
+ if (change > 0n) {
659
+ outputs.push({ address: wallet.address, amount: formatAmount(change) });
660
+ }
661
+ const inputs = selectedUtxos.map((utxo) => ({
662
+ out: {
663
+ txid: utxo.outpoint.txid,
664
+ index: utxo.outpoint.index
665
+ }
666
+ }));
667
+ const txCanonical = {
668
+ inputs,
669
+ outputs,
670
+ fee: formatAmount(fee)
671
+ };
672
+ const txMessage = JSON.stringify(txCanonical);
673
+ const txSignatureHex = wallet.sign(encodeUtf8(txMessage));
674
+ const txSigBytes = fromHex(txSignatureHex);
675
+ const txSigB64 = btoa(String.fromCharCode(...txSigBytes));
676
+ const unlocks = inputs.map(() => ({
677
+ pubkey_hex: wallet.publicKeyHex,
678
+ signature_b64: txSigB64
679
+ }));
680
+ const tx = {
681
+ inputs,
682
+ outputs,
683
+ fee: formatAmount(fee),
684
+ unlocks
685
+ };
686
+ const tips = await this.getTips();
687
+ const parents = tips.slice(0, 2);
688
+ const payload = { Plain: { TxUtxo: tx } };
689
+ const payloadJson = JSON.stringify(payload);
690
+ let nonce = 0;
691
+ let blockId = computeBlockId(parents, payloadJson, nonce);
692
+ const canonicalView = {
693
+ id: blockId,
694
+ parents,
695
+ payload_json: payloadJson,
696
+ nonce,
697
+ network_id: this.config.networkId,
698
+ protocol_version: this.config.protocolVersion,
699
+ signer_pk_hex: wallet.publicKeyHex
700
+ };
701
+ const messageToSign = JSON.stringify(canonicalView);
702
+ const signatureHex = wallet.sign(encodeUtf8(messageToSign));
703
+ const signatureBytes = fromHex(signatureHex);
704
+ const signatureB64 = btoa(String.fromCharCode(...signatureBytes));
705
+ const wireBlock = {
706
+ id: blockId,
707
+ parents,
708
+ payload_json: payloadJson,
709
+ nonce,
710
+ network_id: this.config.networkId,
711
+ protocol_version: this.config.protocolVersion,
712
+ signer_pk_hex: wallet.publicKeyHex,
713
+ signature_hex: signatureB64
714
+ };
715
+ return this.submitBlock(wireBlock);
716
+ }
717
+ // ═══════════════════════════════════════════════════════════════════════
718
+ // Méthodes NFT Cube (Burn et Mint spécialisé)
719
+ // ═══════════════════════════════════════════════════════════════════════
720
+ /**
721
+ * Transférer un NFT à un autre propriétaire.
722
+ * Prend en charge le re-chiffrement des métadonnées via le coordinateur.
723
+ *
724
+ * @param params - Paramètres du transfert
725
+ * @param params.tokenId - Identifiant du NFT
726
+ * @param params.to - Adresse du nouveau propriétaire
727
+ * @param params.wallet - Wallet du propriétaire actuel (signataire)
728
+ * @param params.newOwnerX25519 - Clé publique X25519 du nouveau owner (pour re-encryption)
729
+ */
730
+ async transferNft(params) {
731
+ const { tokenId, to, wallet, newOwnerX25519 } = params;
732
+ let action;
733
+ if (newOwnerX25519) {
734
+ const prepReq = {
735
+ token_id: tokenId,
736
+ to_address: to,
737
+ from_address: wallet.address,
738
+ new_owner_x25519_pubkey: newOwnerX25519
739
+ };
740
+ const prepRes = await this.fetch("/v1/nft/transfer/prepare", {
741
+ method: "POST",
742
+ body: JSON.stringify(prepReq)
743
+ });
744
+ action = prepRes.action;
745
+ } else {
746
+ action = {
747
+ Transfer: {
748
+ token_id: tokenId,
749
+ from: wallet.address,
750
+ to
751
+ }
752
+ };
753
+ }
754
+ const tips = await this.getTips();
755
+ const parents = tips.slice(0, 2);
756
+ const payload = { Plain: { Nft: action } };
757
+ const payloadJson = JSON.stringify(payload);
758
+ const nonce = 0;
759
+ const blockId = computeBlockId(parents, payloadJson, nonce);
760
+ const canonicalView = {
761
+ id: blockId,
762
+ parents,
763
+ payload_json: payloadJson,
764
+ nonce,
765
+ network_id: this.config.networkId,
766
+ protocol_version: this.config.protocolVersion,
767
+ signer_pk_hex: wallet.publicKeyHex
768
+ };
769
+ const messageToSign = JSON.stringify(canonicalView);
770
+ const signatureHex = wallet.sign(encodeUtf8(messageToSign));
771
+ const signatureBytes = fromHex(signatureHex);
772
+ const signatureB64 = btoa(String.fromCharCode(...signatureBytes));
773
+ const wireBlock = {
774
+ id: blockId,
775
+ parents,
776
+ payload_json: payloadJson,
777
+ nonce,
778
+ network_id: this.config.networkId,
779
+ protocol_version: this.config.protocolVersion,
780
+ signer_pk_hex: wallet.publicKeyHex,
781
+ signature_hex: signatureB64
782
+ };
783
+ return this.submitBlock(wireBlock);
784
+ }
785
+ /**
786
+ * Brûle (détruit) un NFT existant.
787
+ *
788
+ * Seul le propriétaire du NFT peut le brûler.
789
+ * Une fois brûlé, le NFT est supprimé définitivement.
790
+ *
791
+ * Pour les Cubes authentiques (avec signature Authority valide),
792
+ * un remboursement est calculé selon la formule:
793
+ * `(weight * size * density) / 10000` PMS
794
+ *
795
+ * @param params - Paramètres du burn
796
+ * @param params.tokenId - Identifiant du NFT à brûler
797
+ * @param params.wallet - Wallet PMS du propriétaire (doit être l'owner actuel)
798
+ * @returns BurnNftResponse avec refund preview si cube authentique
799
+ *
800
+ * @example
801
+ * ```typescript
802
+ * const result = await client.burnNft({
803
+ * tokenId: "abc123def456...",
804
+ * wallet: myWallet,
805
+ * });
806
+ *
807
+ * if (result.refund) {
808
+ * console.log(`Remboursement: ${result.refund.amount} PMS`);
809
+ * }
810
+ * ```
811
+ */
812
+ async burnNft(params) {
813
+ const { tokenId, wallet } = params;
814
+ const tips = await this.getTips();
815
+ const parents = tips.slice(0, 2);
816
+ const payload = {
817
+ Plain: {
818
+ Nft: {
819
+ Burn: {
820
+ token_id: tokenId,
821
+ burner: wallet.address
822
+ }
823
+ }
824
+ }
825
+ };
826
+ const payloadJson = JSON.stringify(payload);
827
+ const nonce = 0;
828
+ const blockId = computeBlockId(parents, payloadJson, nonce);
829
+ const canonicalView = {
830
+ id: blockId,
831
+ parents,
832
+ payload_json: payloadJson,
833
+ nonce,
834
+ network_id: this.config.networkId,
835
+ protocol_version: this.config.protocolVersion,
836
+ signer_pk_hex: wallet.publicKeyHex
837
+ };
838
+ const messageToSign = JSON.stringify(canonicalView);
839
+ const signatureHex = wallet.sign(encodeUtf8(messageToSign));
840
+ const signatureBytes = fromHex(signatureHex);
841
+ const signatureB64 = btoa(String.fromCharCode(...signatureBytes));
842
+ const burnRequest = {
843
+ id: blockId,
844
+ parents,
845
+ payload_json: payloadJson,
846
+ nonce,
847
+ network_id: this.config.networkId,
848
+ protocol_version: this.config.protocolVersion,
849
+ signer_pk_hex: wallet.publicKeyHex,
850
+ signature_hex: signatureB64
851
+ // base64 malgré le nom "hex"
852
+ };
853
+ return this.fetch("/v1/nft/burn", {
854
+ method: "POST",
855
+ body: JSON.stringify(burnRequest)
856
+ });
857
+ }
858
+ /**
859
+ * Brûle (détruit) plusieurs NFTs en une seule transaction.
860
+ *
861
+ * @param params - Paramètres du batch burn
862
+ * @param params.tokenIds - Liste des Identifiants des NFTs à brûler
863
+ * @param params.wallet - Wallet PMS du propriétaire
864
+ * @returns BurnNftResponse avec refund preview cumulé si applicable
865
+ */
866
+ async burnNfts(params) {
867
+ const { tokenIds, wallet } = params;
868
+ if (tokenIds.length === 0) {
869
+ throw new Error("No token IDs provided for batch burn");
870
+ }
871
+ const tips = await this.getTips();
872
+ const parents = tips.slice(0, 2);
873
+ const payload = {
874
+ Plain: {
875
+ Nft: {
876
+ BatchBurn: {
877
+ token_ids: tokenIds,
878
+ burner: wallet.address
879
+ }
880
+ }
881
+ }
882
+ };
883
+ const payloadJson = JSON.stringify(payload);
884
+ const nonce = 0;
885
+ const blockId = computeBlockId(parents, payloadJson, nonce);
886
+ const canonicalView = {
887
+ id: blockId,
888
+ parents,
889
+ payload_json: payloadJson,
890
+ nonce,
891
+ network_id: this.config.networkId,
892
+ protocol_version: this.config.protocolVersion,
893
+ signer_pk_hex: wallet.publicKeyHex
894
+ };
895
+ const messageToSign = JSON.stringify(canonicalView);
896
+ const signatureHex = wallet.sign(encodeUtf8(messageToSign));
897
+ const signatureBytes = fromHex(signatureHex);
898
+ const signatureB64 = btoa(String.fromCharCode(...signatureBytes));
899
+ const burnRequest = {
900
+ id: blockId,
901
+ parents,
902
+ payload_json: payloadJson,
903
+ nonce,
904
+ network_id: this.config.networkId,
905
+ protocol_version: this.config.protocolVersion,
906
+ signer_pk_hex: wallet.publicKeyHex,
907
+ signature_hex: signatureB64
908
+ };
909
+ return this.fetch("/v1/nft/burn", {
910
+ method: "POST",
911
+ body: JSON.stringify(burnRequest)
912
+ });
913
+ }
914
+ /**
915
+ * Mint un NFT via le Coordinateur (Server-Side Signing).
916
+ * Le client génère l'ID et les métadonnées, mais c'est le serveur qui signe et chiffre.
917
+ */
918
+ async mintNft(params) {
919
+ const { wallet, metadata } = params;
920
+ const tokenId = params.tokenId || this.generateRandomHex(32);
921
+ const response = await this.fetch("/v1/nft/mint", {
922
+ method: "POST",
923
+ body: JSON.stringify({
924
+ token_id: tokenId,
925
+ owner_address: wallet.address,
926
+ owner_x25519_pubkey: wallet.x25519PublicKeyHex,
927
+ metadata
928
+ })
929
+ });
930
+ return { ...response, token_id: tokenId };
931
+ }
932
+ /**
933
+ * Mint un Cube avec des attributs générés et signés par le backend Authority.
934
+ * @param params.wallet - Wallet PMS du propriétaire
935
+ * @param params.generatorUrl - URL du backend générateur de cubes (ex: "http://localhost:3000")
936
+ */
937
+ async mintCube(params) {
938
+ const { wallet, generatorUrl } = params;
939
+ const cubeResponse = await fetch(`${generatorUrl}/api/cube/generate`, {
940
+ method: "POST"
941
+ });
942
+ if (!cubeResponse.ok) {
943
+ const text = await cubeResponse.text();
944
+ throw new Error(`Cube generation failed: ${cubeResponse.status} - ${text}`);
945
+ }
946
+ const cubeData = await cubeResponse.json();
947
+ const tokenId = this.generateRandomHex(32);
948
+ const metadata = {
949
+ name: `Cube ${cubeData.rarity}`,
950
+ description: `A ${cubeData.rarity} cube with unique properties.`,
951
+ nft_type: "cube",
952
+ extra: JSON.stringify({
953
+ rarity: cubeData.rarity,
954
+ attributes: cubeData.attributes,
955
+ roll: cubeData.roll,
956
+ signature: cubeData.signature
957
+ // Authority signature
958
+ })
959
+ };
960
+ const submitResult = await this.mintNft({
961
+ wallet,
962
+ metadata,
963
+ tokenId
964
+ });
965
+ return {
966
+ ...submitResult,
967
+ rarity: cubeData.rarity,
968
+ roll: cubeData.roll,
969
+ attributes: cubeData.attributes
970
+ };
971
+ }
972
+ /**
973
+ * Génère une chaîne hexadécimale aléatoire de la longueur spécifiée (en bytes).
974
+ * Utilise crypto.getRandomValues pour la sécurité cryptographique.
975
+ */
976
+ generateRandomHex(bytes) {
977
+ const array = new Uint8Array(bytes);
978
+ crypto.getRandomValues(array);
979
+ return Array.from(array).map((b) => b.toString(16).padStart(2, "0")).join("");
980
+ }
981
+ // ═══════════════════════════════════════════════════════════════════════
982
+ // Helper HTTP
983
+ // ═══════════════════════════════════════════════════════════════════════
984
+ async fetch(path, init) {
985
+ return this.fetchUrl(this.config.nodeUrl, path, init);
986
+ }
987
+ async fetchUrl(baseUrl, path, init) {
988
+ const url = `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
989
+ const controller = new AbortController();
990
+ const timeout = setTimeout(() => controller.abort(), this.config.timeout);
991
+ const signal = init?.signal || controller.signal;
992
+ try {
993
+ const res = await fetch(url, {
994
+ ...init,
995
+ headers: {
996
+ "Content-Type": "application/json",
997
+ ...init?.headers
998
+ },
999
+ signal
1000
+ });
1001
+ if (!res.ok) {
1002
+ const text2 = await res.text();
1003
+ throw new Error(`HTTP ${res.status} (${url}): ${text2}`);
1004
+ }
1005
+ const text = await res.text();
1006
+ if (!text) {
1007
+ return {};
1008
+ }
1009
+ try {
1010
+ return JSON.parse(text);
1011
+ } catch (e) {
1012
+ throw new Error(`Invalid JSON response: ${text.substring(0, 50)}...`);
1013
+ }
1014
+ } finally {
1015
+ clearTimeout(timeout);
1016
+ }
1017
+ }
1018
+ };
1019
+ export {
1020
+ PmsClient,
1021
+ PmsWallet,
1022
+ decryptPayload,
1023
+ formatAmount,
1024
+ fromHex,
1025
+ isValidMnemonic,
1026
+ parseAmount,
1027
+ toHex
1028
+ };