@empereur-rouge/pms-sdk 0.3.8 → 0.7.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/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { wordlist } from "@scure/bip39/wordlists/english";
9
9
  // src/utils.ts
10
10
  import { sha256 } from "@noble/hashes/sha2";
11
11
  import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
12
+ import { base64, bech32m } from "@scure/base";
12
13
  function sha256Hash(data) {
13
14
  return sha256(data);
14
15
  }
@@ -21,12 +22,103 @@ function fromHex(hex) {
21
22
  function encodeUtf8(str) {
22
23
  return new TextEncoder().encode(str);
23
24
  }
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);
25
+ function toBase64(bytes) {
26
+ return base64.encode(bytes);
27
+ }
28
+ function txSigningMessage(networkId, inputs, outputs, fee) {
29
+ const canonInputs = inputs.map((i) => ({
30
+ out: { txid: i.out.txid, index: i.out.index }
31
+ }));
32
+ const canonOutputs = outputs.map((o) => {
33
+ const out = {
34
+ address: o.address,
35
+ amount: o.amount
36
+ };
37
+ if (o.asset_id !== void 0 && o.asset_id !== null) {
38
+ out.asset_id = o.asset_id;
39
+ }
40
+ if (o.locked_until !== void 0 && o.locked_until !== null) {
41
+ out.locked_until = o.locked_until;
42
+ }
43
+ if (o.spend_condition !== void 0 && o.spend_condition !== null) {
44
+ out.spend_condition = o.spend_condition;
45
+ }
46
+ if (o.created_at !== void 0 && o.created_at !== null) {
47
+ out.created_at = o.created_at;
48
+ }
49
+ return out;
50
+ });
51
+ const canon = {
52
+ network_id: networkId,
53
+ inputs: canonInputs,
54
+ outputs: canonOutputs,
55
+ fee
56
+ };
57
+ return toHex(sha256Hash(encodeUtf8(JSON.stringify(canon))));
58
+ }
59
+ var PLAIN_VARIANT_TO_PAYLOAD_TYPE = {
60
+ Genesis: "Genesis",
61
+ Mint: "Mint",
62
+ TxUtxo: "Transaction",
63
+ Milestone: "Milestone",
64
+ Nft: "Nft",
65
+ ConfigUpdate: "ConfigUpdate",
66
+ Reward: "Reward",
67
+ EncryptedReward: "EncryptedReward",
68
+ TokenCreate: "TokenCreate",
69
+ BridgeLock: "BridgeLock",
70
+ BridgeMint: "BridgeMint",
71
+ Freeze: "Freeze",
72
+ Unfreeze: "Unfreeze",
73
+ Seize: "Seize",
74
+ Reverse: "Reverse",
75
+ ContractRegister: "ContractRegister",
76
+ ContractUpdate: "ContractUpdate",
77
+ LedgerOwnershipTransfer: "LedgerOwnershipTransfer",
78
+ CoordinatorKeyRotate: "CoordinatorKeyRotate"
79
+ };
80
+ var EMPTY_COMMITMENT = "0000000000000000000000000000000000000000000000000000000000000000";
81
+ function computeBlockId(parents, payload, nonce) {
82
+ let payload_type;
83
+ let commitment;
84
+ let len_hint;
85
+ let key_version;
86
+ if (payload === void 0 || payload === null) {
87
+ payload_type = "None";
88
+ commitment = EMPTY_COMMITMENT;
89
+ len_hint = 0;
90
+ key_version = 0;
91
+ } else if ("Plain" in payload) {
92
+ const plain = payload.Plain;
93
+ const variant = typeof plain === "string" ? plain : Object.keys(plain)[0];
94
+ payload_type = PLAIN_VARIANT_TO_PAYLOAD_TYPE[variant] ?? variant;
95
+ const plainBytes = encodeUtf8(JSON.stringify(plain));
96
+ commitment = toHex(sha256Hash(plainBytes));
97
+ len_hint = plainBytes.length;
98
+ key_version = 0;
99
+ } else {
100
+ const enc = payload.Encrypted;
101
+ payload_type = "Encrypted";
102
+ commitment = enc.commitment;
103
+ len_hint = enc.ciphertext_b64.length;
104
+ key_version = enc.key_version;
105
+ }
106
+ const head = {
107
+ parents,
108
+ nonce,
109
+ envelope: { payload_type, commitment, len_hint, key_version }
110
+ };
111
+ return toHex(sha256Hash(encodeUtf8(JSON.stringify(head))));
112
+ }
113
+ function x25519FromAddress(address) {
114
+ try {
115
+ const decoded = bech32m.decode(address, false);
116
+ const bytes = bech32m.fromWords(decoded.words);
117
+ if (bytes.length !== 52) return void 0;
118
+ return toHex(bytes.slice(20));
119
+ } catch {
120
+ return void 0;
121
+ }
30
122
  }
