@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.cjs CHANGED
@@ -20,14 +20,23 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DAY_MS: () => DAY_MS,
24
+ ErrorCode: () => ErrorCode,
25
+ HttpError: () => HttpError,
23
26
  PmsClient: () => PmsClient,
24
27
  PmsWallet: () => PmsWallet,
25
28
  decryptPayload: () => decryptPayload,
29
+ effectiveValue: () => effectiveValue,
26
30
  formatAmount: () => formatAmount,
27
31
  fromHex: () => fromHex,
32
+ hashlockHash: () => hashlockHash,
28
33
  isValidMnemonic: () => isValidMnemonic,
34
+ makeUnlock: () => makeUnlock,
35
+ multisigAddress: () => multisigAddress,
29
36
  parseAmount: () => parseAmount,
30
- toHex: () => toHex
37
+ spendableUtxos: () => spendableUtxos,
38
+ toHex: () => toHex,
39
+ txSigningMessage: () => txSigningMessage
31
40
  });
32
41
  module.exports = __toCommonJS(index_exports);
33
42
 
@@ -42,6 +51,7 @@ var import_english = require("@scure/bip39/wordlists/english");
42
51
  // src/utils.ts
43
52
  var import_sha2 = require("@noble/hashes/sha2");
44
53
  var import_utils = require("@noble/hashes/utils");
54
+ var import_base = require("@scure/base");
45
55
  function sha256Hash(data) {
46
56
  return (0, import_sha2.sha256)(data);
47
57
  }
@@ -54,12 +64,103 @@ function fromHex(hex) {
54
64
  function encodeUtf8(str) {
55
65
  return new TextEncoder().encode(str);
56
66
  }
57
- function computeBlockId(parents, payloadJson, nonce) {
58
- const parentsStr = parents.sort().join(",");
59
- const payloadStr = payloadJson ?? "";
60
- const content = `${parentsStr}|${payloadStr}|${nonce}`;
61
- const hash = sha256Hash(encodeUtf8(content));
62
- return toHex(hash);
67
+ function toBase64(bytes) {
68
+ return import_base.base64.encode(bytes);
69
+ }
70
+ function txSigningMessage(networkId, inputs, outputs, fee) {
71
+ const canonInputs = inputs.map((i) => ({
72
+ out: { txid: i.out.txid, index: i.out.index }
73
+ }));
74
+ const canonOutputs = outputs.map((o) => {
75
+ const out = {
76
+ address: o.address,
77
+ amount: o.amount
78
+ };
79
+ if (o.asset_id !== void 0 && o.asset_id !== null) {
80
+ out.asset_id = o.asset_id;
81
+ }
82
+ if (o.locked_until !== void 0 && o.locked_until !== null) {
83
+ out.locked_until = o.locked_until;
84
+ }
85
+ if (o.spend_condition !== void 0 && o.spend_condition !== null) {
86
+ out.spend_condition = o.spend_condition;
87
+ }
88
+ if (o.created_at !== void 0 && o.created_at !== null) {
89
+ out.created_at = o.created_at;
90
+ }
91
+ return out;
92
+ });
93
+ const canon = {
94
+ network_id: networkId,
95
+ inputs: canonInputs,
96
+ outputs: canonOutputs,
97
+ fee
98
+ };
99
+ return toHex(sha256Hash(encodeUtf8(JSON.stringify(canon))));
100
+ }
101
+ var PLAIN_VARIANT_TO_PAYLOAD_TYPE = {
102
+ Genesis: "Genesis",
103
+ Mint: "Mint",
104
+ TxUtxo: "Transaction",
105
+ Milestone: "Milestone",
106
+ Nft: "Nft",
107
+ ConfigUpdate: "ConfigUpdate",
108
+ Reward: "Reward",
109
+ EncryptedReward: "EncryptedReward",
110
+ TokenCreate: "TokenCreate",
111
+ BridgeLock: "BridgeLock",
112
+ BridgeMint: "BridgeMint",
113
+ Freeze: "Freeze",
114
+ Unfreeze: "Unfreeze",
115
+ Seize: "Seize",
116
+ Reverse: "Reverse",
117
+ ContractRegister: "ContractRegister",
118
+ ContractUpdate: "ContractUpdate",
119
+ LedgerOwnershipTransfer: "LedgerOwnershipTransfer",
120
+ CoordinatorKeyRotate: "CoordinatorKeyRotate"
121
+ };
122
+ var EMPTY_COMMITMENT = "0000000000000000000000000000000000000000000000000000000000000000";
123
+ function computeBlockId(parents, payload, nonce) {
124
+ let payload_type;
125
+ let commitment;
126
+ let len_hint;
127
+ let key_version;
128
+ if (payload === void 0 || payload === null) {
129
+ payload_type = "None";
130
+ commitment = EMPTY_COMMITMENT;
131
+ len_hint = 0;
132
+ key_version = 0;
133
+ } else if ("Plain" in payload) {
134
+ const plain = payload.Plain;
135
+ const variant = typeof plain === "string" ? plain : Object.keys(plain)[0];
136
+ payload_type = PLAIN_VARIANT_TO_PAYLOAD_TYPE[variant] ?? variant;
137
+ const plainBytes = encodeUtf8(JSON.stringify(plain));
138
+ commitment = toHex(sha256Hash(plainBytes));
139
+ len_hint = plainBytes.length;
140
+ key_version = 0;
141
+ } else {
142
+ const enc = payload.Encrypted;
143
+ payload_type = "Encrypted";
144
+ commitment = enc.commitment;
145
+ len_hint = enc.ciphertext_b64.length;
146
+ key_version = enc.key_version;
147
+ }
148
+ const head = {
149
+ parents,
150
+ nonce,
151
+ envelope: { payload_type, commitment, len_hint, key_version }
152
+ };
153
+ return toHex(sha256Hash(encodeUtf8(JSON.stringify(head))));
154
+ }
155
+ function x25519FromAddress(address) {
156
+ try {
157
+ const decoded = import_base.bech32m.decode(address, false);
158
+ const bytes = import_base.bech32m.fromWords(decoded.words);
159
+ if (bytes.length !== 52) return void 0;
160
+ return toHex(bytes.slice(20));
161
+ } catch {
162
+ return void 0;
163
+ }
63
164
  }
64
165
  function parseAmount(amount) {
65
166
  const [whole, frac = ""] = amount.split(".");
@@ -72,6 +173,47 @@ function formatAmount(sats) {
72
173
  const fracStr = frac.toString().padStart(8, "0");
73
174
  return `${whole}.${fracStr}`;
74
175
  }
176
+ var DAY_MS = 864e5;
177
+ function multisigAddress(m, pubkeys) {
178
+ const normalized = pubkeys.map((pk) => {
179
+ const t = pk.trim();
180
+ return (t.startsWith("0x") ? t.slice(2) : t).toLowerCase();
181
+ }).sort();
182
+ const parts = [
183
+ encodeUtf8("pms-multisig-v1"),
184
+ new Uint8Array([m, normalized.length]),
185
+ ...normalized.map((pk) => encodeUtf8(pk))
186
+ ];
187
+ const total = parts.reduce((n, p) => n + p.length, 0);
188
+ const buf = new Uint8Array(total);
189
+ let off = 0;
190
+ for (const p of parts) {
191
+ buf.set(p, off);
192
+ off += p.length;
193
+ }
194
+ return `msig1${toHex(sha256Hash(buf).slice(0, 20))}`;
195
+ }
196
+ function hashlockHash(preimage) {
197
+ const bytes = typeof preimage === "string" ? encodeUtf8(preimage) : preimage;
198
+ return toHex(sha256Hash(bytes));
199
+ }
200
+ function effectiveValue(amount, createdAtMs, nowMs, bpsPerDay) {
201
+ if (createdAtMs === void 0 || createdAtMs === null || !bpsPerDay) {
202
+ return amount;
203
+ }
204
+ const elapsed = nowMs - createdAtMs;
205
+ const days = elapsed > 0 ? BigInt(Math.floor(elapsed / DAY_MS)) : 0n;
206
+ if (days === 0n) return amount;
207
+ const sats = parseAmount(amount);
208
+ const scaled = sats * 10000n - sats * BigInt(bpsPerDay) * days;
209
+ if (scaled <= 0n) return "0.00000000";
210
+ return formatAmount(scaled / 10000n);
211
+ }
212
+ function spendableUtxos(utxos, nowMs = Date.now()) {
213
+ return utxos.filter(
214
+ (u) => u.locked_until === void 0 || u.locked_until === null || u.locked_until <= nowMs
215
+ );
216
+ }
75
217
 
76
218
  // src/wallet.ts
77
219
  var PmsWallet = class _PmsWallet {
@@ -246,17 +388,57 @@ var PmsWallet = class _PmsWallet {
246
388
  function isValidMnemonic(mnemonic) {
247
389
  return (0, import_bip39.validateMnemonic)(mnemonic.trim().toLowerCase(), import_english.wordlist);
248
390
  }
391
+ function makeUnlock(signers, txHashHex, opts) {
392
+ if (signers.length === 0) {
393
+ throw new Error("makeUnlock: at least one signer is required");
394
+ }
395
+ const msg = encodeUtf8(txHashHex);
396
+ const sign = (w) => toBase64(fromHex(w.sign(msg)));
397
+ const [primary, ...rest] = signers;
398
+ const unlock = {
399
+ pubkey_hex: primary.publicKeyHex,
400
+ signature_b64: sign(primary)
401
+ };
402
+ if (rest.length > 0) {
403
+ unlock.cosigners = rest.map((w) => ({
404
+ pubkey_hex: w.publicKeyHex,
405
+ signature_b64: sign(w)
406
+ }));
407
+ }
408
+ if (opts?.preimageHex !== void 0) {
409
+ unlock.preimage_hex = opts.preimageHex;
410
+ }
411
+ return unlock;
412
+ }
249
413
 
250
414
  // src/types.ts
251
415
  var DEFAULT_CONFIG = {
252
416
  networkId: "pms-mainnet",
253
- protocolVersion: 1,
417
+ protocolVersion: 3,
254
418
  timeout: 3e4,
255
419
  seedNodes: [],
256
420
  enableRacing: true,
257
421
  retries: 2,
258
422
  perAttemptTimeoutMs: 4e3,
259
- retryBaseDelayMs: 100
423
+ retryBaseDelayMs: 100,
424
+ // Vide par défaut : les méthodes admin lèvent une erreur explicite si
425
+ // appelées sans jeton (voir PmsClient.requireAdminToken).
426
+ adminToken: ""
427
+ };
428
+ var ErrorCode = {
429
+ /** En-tête d'authentification manquant (admin requis). */
430
+ MissingAuth: 1001,
431
+ /** Jeton d'authentification invalide. */
432
+ InvalidAuth: 1002,
433
+ /**
434
+ * Proposition de gouvernance rejetée (timelock non écoulé, proposition
435
+ * non en attente, ou identifiant inconnu). HTTP 409.
436
+ */
437
+ GovernanceRejected: 3071,
438
+ /** Émission native (mint) désactivée. HTTP 503. */
439
+ MintDisabled: 5031,
440
+ /** Erreur interne générique (dette : handler non encore migré). */
441
+ Internal: 9999
260
442
  };
261
443
 
262
444
  // src/crypto.ts
@@ -265,13 +447,13 @@ var import_aes = require("@noble/ciphers/aes.js");
265
447
  var import_hkdf2 = require("@noble/hashes/hkdf");
266
448
  var import_sha23 = require("@noble/hashes/sha2");
267
449
  var import_utils3 = require("@noble/hashes/utils");
268
- var import_base = require("@scure/base");
450
+ var import_base2 = require("@scure/base");
269
451
  var SCHEME = "x25519+aes256gcm";
270
452
  var HKDF_SALT = new TextEncoder().encode("pms-dek-wrap");
271
453
  var HKDF_INFO_KEK = new TextEncoder().encode("kek-v1");
272
454
  var HKDF_INFO_KID = new TextEncoder().encode("kid-v1");
273
455
  function fromBase64(str) {
274
- return import_base.base64.decode(str);
456
+ return import_base2.base64.decode(str);
275
457
  }
276
458
  function sha256Hex(data) {
277
459
  return (0, import_utils3.bytesToHex)((0, import_sha23.sha256)(data));
@@ -436,11 +618,222 @@ var PmsClient = class {
436
618
  }
437
619
  /**
438
620
  * Récupère les UTXOs d'une adresse.
621
+ *
622
+ * Depuis dag-pms v0.10.0, chaque UTXO expose aussi ses contraintes de
623
+ * dépense : `locked_until` (time-lock), `spend_condition`
624
+ * (MultiSig/HashLock) et `created_at` (base du demurrage). Utiliser
625
+ * [`spendableUtxos`] pour exclure les UTXOs encore verrouillés avant
626
+ * toute sélection de coins côté client.
439
627
  */
440
628
  async getUtxos(address) {
441
629
  const res = await this.fetch(`/v1/wallet/${address}/utxos`, void 0, { idempotent: true });
442
630
  return res.utxos ?? [];
443
631
  }
632
+ /**
633
+ * Dernier snapshot de preuve de réserves ancré (protocole 2.6,
634
+ * `GET /v1/reserves/latest`). 404 si aucun snapshot n'a encore été ancré.
635
+ */
636
+ async getLatestReserves() {
637
+ return this.fetch("/v1/reserves/latest", void 0, { idempotent: true });
638
+ }
639
+ // ═══════════════════════════════════════════════════════════════════════
640
+ // Gouvernance — lectures publiques (GET, sans authentification)
641
+ // ═══════════════════════════════════════════════════════════════════════
642
+ /**
643
+ * Liste les propositions de gouvernance EN ATTENTE (statut `pending`).
644
+ *
645
+ * Lecture publique — aucun `adminToken` requis.
646
+ *
647
+ * @returns Le tableau des propositions en attente (vide si aucune).
648
+ *
649
+ * @example
650
+ * ```typescript
651
+ * const pending = await client.getGovernancePending();
652
+ * for (const p of pending) {
653
+ * console.log(`${p.proposal_id} enacts after ${new Date(p.enact_after_ms)}`);
654
+ * }
655
+ * ```
656
+ */
657
+ async getGovernancePending() {
658
+ const res = await this.fetch(
659
+ "/v1/governance/pending",
660
+ void 0,
661
+ { idempotent: true }
662
+ );
663
+ return res.pending ?? [];
664
+ }
665
+ /**
666
+ * Liste les propositions de gouvernance TERMINÉES (enactées ou annulées).
667
+ *
668
+ * Lecture publique — aucun `adminToken` requis.
669
+ *
670
+ * @returns Le tableau de l'historique des propositions (vide si aucune).
671
+ */
672
+ async getGovernanceHistory() {
673
+ const res = await this.fetch(
674
+ "/v1/governance/history",
675
+ void 0,
676
+ { idempotent: true }
677
+ );
678
+ return res.history ?? [];
679
+ }
680
+ /**
681
+ * Liste les blocs de gouvernance ancrés dans le DAG (un par proposition,
682
+ * enactment, ou annulation).
683
+ *
684
+ * Lecture publique — aucun `adminToken` requis.
685
+ *
686
+ * @returns `{ count, blocks }` — le nombre et la liste des blocs.
687
+ */
688
+ async getGovernanceBlocks() {
689
+ return this.fetch(
690
+ "/v1/governance/blocks",
691
+ void 0,
692
+ { idempotent: true }
693
+ );
694
+ }
695
+ // ═══════════════════════════════════════════════════════════════════════
696
+ // Gouvernance — actions ADMIN (POST, requièrent adminToken)
697
+ // ═══════════════════════════════════════════════════════════════════════
698
+ /**
699
+ * Soumet une proposition de gouvernance ancrée dans le DAG.
700
+ *
701
+ * Méthode ADMIN — requiert `adminToken` dans la config du client
702
+ * (envoyé en `Authorization: Bearer <adminToken>`). Lève une erreur
703
+ * explicite avant tout appel réseau si le jeton est absent.
704
+ *
705
+ * @param req.update - Mise à jour de config à proposer.
706
+ * @param req.tier - Palier de gouvernance (minuscules, voir
707
+ * [`GovernanceTier`]).
708
+ * @param req.reason - Raison libre (optionnelle).
709
+ * @returns `{ status, proposal_id, block_id, tier, announced_at_ms,
710
+ * enact_after_ms }`.
711
+ *
712
+ * @example
713
+ * ```typescript
714
+ * const res = await client.proposeGovernance({
715
+ * update: { SetEmissionCorridor: { ceiling_bps: 1500, floor_bps: 0, target_bps: 1000, epoch_duration_sec: 86400 } },
716
+ * tier: "constitution",
717
+ * reason: "raise emission ceiling for Q3",
718
+ * });
719
+ * console.log(res.proposal_id, "enacts after", new Date(res.enact_after_ms));
720
+ * ```
721
+ */
722
+ async proposeGovernance(req) {
723
+ return this.fetchAdmin("proposeGovernance", "/admin/governance/propose", {
724
+ method: "POST",
725
+ body: JSON.stringify({
726
+ update: req.update,
727
+ tier: req.tier,
728
+ reason: req.reason ?? ""
729
+ })
730
+ });
731
+ }
732
+ /**
733
+ * Enacte une proposition de gouvernance dont le timelock est écoulé.
734
+ *
735
+ * Méthode ADMIN — requiert `adminToken`. Le moteur rejette (HTTP 409,
736
+ * code [`ErrorCode.GovernanceRejected`] = 3071) si le timelock n'est pas
737
+ * écoulé, si la proposition n'est pas en attente, ou si l'identifiant est
738
+ * inconnu.
739
+ *
740
+ * @param proposalId - Identifiant de la proposition à enacter.
741
+ * @param reason - Raison libre (optionnelle).
742
+ * @returns `{ status, proposal_id, block_id }`.
743
+ */
744
+ async enactProposal(proposalId, reason) {
745
+ return this.fetchAdmin(
746
+ "enactProposal",
747
+ `/admin/governance/enact/${encodeURIComponent(proposalId)}`,
748
+ {
749
+ method: "POST",
750
+ body: JSON.stringify({ reason: reason ?? "" })
751
+ }
752
+ );
753
+ }
754
+ /**
755
+ * Annule une proposition de gouvernance en attente.
756
+ *
757
+ * Méthode ADMIN — requiert `adminToken`. Rejet HTTP 409 / code 3071 si la
758
+ * proposition n'est pas en attente ou si l'identifiant est inconnu.
759
+ *
760
+ * @param proposalId - Identifiant de la proposition à annuler.
761
+ * @param reason - Raison libre (optionnelle).
762
+ * @returns `{ status, proposal_id, block_id }`.
763
+ */
764
+ async cancelProposal(proposalId, reason) {
765
+ return this.fetchAdmin(
766
+ "cancelProposal",
767
+ `/admin/governance/cancel/${encodeURIComponent(proposalId)}`,
768
+ {
769
+ method: "POST",
770
+ body: JSON.stringify({ reason: reason ?? "" })
771
+ }
772
+ );
773
+ }
774
+ /**
775
+ * Applique (ou propose) une mise à jour de configuration.
776
+ *
777
+ * Méthode ADMIN — requiert `adminToken`. Le moteur distingue DEUX issues
778
+ * selon le sens du changement, et le SDK les EXPOSE explicitement via une
779
+ * union discriminée sur `applied` — un 202 n'est JAMAIS traité comme un
780
+ * succès appliqué :
781
+ * - **HTTP 200** (durcissement) → `{ applied: true, ... }` : la mise à
782
+ * jour est instantanément en vigueur.
783
+ * - **HTTP 202** (assouplissement) → `{ applied: false, proposalId,
784
+ * enactAfterMs, ... }` : la mise à jour est placée sous timelock de
785
+ * gouvernance, PAS encore appliquée. Elle s'auto-enacte à l'expiration
786
+ * du timelock, ou via [`enactProposal`].
787
+ *
788
+ * @param update - Mise à jour de config (envoyée brute, sans wrapper).
789
+ * @returns Une [`ConfigChangeResult`] discriminée sur `applied`.
790
+ *
791
+ * @example
792
+ * ```typescript
793
+ * const r = await client.setConfig({ SetMintEnabled: { enabled: false } });
794
+ * if (r.applied) {
795
+ * console.log("appliqué instantanément:", r.updateApplied);
796
+ * } else {
797
+ * console.log("timelocké, enact après", new Date(r.enactAfterMs));
798
+ * }
799
+ * ```
800
+ */
801
+ async setConfig(update) {
802
+ const { status, body } = await this.fetchAdminWithStatus(
803
+ "setConfig",
804
+ "/admin/config",
805
+ {
806
+ method: "POST",
807
+ body: JSON.stringify(update)
808
+ }
809
+ );
810
+ const isApplied = status === 200 && body.status !== "proposed";
811
+ if (isApplied) {
812
+ return {
813
+ applied: true,
814
+ status: "applied",
815
+ mode: body.mode ?? "",
816
+ updateApplied: body.update_applied ?? "",
817
+ tier: body.tier ?? "",
818
+ proposalId: body.proposal_id ?? "",
819
+ proposalBlockId: body.proposal_block_id ?? "",
820
+ enactBlockId: body.enact_block_id ?? "",
821
+ config: body.config ?? null
822
+ };
823
+ }
824
+ return {
825
+ applied: false,
826
+ status: "proposed",
827
+ mode: body.mode ?? "",
828
+ update: body.update ?? "",
829
+ tier: body.tier ?? "",
830
+ proposalId: body.proposal_id ?? "",
831
+ proposalBlockId: body.proposal_block_id ?? "",
832
+ announcedAtMs: body.announced_at_ms ?? 0,
833
+ enactAfterMs: body.enact_after_ms ?? 0,
834
+ message: body.message ?? ""
835
+ };
836
+ }
444
837
  // ═══════════════════════════════════════════════════════════════════════
445
838
  // Wallet API (Custodial — Server-Side)
446
839
  // ═══════════════════════════════════════════════════════════════════════
@@ -897,101 +1290,83 @@ var PmsClient = class {
897
1290
  }
898
1291
  /**
899
1292
  * Envoie des tokens à une adresse.
900
- * Construit automatiquement la transaction, la signe et la soumet.
1293
+ *
1294
+ * Flux aligné sur dag-pms v0.9.0 (single-writer + vérification des
1295
+ * signatures de transaction par l'engine) :
1296
+ * 1. `POST /v1/tx/prepare` — le serveur sélectionne les UTXOs et calcule
1297
+ * les frais.
1298
+ * 2. Vérification défensive côté client : l'output destinataire demandé
1299
+ * existe bien, et le `tx_hash` retourné correspond au message canonique
1300
+ * recalculé localement (`txSigningMessage`). On ne signe JAMAIS un hash
1301
+ * opaque non vérifié.
1302
+ * 3. Le wallet signe la TRANSACTION (unlocks) — pas le bloc. C'est le
1303
+ * serveur qui forge et signe le bloc (single-writer enforcement).
1304
+ * 4. `POST /v1/wallet/tx/send` avec la TX signée + les clés X25519 des
1305
+ * destinataires pour le chiffrement du payload.
1306
+ *
1307
+ * @param params.to - Adresse destinataire (Bech32m de préférence)
1308
+ * @param params.amount - Montant décimal (ex: "10.0")
1309
+ * @param params.wallet - Wallet signataire (propriétaire des UTXOs)
1310
+ * @param params.assetId - Asset ID optionnel (undefined = PMS natif)
1311
+ * @returns `{id, status, block_id}` — id du bloc forgé par le serveur
901
1312
  */
902
1313
  async send(params) {
903
- const { to, amount, wallet, memo: _memo } = params;
904
- const utxos = await this.getUtxos(wallet.address);
905
- if (utxos.length === 0) {
906
- throw new Error("No UTXOs available");
907
- }
908
- const netConfig = await this.getNetworkConfig();
909
- const baseFeeSats = parseAmount(netConfig.base_fee || "0");
910
- const feeRateBps = BigInt(netConfig.fee_rate_bps || 0);
911
- const amountSats = parseAmount(amount);
912
- const variableFee = amountSats * feeRateBps / 10000n;
913
- const fee = baseFeeSats + variableFee;
914
- const totalNeeded = amountSats + fee;
915
- let selectedSats = 0n;
916
- const selectedUtxos = [];
917
- for (const utxo of utxos) {
918
- selectedUtxos.push(utxo);
919
- selectedSats += parseAmount(utxo.amount || "0");
920
- if (selectedSats >= totalNeeded) break;
1314
+ const { to, amount, wallet, assetId } = params;
1315
+ const prepareBody = {
1316
+ from: wallet.address,
1317
+ to,
1318
+ amount
1319
+ };
1320
+ if (assetId !== void 0) {
1321
+ prepareBody.asset_id = assetId;
921
1322
  }
922
- if (selectedSats < totalNeeded) {
1323
+ const prepared = await this.prepareTx(prepareBody);
1324
+ const { unsigned_tx, tx_hash } = prepared;
1325
+ const requestedSats = parseAmount(amount);
1326
+ const recipientOutput = unsigned_tx.outputs.find(
1327
+ (o) => o.address === to && (o.asset_id ?? void 0) === (assetId ?? void 0) && parseAmount(o.amount) >= requestedSats
1328
+ );
1329
+ if (!recipientOutput) {
923
1330
  throw new Error(
924
- `Insufficient balance: have ${formatAmount(selectedSats)}, need ${formatAmount(totalNeeded)} (incl. fee ${formatAmount(fee)})`
1331
+ `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.`
925
1332
  );
926
1333
  }
927
- const outputs = [
928
- { address: to, amount: formatAmount(amountSats) }
929
- ];
930
- if (fee > 0n) {
931
- outputs.push({
932
- address: netConfig.fee_recipient,
933
- amount: formatAmount(fee)
934
- });
935
- }
936
- const change = selectedSats - amountSats - fee;
937
- if (change > 0n) {
938
- outputs.push({ address: wallet.address, amount: formatAmount(change) });
1334
+ const localHash = txSigningMessage(
1335
+ this.config.networkId,
1336
+ unsigned_tx.inputs,
1337
+ unsigned_tx.outputs,
1338
+ unsigned_tx.fee
1339
+ );
1340
+ if (localHash !== tx_hash) {
1341
+ throw new Error(
1342
+ `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.`
1343
+ );
939
1344
  }
940
- const inputs = selectedUtxos.map((utxo) => ({
941
- out: {
942
- txid: utxo.outpoint.txid,
943
- index: utxo.outpoint.index
944
- }
945
- }));
946
- const txCanonical = {
947
- inputs,
948
- outputs,
949
- fee: formatAmount(fee)
950
- };
951
- const txMessage = JSON.stringify(txCanonical);
952
- const txSignatureHex = wallet.sign(encodeUtf8(txMessage));
953
- const txSigBytes = fromHex(txSignatureHex);
954
- const txSigB64 = btoa(String.fromCharCode(...txSigBytes));
955
- const unlocks = inputs.map(() => ({
1345
+ const sigHex = wallet.sign(encodeUtf8(tx_hash));
1346
+ const sigB64 = toBase64(fromHex(sigHex));
1347
+ const unlocks = unsigned_tx.inputs.map(() => ({
956
1348
  pubkey_hex: wallet.publicKeyHex,
957
- signature_b64: txSigB64
1349
+ signature_b64: sigB64
958
1350
  }));
959
1351
  const tx = {
960
- inputs,
961
- outputs,
962
- fee: formatAmount(fee),
1352
+ inputs: unsigned_tx.inputs,
1353
+ outputs: unsigned_tx.outputs,
1354
+ fee: unsigned_tx.fee,
963
1355
  unlocks
964
1356
  };
965
- const tips = await this.getTips();
966
- const parents = tips.slice(0, 2);
967
- const payload = { Plain: { TxUtxo: tx } };
968
- const payloadJson = JSON.stringify(payload);
969
- let nonce = 0;
970
- let blockId = computeBlockId(parents, payloadJson, nonce);
971
- const canonicalView = {
972
- id: blockId,
973
- parents,
974
- payload_json: payloadJson,
975
- nonce,
976
- network_id: this.config.networkId,
977
- protocol_version: this.config.protocolVersion,
978
- signer_pk_hex: wallet.publicKeyHex
979
- };
980
- const messageToSign = JSON.stringify(canonicalView);
981
- const signatureHex = wallet.sign(encodeUtf8(messageToSign));
982
- const signatureBytes = fromHex(signatureHex);
983
- const signatureB64 = btoa(String.fromCharCode(...signatureBytes));
984
- const wireBlock = {
985
- id: blockId,
986
- parents,
987
- payload_json: payloadJson,
988
- nonce,
989
- network_id: this.config.networkId,
990
- protocol_version: this.config.protocolVersion,
991
- signer_pk_hex: wallet.publicKeyHex,
992
- signature_hex: signatureB64
993
- };
994
- return this.submitBlock(wireBlock);
1357
+ const recipientsXpk = [wallet.x25519PublicKeyHex];
1358
+ const toXpk = x25519FromAddress(to);
1359
+ if (toXpk && !recipientsXpk.includes(toXpk)) {
1360
+ recipientsXpk.push(toXpk);
1361
+ }
1362
+ const resp = await this.fetch(
1363
+ "/v1/wallet/tx/send",
1364
+ {
1365
+ method: "POST",
1366
+ body: JSON.stringify({ tx, recipients_xpk: recipientsXpk })
1367
+ }
1368
+ );
1369
+ return { ...resp, block_id: resp.id };
995
1370
  }
996
1371
  // ═══════════════════════════════════════════════════════════════════════
997
1372
  // Méthodes NFT Cube (Burn et Mint spécialisé)
@@ -999,7 +1374,15 @@ var PmsClient = class {
999
1374
  /**
1000
1375
  * Transférer un NFT à un autre propriétaire.
1001
1376
  * Prend en charge le re-chiffrement des métadonnées via le coordinateur.
1002
- *
1377
+ *
1378
+ * @deprecated Cette méthode soumet un WireBlock signé par la clé
1379
+ * utilisateur via `/submit/block`. Depuis dag-pms v0.9.0, l'engine tourne
1380
+ * en mode **single-writer** : tout bloc signé par une clé non-coordinateur
1381
+ * est rejeté. Cette méthode ne fonctionne que sur un réseau SANS
1382
+ * single-writer enforcement. Utilisez les flux server-side
1383
+ * (`/v1/nft/transfer/prepare` + endpoints serveur) qui forgent et signent
1384
+ * le bloc côté coordinateur.
1385
+ *
1003
1386
  * @param params - Paramètres du transfert
1004
1387
  * @param params.tokenId - Identifiant du NFT
1005
1388
  * @param params.to - Adresse du nouveau propriétaire
@@ -1035,7 +1418,7 @@ var PmsClient = class {
1035
1418
  const payload = { Plain: { Nft: action } };
1036
1419
  const payloadJson = JSON.stringify(payload);
1037
1420
  const nonce = 0;
1038
- const blockId = computeBlockId(parents, payloadJson, nonce);
1421
+ const blockId = computeBlockId(parents, payload, nonce);
1039
1422
  const canonicalView = {
1040
1423
  id: blockId,
1041
1424
  parents,
@@ -1067,10 +1450,17 @@ var PmsClient = class {
1067
1450
  * Seul le propriétaire du NFT peut le brûler.
1068
1451
  * Une fois brûlé, le NFT est supprimé définitivement.
1069
1452
  *
1070
- * Pour les Cubes authentiques (avec signature Authority valide),
1453
+ * Pour les Cubes authentiques (avec signature Authority valide),
1071
1454
  * un remboursement est calculé selon la formule:
1072
1455
  * `(weight * size * density) / 10000` PMS
1073
- *
1456
+ *
1457
+ * @deprecated Cette méthode poste un WireBlock signé par la clé
1458
+ * utilisateur. Depuis dag-pms v0.9.0, l'engine tourne en mode
1459
+ * **single-writer** : tout bloc signé par une clé non-coordinateur est
1460
+ * rejeté. Cette méthode ne fonctionne que sur un réseau SANS single-writer
1461
+ * enforcement. Le flux supporté est le burn server-side
1462
+ * (`/v1/nft/burn-simple`), où le serveur forge et signe le bloc.
1463
+ *
1074
1464
  * @param params - Paramètres du burn
1075
1465
  * @param params.tokenId - Identifiant du NFT à brûler
1076
1466
  * @param params.wallet - Wallet PMS du propriétaire (doit être l'owner actuel)
@@ -1104,7 +1494,7 @@ var PmsClient = class {
1104
1494
  };
1105
1495
  const payloadJson = JSON.stringify(payload);
1106
1496
  const nonce = 0;
1107
- const blockId = computeBlockId(parents, payloadJson, nonce);
1497
+ const blockId = computeBlockId(parents, payload, nonce);
1108
1498
  const canonicalView = {
1109
1499
  id: blockId,
1110
1500
  parents,
@@ -1136,7 +1526,14 @@ var PmsClient = class {
1136
1526
  }
1137
1527
  /**
1138
1528
  * Brûle (détruit) plusieurs NFTs en une seule transaction.
1139
- *
1529
+ *
1530
+ * @deprecated Cette méthode poste un WireBlock signé par la clé
1531
+ * utilisateur. Depuis dag-pms v0.9.0, l'engine tourne en mode
1532
+ * **single-writer** : tout bloc signé par une clé non-coordinateur est
1533
+ * rejeté. Cette méthode ne fonctionne que sur un réseau SANS single-writer
1534
+ * enforcement. Le flux supporté est le burn server-side
1535
+ * (`/v1/nft/burn-simple`), où le serveur forge et signe le bloc.
1536
+ *
1140
1537
  * @param params - Paramètres du batch burn
1141
1538
  * @param params.tokenIds - Liste des Identifiants des NFTs à brûler
1142
1539
  * @param params.wallet - Wallet PMS du propriétaire
@@ -1161,7 +1558,7 @@ var PmsClient = class {
1161
1558
  };
1162
1559
  const payloadJson = JSON.stringify(payload);
1163
1560
  const nonce = 0;
1164
- const blockId = computeBlockId(parents, payloadJson, nonce);
1561
+ const blockId = computeBlockId(parents, payload, nonce);
1165
1562
  const canonicalView = {
1166
1563
  id: blockId,
1167
1564
  parents,
@@ -1260,6 +1657,62 @@ var PmsClient = class {
1260
1657
  // ═══════════════════════════════════════════════════════════════════════
1261
1658
  // Helper HTTP
1262
1659
  // ═══════════════════════════════════════════════════════════════════════
1660
+ /**
1661
+ * @internal
1662
+ * Garantit qu'un `adminToken` est configuré avant un appel admin.
1663
+ * Lève une erreur claire (sans appel réseau) sinon.
1664
+ */
1665
+ requireAdminToken(method) {
1666
+ const token = this.config.adminToken;
1667
+ if (!token || token.trim() === "") {
1668
+ throw new Error(
1669
+ `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.`
1670
+ );
1671
+ }
1672
+ return token;
1673
+ }
1674
+ /**
1675
+ * @internal
1676
+ * Effectue un appel admin authentifié (en-tête `Authorization: Bearer`).
1677
+ *
1678
+ * Les écritures admin ne sont PAS idempotentes — pas de retry automatique
1679
+ * (single-attempt via la branche non-idempotente de `fetchUrl`). L'auth est
1680
+ * injectée par appel (pas dans le bloc d'en-têtes partagé de `fetchOnce`)
1681
+ * pour éviter de fuiter le jeton admin vers des nœuds découverts/seeds lors
1682
+ * du racing — cf. `submitBlockRacing`. `method` ne sert qu'au libellé de
1683
+ * l'erreur "jeton manquant".
1684
+ */
1685
+ async fetchAdmin(method, path, init) {
1686
+ return this.fetchUrl(this.config.nodeUrl, path, this.withAdminAuth(method, init));
1687
+ }
1688
+ /**
1689
+ * @internal
1690
+ * Variante d'appel admin qui expose le STATUT HTTP en plus du corps parsé.
1691
+ * Nécessaire pour [`setConfig`], qui doit distinguer 200 (applied) de 202
1692
+ * (proposed) — deux corps de réponse différents pour deux issues. Délègue à
1693
+ * la même primitive `fetchOnceWithStatus` que tous les autres appels, donc
1694
+ * hérite du merge de signal et du tagging d'abort sans duplication.
1695
+ */
1696
+ async fetchAdminWithStatus(method, path, init) {
1697
+ const url = `${this.config.nodeUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
1698
+ const auth = this.withAdminAuth(method, init);
1699
+ return this.fetchOnceWithStatus(url, auth, this.config.timeout, auth.signal ?? void 0);
1700
+ }
1701
+ /**
1702
+ * @internal
1703
+ * Vérifie la présence du jeton admin (sinon erreur claire, sans réseau) et
1704
+ * injecte l'en-tête `Authorization: Bearer <token>` dans un RequestInit.
1705
+ */
1706
+ withAdminAuth(method, init) {
1707
+ const token = this.requireAdminToken(method);
1708
+ return {
1709
+ ...init,
1710
+ headers: {
1711
+ ...init?.headers,
1712
+ "Authorization": `Bearer ${token}`
1713
+ }
1714
+ };
1715
+ }
1263
1716
  async fetch(path, init, opts) {
1264
1717
  return this.fetchUrl(this.config.nodeUrl, path, init, opts);
1265
1718
  }
@@ -1267,8 +1720,26 @@ var PmsClient = class {
1267
1720
  * Single-attempt HTTP request. Throws a tagged error on transient conditions
1268
1721
  * (network failure, per-attempt abort, 5xx) so the retry loop can decide
1269
1722
  * whether to retry. 4xx and other non-2xx are thrown as fatal HttpError.
1723
+ *
1724
+ * Thin wrapper over `fetchOnceWithStatus` that discards the HTTP status —
1725
+ * every caller that only needs the parsed body goes through here.
1270
1726
  */
1271
1727
  async fetchOnce(url, init, attemptTimeoutMs, outerSignal) {
1728
+ return (await this.fetchOnceWithStatus(url, init, attemptTimeoutMs, outerSignal)).body;
1729
+ }
1730
+ /**
1731
+ * Single-attempt HTTP request returning BOTH the numeric HTTP status and the
1732
+ * parsed body. The shared fetch primitive: applies the default headers
1733
+ * (`Content-Type`, public `X-API-Key`), merges the caller's signal with the
1734
+ * per-attempt timeout, tags per-attempt aborts as `PerAttemptTimeoutError`
1735
+ * (so the retry loop can act on them), and constructs `HttpError` on non-2xx.
1736
+ *
1737
+ * NOTE: admin `Authorization: Bearer` is NOT injected here — it is added
1738
+ * per-admin-call by `withAdminAuth` so the admin token never leaks to
1739
+ * discovered/seed nodes during `submitBlockRacing`. Only the public,
1740
+ * scoped `X-API-Key` is broadcast.
1741
+ */
1742
+ async fetchOnceWithStatus(url, init, attemptTimeoutMs, outerSignal) {
1272
1743
  const attemptController = new AbortController();
1273
1744
  const attemptTimer = setTimeout(() => attemptController.abort(), attemptTimeoutMs);
1274
1745
  const signal = mergeSignalsAny(attemptController.signal, outerSignal);
@@ -1287,14 +1758,14 @@ var PmsClient = class {
1287
1758
  if (!res.ok) {
1288
1759
  const text2 = await res.text().catch(() => "");
1289
1760
  const retryAfter = res.headers?.get?.("Retry-After") ?? null;
1290
- throw new HttpError(res.status, `HTTP ${res.status} (${url}): ${text2}`, retryAfter);
1761
+ throw new HttpError(res.status, `HTTP ${res.status} (${url}): ${text2}`, retryAfter, text2);
1291
1762
  }
1292
1763
  const text = await res.text();
1293
1764
  if (!text) {
1294
- return {};
1765
+ return { status: res.status, body: {} };
1295
1766
  }
1296
1767
  try {
1297
- return JSON.parse(text);
1768
+ return { status: res.status, body: JSON.parse(text) };
1298
1769
  } catch {
1299
1770
  throw new Error(`Invalid JSON response: ${text.substring(0, 50)}...`);
1300
1771
  }
@@ -1357,11 +1828,30 @@ var PmsClient = class {
1357
1828
  var HttpError = class extends Error {
1358
1829
  status;
1359
1830
  retryAfter;
1360
- constructor(status, message, retryAfter) {
1831
+ /** Raw response body text (may be empty). */
1832
+ body;
1833
+ /** Stable numeric error code parsed from `{code,message}`, if present. */
1834
+ code;
1835
+ /** Engine-provided message from `{code,message}`, if present. */
1836
+ engineMessage;
1837
+ constructor(status, message, retryAfter, body = "") {
1361
1838
  super(message);
1362
1839
  this.name = "HttpError";
1363
1840
  this.status = status;
1364
1841
  this.retryAfter = retryAfter;
1842
+ this.body = body;
1843
+ if (body) {
1844
+ try {
1845
+ const parsed = JSON.parse(body);
1846
+ if (parsed && typeof parsed === "object" && typeof parsed.code === "number") {
1847
+ this.code = parsed.code;
1848
+ if (typeof parsed.message === "string") {
1849
+ this.engineMessage = parsed.message;
1850
+ }
1851
+ }
1852
+ } catch {
1853
+ }
1854
+ }
1365
1855
  }
1366
1856
  };
1367
1857
  var PerAttemptTimeoutError = class extends Error {
@@ -1430,12 +1920,21 @@ function mergeAbortSignals(a, b) {
1430
1920
  }
1431
1921
  // Annotate the CommonJS export names for ESM import in node:
1432
1922
  0 && (module.exports = {
1923
+ DAY_MS,
1924
+ ErrorCode,
1925
+ HttpError,
1433
1926
  PmsClient,
1434
1927
  PmsWallet,
1435
1928
  decryptPayload,
1929
+ effectiveValue,
1436
1930
  formatAmount,
1437
1931
  fromHex,
1932
+ hashlockHash,
1438
1933
  isValidMnemonic,
1934
+ makeUnlock,
1935
+ multisigAddress,
1439
1936
  parseAmount,
1440
- toHex
1937
+ spendableUtxos,
1938
+ toHex,
1939
+ txSigningMessage
1441
1940
  });