31
123
  function parseAmount(amount) {
32
124
  const [whole, frac = ""] = amount.split(".");
@@ -39,6 +131,47 @@ function formatAmount(sats) {
39
131
  const fracStr = frac.toString().padStart(8, "0");
40
132
  return `${whole}.${fracStr}`;
41
133
  }
134
+ var DAY_MS = 864e5;
135
+ function multisigAddress(m, pubkeys) {
136
+ const normalized = pubkeys.map((pk) => {
137
+ const t = pk.trim();
138
+ return (t.startsWith("0x") ? t.slice(2) : t).toLowerCase();
139
+ }).sort();
140
+ const parts = [
141
+ encodeUtf8("pms-multisig-v1"),
142
+ new Uint8Array([m, normalized.length]),
143
+ ...normalized.map((pk) => encodeUtf8(pk))
144
+ ];
145
+ const total = parts.reduce((n, p) => n + p.length, 0);
146
+ const buf = new Uint8Array(total);
147
+ let off = 0;
148
+ for (const p of parts) {
149
+ buf.set(p, off);
150
+ off += p.length;
151
+ }
152
+ return `msig1${toHex(sha256Hash(buf).slice(0, 20))}`;
153
+ }
154
+ function hashlockHash(preimage) {
155
+ const bytes = typeof preimage === "string" ? encodeUtf8(preimage) : preimage;
156
+ return toHex(sha256Hash(bytes));
157
+ }
158
+ function effectiveValue(amount, createdAtMs, nowMs, bpsPerDay) {
159
+ if (createdAtMs === void 0 || createdAtMs === null || !bpsPerDay) {
160
+ return amount;
161
+ }
162
+ const elapsed = nowMs - createdAtMs;
163
+ const days = elapsed > 0 ? BigInt(Math.floor(elapsed / DAY_MS)) : 0n;
164
+ if (days === 0n) return amount;
165
+ const sats = parseAmount(amount);
166
+ const scaled = sats * 10000n - sats * BigInt(bpsPerDay) * days;
167
+ if (scaled <= 0n) return "0.00000000";
168
+ return formatAmount(scaled / 10000n);
169
+ }
170
+ function spendableUtxos(utxos, nowMs = Date.now()) {
171
+ return utxos.filter(
172
+ (u) => u.locked_until === void 0 || u.locked_until === null || u.locked_until <= nowMs
173
+ );
174
+ }
42
175
 
43
176
  // src/wallet.ts
44
177
  var PmsWallet = class _PmsWallet {
@@ -213,17 +346,57 @@ var PmsWallet = class _PmsWallet {
213
346
  function isValidMnemonic(mnemonic) {
214
347
  return validateMnemonic(mnemonic.trim().toLowerCase(), wordlist);
215
348
  }
349
+ function makeUnlock(signers, txHashHex, opts) {
350
+ if (signers.length === 0) {
351
+ throw new Error("makeUnlock: at least one signer is required");
352
+ }
353
+ const msg = encodeUtf8(txHashHex);
354
+ const sign = (w) => toBase64(fromHex(w.sign(msg)));
355
+ const [primary, ...rest] = signers;
356
+ const unlock = {
357
+ pubkey_hex: primary.publicKeyHex,
358
+ signature_b64: sign(primary)
359
+ };
360
+ if (rest.length > 0) {
361
+ unlock.cosigners = rest.map((w) => ({
362
+ pubkey_hex: w.publicKeyHex,
363
+ signature_b64: sign(w)
364
+ }));
365
+ }
366
+ if (opts?.preimageHex !== void 0) {
367
+ unlock.preimage_hex = opts.preimageHex;
368
+ }
369
+ return unlock;
370
+ }
216
371
 
217
372
  // src/types.ts
218
373
  var DEFAULT_CONFIG = {
219
374
  networkId: "pms-mainnet",
220
- protocolVersion: 1,
375
+ protocolVersion: 3,
221
376
  timeout: 3e4,
222
377
  seedNodes: [],
223
378
  enableRacing: true,
224
379
  retries: 2,
225
380
  perAttemptTimeoutMs: 4e3,
226
- retryBaseDelayMs: 100
381
+ retryBaseDelayMs: 100,
382
+ // Vide par défaut : les méthodes admin lèvent une erreur explicite si
383
+ // appelées sans jeton (voir PmsClient.requireAdminToken).
384
+ adminToken: ""
385
+ };
386
+ var ErrorCode = {
387
+ /** En-tête d'authentification manquant (admin requis). */
388
+ MissingAuth: 1001,
389
+ /** Jeton d'authentification invalide. */
390
+ InvalidAuth: 1002,
391
+ /**
392
+ * Proposition de gouvernance rejetée (timelock non écoulé, proposition
393
+ * non en attente, ou identifiant inconnu). HTTP 409.
394
+ */
395
+ GovernanceRejected: 3071,
396
+ /** Émission native (mint) désactivée. HTTP 503. */
397
+ MintDisabled: 5031,
398
+ /** Erreur interne générique (dette : handler non encore migré). */
399
+ Internal: 9999
227
400
  };
228
401
 
229
402
  // src/crypto.ts
@@ -232,13 +405,13 @@ import { gcm } from "@noble/ciphers/aes.js";
232
405
  import { hkdf as hkdf2 } from "@noble/hashes/hkdf";
233
406
  import { sha256 as sha2563 } from "@noble/hashes/sha2";
234
407
  import { randomBytes, bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@noble/hashes/utils";
235
- import { base64 } from "@scure/base";
408
+ import { base64 as base642 } from "@scure/base";
236
409
  var SCHEME = "x25519+aes256gcm";
237
410
  var HKDF_SALT = new TextEncoder().encode("pms-dek-wrap");
238
411
  var HKDF_INFO_KEK = new TextEncoder().encode("kek-v1");
239
412
  var HKDF_INFO_KID = new TextEncoder().encode("kid-v1");
240
413
  function fromBase64(str) {
241
- return base64.decode(str);
414
+ return base642.decode(str);
242
415
  }
243
416
  function sha256Hex(data) {
244
417
  return bytesToHex2(sha2563(data));
@@ -403,11 +576,222 @@ var PmsClient = class {
403
576
  }
404
577
  /**
405
578
  * Récupère les UTXOs d'une adresse.
579
+ *
580
+ * Depuis dag-pms v0.10.0, chaque UTXO expose aussi ses contraintes de
581
+ * dépense : `locked_until` (time-lock), `spend_condition`
582
+ * (MultiSig/HashLock) et `created_at` (base du demurrage). Utiliser
583
+ * [`spendableUtxos`] pour exclure les UTXOs encore verrouillés avant
584
+ * toute sélection de coins côté client.
406
585
  */
407
586
  async getUtxos(address) {
408
587
  const res = await this.fetch(`/v1/wallet/${address}/utxos`, void 0, { idempotent: true });
409
588
  return res.utxos ?? [];
410
589
  }
590
+ /**
591
+ * Dernier snapshot de preuve de réserves ancré (protocole 2.6,
592
+ * `GET /v1/reserves/latest`). 404 si aucun snapshot n'a encore été ancré.
593
+ */
594
+ async getLatestReserves() {
595
+ return this.fetch("/v1/reserves/latest", void 0, { idempotent: true });
596
+ }
597
+ // ═══════════════════════════════════════════════════════════════════════
598
+ // Gouvernance — lectures publiques (GET, sans authentification)
599
+ // ═══════════════════════════════════════════════════════════════════════
600
+ /**
601
+ * Liste les propositions de gouvernance EN ATTENTE (statut `pending`).
602
+ *
603
+ * Lecture publique — aucun `adminToken` requis.
604
+ *
605
+ * @returns Le tableau des propositions en attente (vide si aucune).
606
+ *
607
+ * @example
608
+ * ```typescript
609
+ * const pending = await client.getGovernancePending();
610
+ * for (const p of pending) {
611
+ * console.log(`${p.proposal_id} enacts after ${new Date(p.enact_after_ms)}`);
612
+ * }
613
+ * ```
614
+ */
615
+ async getGovernancePending() {
616
+ const res = await this.fetch(
617
+ "/v1/governance/pending",
618
+ void 0,
619
+ { idempotent: true }
620
+ );
621
+ return res.pending ?? [];
622
+ }
623
+ /**
624
+ * Liste les propositions de gouvernance TERMINÉES (enactées ou annulées).
625
+ *
626
+ * Lecture publique — aucun `adminToken` requis.
627
+ *
628
+ * @returns Le tableau de l'historique des propositions (vide si aucune).
629
+ */
630
+ async getGovernanceHistory() {
631
+ const res = await this.fetch(
632
+ "/v1/governance/history",
633
+ void 0,
634
+ { idempotent: true }
635
+ );
636
+ return res.history ?? [];
637
+ }
638
+ /**
639
+ * Liste les blocs de gouvernance ancrés dans le DAG (un par proposition,
640
+ * enactment, ou annulation).
641
+ *
642
+ * Lecture publique — aucun `adminToken` requis.
643
+ *
644
+ * @returns `{ count, blocks }` — le nombre et la liste des blocs.
645
+ */
646
+ async getGovernanceBlocks() {
647
+ return this.fetch(
648
+ "/v1/governance/blocks",
649
+ void 0,
650
+ { idempotent: true }
651
+ );
652
+ }
653
+ // ═══════════════════════════════════════════════════════════════════════
654
+ // Gouvernance — actions ADMIN (POST, requièrent adminToken)
655
+ // ═══════════════════════════════════════════════════════════════════════
656
+ /**
657
+ * Soumet une proposition de gouvernance ancrée dans le DAG.
658
+ *
659
+ * Méthode ADMIN — requiert `adminToken` dans la config du client
660
+ * (envoyé en `Authorization: Bearer <adminToken>`). Lève une erreur
661
+ * explicite avant tout appel réseau si le jeton est absent.
662
+ *
663
+ * @param req.update - Mise à jour de config à proposer.
664
+ * @param req.tier - Palier de gouvernance (minuscules, voir
665
+ * [`GovernanceTier`]).
666
+ * @param req.reason - Raison libre (optionnelle).
667
+ * @returns `{ status, proposal_id, block_id, tier, announced_at_ms,
668
+ * enact_after_ms }`.
669
+ *
670
+ * @example
671
+ * ```typescript
672
+ * const res = await client.proposeGovernance({
673
+ * update: { SetEmissionCorridor: { ceiling_bps: 1500, floor_bps: 0, target_bps: 1000, epoch_duration_sec: 86400 } },
674
+ * tier: "constitution",
675
+ * reason: "raise emission ceiling for Q3",
676
+ * });
677
+ * console.log(res.proposal_id, "enacts after", new Date(res.enact_after_ms));
678
+ * ```
679
+ */
680
+ async proposeGovernance(req) {
681
+ return this.fetchAdmin("proposeGovernance", "/admin/governance/propose", {
682
+ method: "POST",
683
+ body: JSON.stringify({
684
+ update: req.update,
685
+ tier: req.tier,
686
+ reason: req.reason ?? ""
687
+ })
688
+ });
689
+ }
690
+ /**
691
+ * Enacte une proposition de gouvernance dont le timelock est écoulé.
692
+ *
693
+ * Méthode ADMIN — requiert `adminToken`. Le moteur rejette (HTTP 409,
694
+ * code [`ErrorCode.GovernanceRejected`] = 3071) si le timelock n'est pas
695
+ * écoulé, si la proposition n'est pas en attente, ou si l'identifiant est
696
+ * inconnu.
697
+ *
698
+ * @param proposalId - Identifiant de la proposition à enacter.
699
+ * @param reason - Raison libre (optionnelle).
700
+ * @returns `{ status, proposal_id, block_id }`.
701
+ */
702
+ async enactProposal(proposalId, reason) {
703
+ return this.fetchAdmin(
704
+ "enactProposal",
705
+ `/admin/governance/enact/${encodeURIComponent(proposalId)}`,
706
+ {
707
+ method: "POST",
708
+ body: JSON.stringify({ reason: reason ?? "" })
709
+ }
710
+ );
711
+ }
712
+ /**
713
+ * Annule une proposition de gouvernance en attente.
714
+ *
715
+ * Méthode ADMIN — requiert `adminToken`. Rejet HTTP 409 / code 3071 si la
716
+ * proposition n'est pas en attente ou si l'identifiant est inconnu.
717
+ *
718
+ * @param proposalId - Identifiant de la proposition à annuler.
719
+ * @param reason - Raison libre (optionnelle).
720
+ * @returns `{ status, proposal_id, block_id }`.
721
+ */
722
+ async cancelProposal(proposalId, reason) {
723
+ return this.fetchAdmin(
724
+ "cancelProposal",
725
+ `/admin/governance/cancel/${encodeURIComponent(proposalId)}`,
726
+ {
727
+ method: "POST",
728
+ body: JSON.stringify({ reason: reason ?? "" })
729
+ }
730
+ );
731
+ }
732
+ /**
733
+ * Applique (ou propose) une mise à jour de configuration.
734
+ *
735
+ * Méthode ADMIN — requiert `adminToken`. Le moteur distingue DEUX issues
736
+ * selon le sens du changement, et le SDK les EXPOSE explicitement via une
737
+ * union discriminée sur `applied` — un 202 n'est JAMAIS traité comme un
738
+ * succès appliqué :
739
+ * - **HTTP 200** (durcissement) → `{ applied: true, ... }` : la mise à
740
+ * jour est instantanément en vigueur.
741
+ * - **HTTP 202** (assouplissement) → `{ applied: false, proposalId,
742
+ * enactAfterMs, ... }` : la mise à jour est placée sous timelock de
743
+ * gouvernance, PAS encore appliquée. Elle s'auto-enacte à l'expiration
744
+ * du timelock, ou via [`enactProposal`].
745
+ *
746
+ * @param update - Mise à jour de config (envoyée brute, sans wrapper).
747
+ * @returns Une [`ConfigChangeResult`] discriminée sur `applied`.
748
+ *
749
+ * @example
750
+ * ```typescript
751
+ * const r = await client.setConfig({ SetMintEnabled: { enabled: false } });
752
+ * if (r.applied) {
753
+ * console.log("appliqué instantanément:", r.updateApplied);
754
+ * } else {
755
+ * console.log("timelocké, enact après", new Date(r.enactAfterMs));
756
+ * }
757
+ * ```
758
+ */
759
+ async setConfig(update) {
760
+ const { status, body } = await this.fetchAdminWithStatus(
761
+ "setConfig",
762
+ "/admin/config",
763
+ {
764
+ method: "POST",
765
+ body: JSON.stringify(update)
766
+ }
767
+ );
768
+ const isApplied = status === 200 && body.status !== "proposed";
769
+ if (isApplied) {
770
+ return {
771
+ applied: true,
772
+ status: "applied",
773
+ mode: body.mode ?? "",
774
+ updateApplied: body.update_applied ?? "",
775
+ tier: body.tier ?? "",
776
+ proposalId: body.proposal_id ?? "",
777
+ proposalBlockId: body.proposal_block_id ?? "",
778
+ enactBlockId: body.enact_block_id ?? "",
779
+ config: body.config ?? null
780
+ };
781
+ }
782
+ return {
783
+ applied: false,
784
+ status: "proposed",
785
+ mode: body.mode ?? "",
786
+ update: body.update ?? "",
787
+ tier: body.tier ?? "",
788
+ proposalId: body.proposal_id ?? "",
789
+ proposalBlockId: body.proposal_block_id ?? "",
790
+ announcedAtMs: body.announced_at_ms ?? 0,
791
+ enactAfterMs: body.enact_after_ms ?? 0,
792
+ message: body.message ?? ""
793
+ };
794
+ }
411
795
  // ═══════════════════════════════════════════════════════════════════════
412
796
  // Wallet API (Custodial — Server-Side)
413
797
  // ═══════════════════════════════════════════════════════════════════════
@@ -864,101 +1248,83 @@ var PmsClient = class {
864
1248
  }
865
1249
  /**
866
1250
  * Envoie des tokens à une adresse.
867
- * Construit automatiquement la transaction, la signe et la soumet.
1251
+ *
1252
+ * Flux aligné sur dag-pms v0.9.0 (single-writer + vérification des
1253
+ * signatures de transaction par l'engine) :
1254
+ * 1. `POST /v1/tx/prepare` — le serveur sélectionne les UTXOs et calcule
1255
+ * les frais.
1256
+ * 2. Vérification défensive côté client : l'output destinataire demandé
1257
+ * existe bien, et le `tx_hash` retourné correspond au message canonique
1258
+ * recalculé localement (`txSigningMessage`). On ne signe JAMAIS un hash
1259
+ * opaque non vérifié.
1260
+ * 3. Le wallet signe la TRANSACTION (unlocks) — pas le bloc. C'est le
1261
+ * serveur qui forge et signe le bloc (single-writer enforcement).
1262
+ * 4. `POST /v1/wallet/tx/send` avec la TX signée + les clés X25519 des
1263
+ * destinataires pour le chiffrement du payload.
1264
+ *
1265
+ * @param params.to - Adresse destinataire (Bech32m de préférence)
1266
+ * @param params.amount - Montant décimal (ex: "10.0")
1267
+ * @param params.wallet - Wallet signataire (propriétaire des UTXOs)
1268
+ * @param params.assetId - Asset ID optionnel (undefined = PMS natif)
1269
+ * @returns `{id, status, block_id}` — id du bloc forgé par le serveur
868
1270
  */
869
1271
  async send(params) {
870
- const { to, amount, wallet, memo: _memo } = params;
871
- const utxos = await this.getUtxos(wallet.address);
872
- if (utxos.length === 0) {
873
- throw new Error("No UTXOs available");
874
- }
875
- const netConfig = await this.getNetworkConfig();
876
- const baseFeeSats = parseAmount(netConfig.base_fee || "0");
877
- const feeRateBps = BigInt(netConfig.fee_rate_bps || 0);
878
- const amountSats = parseAmount(amount);
879
- const variableFee = amountSats * feeRateBps / 10000n;
880
- const fee = baseFeeSats + variableFee;
881
- const totalNeeded = amountSats + fee;
882
- let selectedSats = 0n;
883
- const selectedUtxos = [];
884
- for (const utxo of utxos) {
885
- selectedUtxos.push(utxo);
886
- selectedSats += parseAmount(utxo.amount || "0");
887
- if (selectedSats >= totalNeeded) break;
1272
+ const { to, amount, wallet, assetId } = params;
1273
+ const prepareBody = {
1274
+ from: wallet.address,
1275
+ to,
1276
+ amount
1277
+ };
1278
+ if (assetId !== void 0) {
1279
+ prepareBody.asset_id = assetId;
888
1280
  }
889
- if (selectedSats < totalNeeded) {
1281
+ const prepared = await this.prepareTx(prepareBody);
1282
+ const { unsigned_tx, tx_hash } = prepared;
1283
+ const requestedSats = parseAmount(amount);
1284
+ const recipientOutput = unsigned_tx.outputs.find(
1285
+ (o) => o.address === to && (o.asset_id ?? void 0) === (assetId ?? void 0) && parseAmount(o.amount) >= requestedSats
1286
+ );
1287
+ if (!recipientOutput) {
890
1288
  throw new Error(
891
- `Insufficient balance: have ${formatAmount(selectedSats)}, need ${formatAmount(totalNeeded)} (incl. fee ${formatAmount(fee)})`
1289
+ `Prepared transaction does not contain the requested output (to=${to}, amount=${amount}, asset=${assetId ?? "PMS"}). Refusing to sign a transaction that does not pay the intended recipient.`
892
1290
  );
893
1291
  }
894
- const outputs = [
895
- { address: to, amount: formatAmount(amountSats) }
896
- ];
897
- if (fee > 0n) {
898
- outputs.push({
899
- address: netConfig.fee_recipient,
900
- amount: formatAmount(fee)
901
- });
902
- }
903
- const change = selectedSats - amountSats - fee;
904
- if (change > 0n) {
905
- outputs.push({ address: wallet.address, amount: formatAmount(change) });
1292
+ const localHash = txSigningMessage(
1293
+ this.config.networkId,
1294
+ unsigned_tx.inputs,
1295
+ unsigned_tx.outputs,
1296
+ unsigned_tx.fee
1297
+ );
1298
+ if (localHash !== tx_hash) {
1299
+ throw new Error(
1300
+ `tx_hash mismatch: server returned ${tx_hash}, locally computed ${localHash}. Either the client networkId ("${this.config.networkId}") does not match the node's network, or the server response was tampered with. Refusing to sign.`
1301
+ );
906
1302
  }
907
- const inputs = selectedUtxos.map((utxo) => ({
908
- out: {
909
- txid: utxo.outpoint.txid,
910
- index: utxo.outpoint.index
911
- }
912
- }));
913
- const txCanonical = {
914
- inputs,
915
- outputs,
916
- fee: formatAmount(fee)
917
- };
918
- const txMessage = JSON.stringify(txCanonical);
919
- const txSignatureHex = wallet.sign(encodeUtf8(txMessage));
920
- const txSigBytes = fromHex(txSignatureHex);
921
- const txSigB64 = btoa(String.fromCharCode(...txSigBytes));
922
- const unlocks = inputs.map(() => ({
1303
+ const sigHex = wallet.sign(encodeUtf8(tx_hash));
1304
+ const sigB64 = toBase64(fromHex(sigHex));
1305
+ const unlocks = unsigned_tx.inputs.map(() => ({
923
1306
  pubkey_hex: wallet.publicKeyHex,
924
- signature_b64: txSigB64
1307
+ signature_b64: sigB64
925
1308
  }));
926
1309
  const tx = {
927
- inputs,
928
- outputs,
929
- fee: formatAmount(fee),
1310
+ inputs: unsigned_tx.inputs,
1311
+ outputs: unsigned_tx.outputs,
1312
+ fee: unsigned_tx.fee,
930
1313
  unlocks
931
1314
  };
932
- const tips = await this.getTips();
933
- const parents = tips.slice(0, 2);
934
- const payload = { Plain: { TxUtxo: tx } };
935
- const payloadJson = JSON.stringify(payload);
936
- let nonce = 0;
937
- let blockId = computeBlockId(parents, payloadJson, nonce);
938
- const canonicalView = {
939
- id: blockId,
940
- parents,
941
- payload_json: payloadJson,
942
- nonce,
943
- network_id: this.config.networkId,
944
- protocol_version: this.config.protocolVersion,
945
- signer_pk_hex: wallet.publicKeyHex
946
- };
947
- const messageToSign = JSON.stringify(canonicalView);
948
- const signatureHex = wallet.sign(encodeUtf8(messageToSign));
949
- const signatureBytes = fromHex(signatureHex);
950
- const signatureB64 = btoa(String.fromCharCode(...signatureBytes));
951
- const wireBlock = {
952
- id: blockId,
953
- parents,
954
- payload_json: payloadJson,
955
- nonce,
956
- network_id: this.config.networkId,
957
- protocol_version: this.config.protocolVersion,
958
- signer_pk_hex: wallet.publicKeyHex,
959
- signature_hex: signatureB64
960
- };
961
- return this.submitBlock(wireBlock);
1315
+ const recipientsXpk = [wallet.x25519PublicKeyHex];
1316
+ const toXpk = x25519FromAddress(to);
1317
+ if (toXpk && !recipientsXpk.includes(toXpk)) {
1318
+ recipientsXpk.push(toXpk);
1319
+ }
1320
+ const resp = await this.fetch(
1321
+ "/v1/wallet/tx/send",
1322
+ {
1323
+ method: "POST",
1324
+ body: JSON.stringify({ tx, recipients_xpk: recipientsXpk })
1325
+ }
1326
+ );
1327
+ return { ...resp, block_id: resp.id };
962
1328
  }
963
1329
  // ═══════════════════════════════════════════════════════════════════════
964
1330
  // Méthodes NFT Cube (Burn et Mint spécialisé)
@@ -966,7 +1332,15 @@ var PmsClient = class {
966
1332
  /**
967
1333
  * Transférer un NFT à un autre propriétaire.
968
1334
  * Prend en charge le re-chiffrement des métadonnées via le coordinateur.
969
- *
1335
+ *
1336
+ * @deprecated Cette méthode soumet un WireBlock signé par la clé
1337
+ * utilisateur via `/submit/block`. Depuis dag-pms v0.9.0, l'engine tourne
1338
+ * en mode **single-writer** : tout bloc signé par une clé non-coordinateur
1339
+ * est rejeté. Cette méthode ne fonctionne que sur un réseau SANS
1340
+ * single-writer enforcement. Utilisez les flux server-side
1341
+ * (`/v1/nft/transfer/prepare` + endpoints serveur) qui forgent et signent
1342
+ * le bloc côté coordinateur.
1343
+ *
970
1344
  * @param params - Paramètres du transfert
971
1345
  * @param params.tokenId - Identifiant du NFT
972
1346
  * @param params.to - Adresse du nouveau propriétaire
@@ -1002,7 +1376,7 @@ var PmsClient = class {
1002
1376
  const payload = { Plain: { Nft: action } };
1003
1377
  const payloadJson = JSON.stringify(payload);
1004
1378
  const nonce = 0;
1005
- const blockId = computeBlockId(parents, payloadJson, nonce);
1379
+ const blockId = computeBlockId(parents, payload, nonce);
1006
1380
  const canonicalView = {
1007
1381
  id: blockId,
1008
1382
  parents,
@@ -1034,10 +1408,17 @@ var PmsClient = class {
1034
1408
  * Seul le propriétaire du NFT peut le brûler.
1035
1409
  * Une fois brûlé, le NFT est supprimé définitivement.
1036
1410
  *
1037
- * Pour les Cubes authentiques (avec signature Authority valide),
1411
+ * Pour les Cubes authentiques (avec signature Authority valide),
1038
1412
  * un remboursement est calculé selon la formule:
1039
1413
  * `(weight * size * density) / 10000` PMS
1040
- *
1414
+ *
1415
+ * @deprecated Cette méthode poste un WireBlock signé par la clé
1416
+ * utilisateur. Depuis dag-pms v0.9.0, l'engine tourne en mode
1417
+ * **single-writer** : tout bloc signé par une clé non-coordinateur est
1418
+ * rejeté. Cette méthode ne fonctionne que sur un réseau SANS single-writer
1419
+ * enforcement. Le flux supporté est le burn server-side
1420
+ * (`/v1/nft/burn-simple`), où le serveur forge et signe le bloc.
1421
+ *
1041
1422
  * @param params - Paramètres du burn
1042
1423
  * @param params.tokenId - Identifiant du NFT à brûler
1043
1424
  * @param params.wallet - Wallet PMS du propriétaire (doit être l'owner actuel)
@@ -1071,7 +1452,7 @@ var PmsClient = class {
1071
1452
  };
1072
1453
  const payloadJson = JSON.stringify(payload);
1073
1454
  const nonce = 0;
1074
- const blockId = computeBlockId(parents, payloadJson, nonce);
1455
+ const blockId = computeBlockId(parents, payload, nonce);
1075
1456
  const canonicalView = {
1076
1457
  id: blockId,
1077
1458
  parents,
@@ -1103,7 +1484,14 @@ var PmsClient = class {
1103
1484
  }
1104
1485
  /**
1105
1486
  * Brûle (détruit) plusieurs NFTs en une seule transaction.
1106
- *
1487
+ *
1488
+ * @deprecated Cette méthode poste un WireBlock signé par la clé
1489
+ * utilisateur. Depuis dag-pms v0.9.0, l'engine tourne en mode
1490
+ * **single-writer** : tout bloc signé par une clé non-coordinateur est
1491
+ * rejeté. Cette méthode ne fonctionne que sur un réseau SANS single-writer
1492
+ * enforcement. Le flux supporté est le burn server-side
1493
+ * (`/v1/nft/burn-simple`), où le serveur forge et signe le bloc.
1494
+ *
1107
1495
  * @param params - Paramètres du batch burn
1108
1496
  * @param params.tokenIds - Liste des Identifiants des NFTs à brûler
1109
1497
  * @param params.wallet - Wallet PMS du propriétaire
@@ -1128,7 +1516,7 @@ var PmsClient = class {
1128
1516
  };
1129
1517
  const payloadJson = JSON.stringify(payload);
1130
1518
  const nonce = 0;
1131
- const blockId = computeBlockId(parents, payloadJson, nonce);
1519
+ const blockId = computeBlockId(parents, payload, nonce);
1132
1520
  const canonicalView = {
1133
1521
  id: blockId,
1134
1522
  parents,
@@ -1227,6 +1615,62 @@ var PmsClient = class {
1227
1615
  // ═══════════════════════════════════════════════════════════════════════
1228
1616
  // Helper HTTP
1229
1617
  // ═══════════════════════════════════════════════════════════════════════
1618
+ /**
1619
+ * @internal
1620
+ * Garantit qu'un `adminToken` est configuré avant un appel admin.
1621
+ * Lève une erreur claire (sans appel réseau) sinon.
1622
+ */
1623
+ requireAdminToken(method) {
1624
+ const token = this.config.adminToken;
1625
+ if (!token || token.trim() === "") {
1626
+ throw new Error(
1627
+ `PmsClient.${method}() requires an admin token. Set it via new PmsClient({ ..., adminToken: '<token>' }). Admin methods send it as 'Authorization: Bearer <adminToken>'. Public reads (getGovernancePending/History/Blocks) do NOT need it.`
1628
+ );
1629
+ }
1630
+ return token;
1631
+ }
1632
+ /**
1633
+ * @internal
1634
+ * Effectue un appel admin authentifié (en-tête `Authorization: Bearer`).
1635
+ *
1636
+ * Les écritures admin ne sont PAS idempotentes — pas de retry automatique
1637
+ * (single-attempt via la branche non-idempotente de `fetchUrl`). L'auth est
1638
+ * injectée par appel (pas dans le bloc d'en-têtes partagé de `fetchOnce`)
1639
+ * pour éviter de fuiter le jeton admin vers des nœuds découverts/seeds lors
1640
+ * du racing — cf. `submitBlockRacing`. `method` ne sert qu'au libellé de
1641
+ * l'erreur "jeton manquant".
1642
+ */
1643
+ async fetchAdmin(method, path, init) {
1644
+ return this.fetchUrl(this.config.nodeUrl, path, this.withAdminAuth(method, init));
1645
+ }
1646
+ /**
1647
+ * @internal
1648
+ * Variante d'appel admin qui expose le STATUT HTTP en plus du corps parsé.
1649
+ * Nécessaire pour [`setConfig`], qui doit distinguer 200 (applied) de 202
1650
+ * (proposed) — deux corps de réponse différents pour deux issues. Délègue à
1651
+ * la même primitive `fetchOnceWithStatus` que tous les autres appels, donc
1652
+ * hérite du merge de signal et du tagging d'abort sans duplication.
1653
+ */
1654
+ async fetchAdminWithStatus(method, path, init) {
1655
+ const url = `${this.config.nodeUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
1656
+ const auth = this.withAdminAuth(method, init);
1657
+ return this.fetchOnceWithStatus(url, auth, this.config.timeout, auth.signal ?? void 0);
1658
+ }
1659
+ /**
1660
+ * @internal
1661
+ * Vérifie la présence du jeton admin (sinon erreur claire, sans réseau) et
1662
+ * injecte l'en-tête `Authorization: Bearer <token>` dans un RequestInit.
1663
+ */
1664
+ withAdminAuth(method, init) {
1665
+ const token = this.requireAdminToken(method);
1666
+ return {
1667
+ ...init,
1668
+ headers: {
1669
+ ...init?.headers,
1670
+ "Authorization": `Bearer ${token}`
1671
+ }
1672
+ };
1673
+ }
1230
1674
  async fetch(path, init, opts) {
1231
1675
  return this.fetchUrl(this.config.nodeUrl, path, init, opts);
1232
1676
  }
@@ -1234,8 +1678,26 @@ var PmsClient = class {
1234
1678
  * Single-attempt HTTP request. Throws a tagged error on transient conditions
1235
1679
  * (network failure, per-attempt abort, 5xx) so the retry loop can decide
1236
1680
  * whether to retry. 4xx and other non-2xx are thrown as fatal HttpError.
1681
+ *
1682
+ * Thin wrapper over `fetchOnceWithStatus` that discards the HTTP status —
1683
+ * every caller that only needs the parsed body goes through here.
1237
1684
  */
1238
1685
  async fetchOnce(url, init, attemptTimeoutMs, outerSignal) {
1686
+ return (await this.fetchOnceWithStatus(url, init, attemptTimeoutMs, outerSignal)).body;
1687
+ }
1688
+ /**
1689
+ * Single-attempt HTTP request returning BOTH the numeric HTTP status and the
1690
+ * parsed body. The shared fetch primitive: applies the default headers
1691
+ * (`Content-Type`, public `X-API-Key`), merges the caller's signal with the
1692
+ * per-attempt timeout, tags per-attempt aborts as `PerAttemptTimeoutError`
1693
+ * (so the retry loop can act on them), and constructs `HttpError` on non-2xx.
1694
+ *
1695
+ * NOTE: admin `Authorization: Bearer` is NOT injected here — it is added
1696
+ * per-admin-call by `withAdminAuth` so the admin token never leaks to
1697
+ * discovered/seed nodes during `submitBlockRacing`. Only the public,
1698
+ * scoped `X-API-Key` is broadcast.
1699
+ */
1700
+ async fetchOnceWithStatus(url, init, attemptTimeoutMs, outerSignal) {
1239
1701
  const attemptController = new AbortController();
1240
1702
  const attemptTimer = setTimeout(() => attemptController.abort(), attemptTimeoutMs);
1241
1703
  const signal = mergeSignalsAny(attemptController.signal, outerSignal);
@@ -1254,14 +1716,14 @@ var PmsClient = class {
1254
1716
  if (!res.ok) {
1255
1717
  const text2 = await res.text().catch(() => "");
1256
1718
  const retryAfter = res.headers?.get?.("Retry-After") ?? null;
1257
- throw new HttpError(res.status, `HTTP ${res.status} (${url}): ${text2}`, retryAfter);
1719
+ throw new HttpError(res.status, `HTTP ${res.status} (${url}): ${text2}`, retryAfter, text2);
1258
1720
  }
1259
1721
  const text = await res.text();
1260
1722
  if (!text) {
1261
- return {};
1723
+ return { status: res.status, body: {} };
1262
1724
  }
1263
1725
  try {
1264
- return JSON.parse(text);
1726
+ return { status: res.status, body: JSON.parse(text) };
1265
1727
  } catch {
1266
1728
  throw new Error(`Invalid JSON response: ${text.substring(0, 50)}...`);
1267
1729
  }
@@ -1324,11 +1786,30 @@ var PmsClient = class {
1324
1786
  var HttpError = class extends Error {
1325
1787
  status;
1326
1788
  retryAfter;
1327
- constructor(status, message, retryAfter) {
1789
+ /** Raw response body text (may be empty). */
1790
+ body;
1791
+ /** Stable numeric error code parsed from `{code,message}`, if present. */
1792
+ code;
1793
+ /** Engine-provided message from `{code,message}`, if present. */
1794
+ engineMessage;
1795
+ constructor(status, message, retryAfter, body = "") {
1328
1796
  super(message);
1329
1797
  this.name = "HttpError";
1330
1798
  this.status = status;
1331
1799
  this.retryAfter = retryAfter;
1800
+ this.body = body;
1801
+ if (body) {
1802
+ try {
1803
+ const parsed = JSON.parse(body);
1804
+ if (parsed && typeof parsed === "object" && typeof parsed.code === "number") {
1805
+ this.code = parsed.code;
1806
+ if (typeof parsed.message === "string") {
1807
+ this.engineMessage = parsed.message;
1808
+ }
1809
+ }
1810
+ } catch {
1811
+ }
1812
+ }
1332
1813
  }
1333
1814
  };
1334
1815
  var PerAttemptTimeoutError = class extends Error {
@@ -1396,12 +1877,21 @@ function mergeAbortSignals(a, b) {
1396
1877
  return controller.signal;
1397
1878
  }
1398
1879
  export {
1880
+ DAY_MS,
1881
+ ErrorCode,
1882
+ HttpError,
1399
1883
  PmsClient,
1400
1884
  PmsWallet,
1401
1885
  decryptPayload,
1886
+ effectiveValue,
1402
1887
  formatAmount,
1403
1888
  fromHex,
1889
+ hashlockHash,
1404
1890
  isValidMnemonic,
1891
+ makeUnlock,
1892
+ multisigAddress,
1405
1893
  parseAmount,
1406
- toHex
1894
+ spendableUtxos,
1895
+ toHex,
1896
+ txSigningMessage
1407
1897
  };