@chainvue/verus-sdk 0.7.0 → 0.9.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.
@@ -47,6 +47,7 @@ const index_js_1 = require("../constants/index.js");
47
47
  const index_js_2 = require("../utils/index.js");
48
48
  const index_js_3 = require("../signing/index.js");
49
49
  const index_js_4 = require("../utxo/index.js");
50
+ const brands_js_1 = require("../core/brands.js");
50
51
  const errors_js_1 = require("../errors.js");
51
52
  const index_js_5 = require("../keys/index.js");
52
53
  const { createUnfundedIdentityUpdate, completeFundedIdentityUpdate } = utxo_lib_1.smarttxs;
@@ -56,6 +57,10 @@ const HASH256_BYTE_LENGTH = 32;
56
57
  const NULL_ID_HASH = Buffer.alloc(HASH160_BYTE_LENGTH, 0);
57
58
  /** Eval code for CAdvancedNameReservation (sub-ID) */
58
59
  const EVAL_IDENTITY_ADVANCEDRESERVATION = 10;
60
+ /** Consensus maximum identity unlock delay (blocks) — CIdentity::MAX_UNLOCK_DELAY, ~22 years. */
61
+ const MAX_UNLOCK_DELAY = 11563200;
62
+ /** ~1 year of blocks (1 block/min); a lock delay above this needs an explicit opt-in. */
63
+ const LOCK_DELAY_SANITY_BLOCKS = 525600;
59
64
  // ─── Script / Hash Builders ────────────────────────────────────────
60
65
  /**
61
66
  * Assert a base58check address has the expected version byte.
@@ -214,6 +219,14 @@ function buildIdentityScript(identity) {
214
219
  * Derive the identity i-address from a name and optional parent
215
220
  */
216
221
  function deriveIdentityAddress(name, parentIAddress) {
222
+ // nameAndParentAddrToIAddr hashes fromBase58Check(parent).hash regardless of
223
+ // version, so an R-address parent is laundered into an identity-versioned hash
224
+ // that names no real currency. The derived i-address then looks valid but can
225
+ // never be registered — funds paid to it (pay-to-unregistered-identity) burn,
226
+ // and a name commitment built with it wastes its fee. Require an i-address.
227
+ if (parentIAddress) {
228
+ assertAddressVersion(parentIAddress, index_js_1.I_ADDR_VERSION, 'parent');
229
+ }
217
230
  return (0, verus_typescript_primitives_3.nameAndParentAddrToIAddr)(name, parentIAddress || undefined);
218
231
  }
219
232
  /**
@@ -228,14 +241,29 @@ function isVRSCParent(parentIAddress, network = 'mainnet') {
228
241
  /**
229
242
  * Build the full commitment data needed for Step 1 of identity creation
230
243
  */
231
- function prepareNameCommitment(name, controlAddress, referralIAddress, parentIAddress, network = 'mainnet') {
232
- const salt = generateSalt();
244
+ function prepareNameCommitment(name, controlAddress, referralIAddress, parentIAddress, network = 'mainnet',
245
+ /**
246
+ * Explicit 32-byte salt. Omit for a fresh random salt (the normal path); pass
247
+ * a fixed value only to make the commitment deterministic for golden/diff
248
+ * tests. A reused salt on a real registration is a privacy leak, so callers
249
+ * outside tests must not set it.
250
+ */
251
+ salt = generateSalt()) {
233
252
  const systemId = index_js_1.NETWORK_CONFIG[network].chainId;
234
253
  // iAddressToHash discards the version byte, so an R-address referral would be
235
254
  // laundered into a hash that names no identity (the daemon rejects the
236
255
  // registration and the commitment fee is wasted). Require an i-address.
237
256
  if (referralIAddress) {
238
257
  assertAddressVersion(referralIAddress, index_js_1.I_ADDR_VERSION, 'referral');
258
+ // A sub-ID commitment (non-VRSC parent) would bake the referral into the
259
+ // advanced reservation hash, but _buildSubIdRegistration emits NO referral
260
+ // payout — the registration would then mismatch its own committed hash and
261
+ // the daemon rejects it, wasting the commitment fee. Fail closed here rather
262
+ // than committing a referral this SDK cannot honor.
263
+ if (!isVRSCParent(parentIAddress, network)) {
264
+ throw new errors_js_1.TransactionBuildError('referrals are not supported for sub-ID (non-VRSC-parent) registrations in this SDK; ' +
265
+ 'omit the referral for a sub-ID name commitment.');
266
+ }
239
267
  }
240
268
  const referralHash = referralIAddress
241
269
  ? (0, index_js_2.iAddressToHash)(referralIAddress)
@@ -256,7 +284,7 @@ function prepareNameCommitment(name, controlAddress, referralIAddress, parentIAd
256
284
  }
257
285
  const commitmentHash = calculateCommitmentHash(serializedReservation);
258
286
  const serializedCommitmentHash = serializeCommitmentHash(commitmentHash);
259
- const commitmentScript = buildCommitmentScript(commitmentHash, controlAddress);
287
+ const commitmentScript = buildCommitmentScript(commitmentHash, (0, brands_js_1.parseRAddress)(controlAddress, 'controlAddress'));
260
288
  return {
261
289
  salt,
262
290
  serializedReservation,
@@ -267,18 +295,17 @@ function prepareNameCommitment(name, controlAddress, referralIAddress, parentIAd
267
295
  };
268
296
  }
269
297
  /**
270
- * Build a P2ID output script (pay to identity)
298
+ * Build a pay-to-identity output script for an i-address.
299
+ *
300
+ * Alias of {@link identityPaymentScript}. This previously emitted the bare
301
+ * 26-byte template `OP_DUP OP_HASH160 <idhash> OP_EQUALVERIFY OP_CHECKSIG
302
+ * OP_CHECKCRYPTOCONDITION`, which is NOT a valid Verus scriptPubKey — a Verus
303
+ * P2ID is a CryptoCondition (OptCCParams) output. Funds sent to the old
304
+ * template would have gone to a non-standard script the daemon does not treat
305
+ * as identity-spendable. It now delegates to the chain-verified CC builder.
271
306
  */
272
307
  function buildP2IDScript(iAddress) {
273
- const idHash = (0, index_js_2.iAddressToHash)(iAddress);
274
- return utxo_lib_1.script.compile([
275
- utxo_lib_1.opcodes.OP_DUP,
276
- utxo_lib_1.opcodes.OP_HASH160,
277
- idHash,
278
- utxo_lib_1.opcodes.OP_EQUALVERIFY,
279
- utxo_lib_1.opcodes.OP_CHECKSIG,
280
- utxo_lib_1.opcodes.OP_CHECKCRYPTOCONDITION,
281
- ]);
308
+ return identityPaymentScript((0, brands_js_1.parseIAddress)(iAddress, 'iAddress'));
282
309
  }
283
310
  /**
284
311
  * The standard pay-to-identity output script (CC EVAL_NONE 1-of-1 to an
@@ -290,7 +317,9 @@ function identityPaymentScript(iAddress) {
290
317
  return buildReferralPaymentScript(iAddress);
291
318
  }
292
319
  /**
293
- * Build a CC referral payment output script
320
+ * Build a CC referral payment output script. Requires an already-parsed
321
+ * i-address: IdentityID.fromAddress launders the version byte, so accepting a
322
+ * raw string here is exactly the hole that let a wrong-kind address through.
294
323
  */
295
324
  function buildReferralPaymentScript(iAddress) {
296
325
  const identityDest = new verus_typescript_primitives_1.TxDestination(verus_typescript_primitives_1.IdentityID.fromAddress(iAddress));
@@ -331,9 +360,9 @@ function calculateRegistrationFees(hasReferral, totalFee = index_js_1.DEFAULT_RE
331
360
  * Build a new Identity object for registration
332
361
  */
333
362
  function createIdentityObject(params) {
334
- params.primaryAddresses.forEach((addr, i) => assertAddressVersion(addr, index_js_1.PUBKEY_HASH_PREFIX, `primaryAddresses[${i}]`));
335
- assertAddressVersion(params.revocationAuthority, index_js_1.I_ADDR_VERSION, 'revocationAuthority');
336
- assertAddressVersion(params.recoveryAuthority, index_js_1.I_ADDR_VERSION, 'recoveryAuthority');
363
+ // No assertAddressVersion here: the brands guarantee the version bytes. An
364
+ // R-address primary or an i-address authority is now a compile error at the
365
+ // call site, where the raw string is parsed.
337
366
  validateMinSigs(params.minSigs ?? 1, params.primaryAddresses.length);
338
367
  const primaryKeys = params.primaryAddresses.map(addr => verus_typescript_primitives_1.KeyID.fromAddress(addr));
339
368
  const identity = new verus_typescript_primitives_1.Identity({
@@ -354,45 +383,79 @@ function createIdentityObject(params) {
354
383
  /**
355
384
  * Build the parent-currency registration-fee output for a sub-ID.
356
385
  *
357
- * The daemon pays this fee as a plain reserve output (EVAL_RESERVE_OUTPUT)
358
- * holding `feeAmount` of the parent currency AT the parent currency's own
359
- * i-address, carrying zero native value. Verified against `registeridentity`
360
- * on VRSCTEST and an accepted on-chain sub-ID registration: for a currency
361
- * with idregistrationfees=1.0 the fee output is exactly `{parent: 1.0}` at the
362
- * parent, 0 native, and the native cost that leaves the transaction is the
363
- * currency's idimportfees (the caller passes it as `nativeImportFee`).
386
+ * The fee-output STRUCTURE depends on the parent currency's proofprotocol,
387
+ * verified against accepted on-chain registrations on VRSCTEST:
388
+ * - proofprotocol 2 (centralized / token, e.g. `ownora-nft`): a plain reserve
389
+ * output (EVAL_RESERVE_OUTPUT) holding `feeAmount` of the parent AT the
390
+ * parent's i-address, ZERO native.
391
+ * - otherwise (e.g. proofprotocol 1 PBaaS/fractional, `fum`): a CReserveTransfer
392
+ * (EVAL_RESERVE_TRANSFER) of `feeAmount` to the parent, carrying
393
+ * RESERVE_TRANSFER_FEE (0.0002) native.
364
394
  *
365
- * A previous implementation built this as a CReserveTransfer to the
366
- * reserve-transfer eval address carrying 0.0002 native a cross-currency
367
- * transfer, not a same-chain fee payment. That structure never matched the
368
- * daemon and was never accepted at broadcast.
395
+ * An offline SDK cannot read the parent's proofprotocol, so the caller passes it
396
+ * (`parentProofProtocol`). In both cases the native the transaction actually
397
+ * burns is the parent's idimportfees (`nativeImportFee`); the fee output's own
398
+ * native value is funded and cancels out in the conservation accounting.
369
399
  *
370
- * `systemId`/`_controlAddress` are retained for signature compatibility and
371
- * are unused: the fee is denominated in and paid to the parent currency.
400
+ * `_controlAddress` is retained for signature compatibility and is unused.
401
+ */
402
+ function buildRegistrationFeeOutput(parentCurrencyId, feeAmount, systemId, _controlAddress, parentProofProtocol = 2) {
403
+ if (parentProofProtocol === 2) {
404
+ return buildTokenChangeOutput((0, brands_js_1.parseIAddress)(parentCurrencyId, 'parentCurrencyId'), new Map([[parentCurrencyId, feeAmount]]));
405
+ }
406
+ return buildRegistrationFeeReserveTransfer(parentCurrencyId, feeAmount, systemId);
407
+ }
408
+ /**
409
+ * CReserveTransfer fee output for a sub-ID under a non-centralized (proofprotocol
410
+ * != 2) parent — the daemon's form for PBaaS/fractional currencies. Carries
411
+ * RESERVE_TRANSFER_FEE native.
372
412
  */
373
- function buildRegistrationFeeOutput(parentCurrencyId, feeAmount, systemId, _controlAddress) {
374
- void systemId;
375
- return buildTokenChangeOutput(parentCurrencyId, new Map([[parentCurrencyId, feeAmount]]));
413
+ function buildRegistrationFeeReserveTransfer(parentCurrencyId, feeAmount, systemId) {
414
+ const destination = new verus_typescript_primitives_1.TxDestination(verus_typescript_primitives_1.KeyID.fromAddress(index_js_1.RESERVE_TRANSFER_EVAL_PKH));
415
+ const values = new verus_typescript_primitives_1.CurrencyValueMap({
416
+ value_map: new Map([[parentCurrencyId, new bn_js_1.default(feeAmount.toString(10))]]),
417
+ multivalue: false,
418
+ });
419
+ const parentHash = (0, verus_typescript_primitives_3.fromBase58Check)(parentCurrencyId).hash;
420
+ const transferDest = new verus_typescript_primitives_1.TransferDestination({ type: verus_typescript_primitives_1.DEST_ID, destination_bytes: Buffer.from(parentHash) });
421
+ const flags = verus_typescript_primitives_1.RESERVE_TRANSFER_VALID.or(verus_typescript_primitives_1.RESERVE_TRANSFER_BURN_CHANGE_PRICE);
422
+ const resTransfer = new verus_typescript_primitives_1.ReserveTransfer({
423
+ values,
424
+ version: new bn_js_1.default(1),
425
+ flags,
426
+ fee_currency_id: systemId,
427
+ fee_amount: new bn_js_1.default(index_js_1.RESERVE_TRANSFER_FEE.toString(10)),
428
+ transfer_destination: transferDest,
429
+ dest_currency_id: parentCurrencyId,
430
+ });
431
+ const master = new verus_typescript_primitives_1.OptCCParams({
432
+ version: new bn_js_1.default(3), eval_code: new bn_js_1.default(verus_typescript_primitives_2.EVALS.EVAL_NONE), m: new bn_js_1.default(1), n: new bn_js_1.default(1),
433
+ destinations: [destination], vdata: [],
434
+ });
435
+ const params = new verus_typescript_primitives_1.OptCCParams({
436
+ version: new bn_js_1.default(3), eval_code: new bn_js_1.default(verus_typescript_primitives_2.EVALS.EVAL_RESERVE_TRANSFER), m: new bn_js_1.default(1), n: new bn_js_1.default(1),
437
+ destinations: [destination], vdata: [resTransfer.toBuffer()],
438
+ });
439
+ const script = new verus_typescript_primitives_1.SmartTransactionScript(master, params);
440
+ return { script: script.toBuffer(), nativeValue: index_js_1.RESERVE_TRANSFER_FEE };
376
441
  }
377
442
  /**
378
443
  * Build a token change output (EVAL_RESERVE_OUTPUT)
379
444
  */
380
445
  function buildTokenChangeOutput(changeAddress, currencyChanges) {
381
- // KeyID.fromAddress launders any address to the R-address form, so token
382
- // change to an i-address changeAddress was paid to a transparent script
383
- // nobody controls (burned on paths with no funded-transfer validator). Build
384
- // a pay-to-identity destination for i-addresses, and reject anything that is
385
- // neither an R- nor an i-address rather than silently mis-routing it.
386
- const version = (0, verus_typescript_primitives_3.fromBase58Check)(changeAddress).version;
446
+ // The brand guarantees the version; narrow it to pick the destination form.
447
+ // A pay-to-identity destination for i-addresses (KeyID.fromAddress would
448
+ // launder an i-address to a transparent script nobody controls), a KeyID for
449
+ // R-addresses, and reject a P2SH address (not a valid reserve destination).
387
450
  let destination;
388
- if (version === index_js_1.I_ADDR_VERSION) {
451
+ if ((0, brands_js_1.isIAddress)(changeAddress)) {
389
452
  destination = new verus_typescript_primitives_1.TxDestination(verus_typescript_primitives_1.IdentityID.fromAddress(changeAddress));
390
453
  }
391
- else if (version === index_js_1.PUBKEY_HASH_PREFIX) {
454
+ else if ((0, brands_js_1.isRAddress)(changeAddress)) {
392
455
  destination = new verus_typescript_primitives_1.TxDestination(verus_typescript_primitives_1.KeyID.fromAddress(changeAddress));
393
456
  }
394
457
  else {
395
- throw new errors_js_1.TransactionBuildError(`token change address must be an R-address or identity i-address, got version ${version}: ${changeAddress}`);
458
+ throw new errors_js_1.TransactionBuildError(`token change address must be an R-address or identity i-address, got: ${changeAddress}`);
396
459
  }
397
460
  const valueMap = new Map();
398
461
  for (const [currency, amount] of currencyChanges) {
@@ -462,7 +525,7 @@ function buildAndSignCommitment(params, network) {
462
525
  // (KeyID.fromAddress laundered it to an uncontrollable key — permanently
463
526
  // unspendable, wasting the commitment fee). Derive it from the WIF instead.
464
527
  const controlAddress = utxo_lib_1.ECPair.fromWIF(params.wif, verusNetwork).getAddress();
465
- const commitment = prepareNameCommitment(params.name, controlAddress, params.referral, params.parent, network);
528
+ const commitment = prepareNameCommitment(params.name, controlAddress, params.referral, params.parent, network, params.salt);
466
529
  const utxos = params.utxos;
467
530
  const selection = (0, index_js_4.selectUtxos)(utxos, 0n, new Map(), 1, networkConfig.chainId, undefined, true);
468
531
  const txb = new utxo_lib_1.TransactionBuilder(verusNetwork);
@@ -473,18 +536,35 @@ function buildAndSignCommitment(params, network) {
473
536
  txb.addInput(Buffer.from(utxo.txid, 'hex').reverse(), utxo.outputIndex, 0xffffffff, Buffer.from(utxo.script, 'hex'));
474
537
  }
475
538
  txb.addOutput(commitment.commitmentScript, 0);
476
- if (selection.nativeChange > 0n) {
477
- // utxo-lib's addOutput only resolves base58 R-addresses; an i-address
478
- // changeAddress needs the explicit P2ID script (matching sendCurrency), or
479
- // it throws an untyped "no matching Script".
480
- if (params.changeAddress.startsWith('i')) {
481
- txb.addOutput(identityPaymentScript(params.changeAddress), (0, index_js_2.toSafeNumber)(selection.nativeChange));
539
+ // If native-only UTXOs can't cover the fee, selectUtxos falls back to a
540
+ // token-bearing UTXO and returns its token value as currencyChanges. Emit it
541
+ // as a reserve-output change (bundled with the native change) — otherwise
542
+ // that token value is silently forfeited to the miner.
543
+ const hasTokenChange = selection.currencyChanges.size > 0;
544
+ if (hasTokenChange || selection.nativeChange > 0n) {
545
+ if (hasTokenChange) {
546
+ const tokenChangeScript = buildTokenChangeOutput((0, brands_js_1.parseAddress)(params.changeAddress, 'changeAddress'), selection.currencyChanges);
547
+ txb.addOutput(tokenChangeScript.script, (0, index_js_2.toSafeNumber)(selection.nativeChange));
548
+ }
549
+ else if (params.changeAddress.startsWith('i')) {
550
+ // utxo-lib's addOutput only resolves base58 R-addresses; an i-address
551
+ // changeAddress needs the explicit P2ID script (matching sendCurrency), or
552
+ // it throws an untyped "no matching Script".
553
+ txb.addOutput(identityPaymentScript((0, brands_js_1.parseIAddress)(params.changeAddress, 'changeAddress')), (0, index_js_2.toSafeNumber)(selection.nativeChange));
482
554
  }
483
555
  else {
484
556
  txb.addOutput(params.changeAddress, (0, index_js_2.toSafeNumber)(selection.nativeChange));
485
557
  }
486
558
  }
559
+ // No token is paid out here (the commitment output carries none), so every
560
+ // token in the selected inputs must return as change.
561
+ (0, index_js_4.assertTokenConservation)(selection.selected, new Map(), selection.currencyChanges, networkConfig.chainId, 'name commitment');
487
562
  const unsignedTx = txb.buildIncomplete();
563
+ // Native value conservation: the commitment output is 0 and change is native,
564
+ // so the assembled native fee must equal selection.fee. The fork's absurd-fee
565
+ // guard is blind for inputs > 2^32 sats (it truncates input value mod 2^32),
566
+ // so this bigint check is the real backstop against a change-accounting slip.
567
+ (0, index_js_3.assertNativeConservation)(selection.selected, unsignedTx.outs, selection.fee, 'name commitment');
488
568
  const { signedTx, txid } = (0, index_js_3.signTransactionSmart)(unsignedTx.toHex(), params.wif, selection.selected, verusNetwork);
489
569
  return {
490
570
  signedTx,
@@ -515,6 +595,17 @@ function buildAndSignRegistration(params, network) {
515
595
  const verusNetwork = (0, index_js_3.getNetwork)(network === 'testnet');
516
596
  const networkConfig = index_js_1.NETWORK_CONFIG[network];
517
597
  const systemId = networkConfig.chainId;
598
+ // Step 2 spends the name-commitment output, which is controlled by the key that
599
+ // created it in step 1. The fork signs the commitment input with whatever WIF it
600
+ // is handed, so a mismatched WIF produces a tx the daemon rejects at broadcast
601
+ // (and the commitment fee is wasted). Verify the WIF's hash is the commitment's
602
+ // control hash up front. (The 20-byte control hash is embedded in the CC script.)
603
+ const signerAddr = utxo_lib_1.ECPair.fromWIF(params.wif, verusNetwork).getAddress();
604
+ const signerHash = (0, index_js_2.addressToScriptPubKey)(signerAddr).subarray(3, 23).toString('hex');
605
+ if (!params.commitmentUtxo.script.toLowerCase().includes(signerHash)) {
606
+ throw new errors_js_1.TransactionBuildError(`the provided WIF (${signerAddr}) does not control the name-commitment output; step 2 must be ` +
607
+ `signed by the same key that created the commitment in step 1.`);
608
+ }
518
609
  const commitData = params.commitmentData;
519
610
  const parentIAddress = commitData.parent || systemId;
520
611
  const isSubId = !isVRSCParent(commitData.parent || undefined, network);
@@ -522,15 +613,15 @@ function buildAndSignRegistration(params, network) {
522
613
  const identityAddress = deriveIdentityAddress(commitData.name, effectiveParent);
523
614
  const identity = createIdentityObject({
524
615
  name: commitData.name,
525
- primaryAddresses: params.primaryAddresses,
616
+ primaryAddresses: params.primaryAddresses.map((a, i) => (0, brands_js_1.parseRAddress)(a, `primaryAddresses[${i}]`)),
526
617
  ...(params.minSigs !== undefined ? { minSigs: params.minSigs } : {}),
527
- revocationAuthority: params.revocationAuthority || identityAddress,
528
- recoveryAuthority: params.recoveryAuthority || identityAddress,
529
- parentIAddress,
530
- systemId,
618
+ revocationAuthority: (0, brands_js_1.parseIAddress)(params.revocationAuthority || identityAddress, 'revocationAuthority'),
619
+ recoveryAuthority: (0, brands_js_1.parseIAddress)(params.recoveryAuthority || identityAddress, 'recoveryAuthority'),
620
+ parentIAddress: (0, brands_js_1.parseIAddress)(parentIAddress, 'parentIAddress'),
621
+ systemId: (0, brands_js_1.parseIAddress)(systemId, 'systemId'),
531
622
  });
532
623
  const identityScript = buildIdentityScript(identity);
533
- const reservationScript = buildReservationScript(identityAddress, Buffer.from(commitData.namereservationHex, 'hex'), isSubId);
624
+ const reservationScript = buildReservationScript((0, brands_js_1.parseIAddress)(identityAddress, 'identityAddress'), Buffer.from(commitData.namereservationHex, 'hex'), isSubId);
534
625
  if (isSubId) {
535
626
  return _buildSubIdRegistration(params, identity, identityScript, reservationScript, identityAddress, parentIAddress, systemId, verusNetwork);
536
627
  }
@@ -557,7 +648,7 @@ function _buildVrscRegistration(params, identityScript, reservationScript, ident
557
648
  }
558
649
  for (const referrerAddr of chain) {
559
650
  referralOutputs.push({
560
- script: buildReferralPaymentScript(referrerAddr),
651
+ script: buildReferralPaymentScript((0, brands_js_1.parseIAddress)(referrerAddr, 'referralChain entry')),
561
652
  value: fees.referralAmount,
562
653
  });
563
654
  }
@@ -587,17 +678,29 @@ function _buildVrscRegistration(params, identityScript, reservationScript, ident
587
678
  txb.addOutput(referralOut.script, (0, index_js_2.toSafeNumber)(referralOut.value));
588
679
  }
589
680
  txb.addOutput(reservationScript, 0);
590
- if (selection.nativeChange > 0n) {
591
- // utxo-lib's addOutput only resolves base58 R-addresses; an i-address
592
- // changeAddress needs the explicit P2ID script (matching sendCurrency), or
593
- // it throws an untyped "no matching Script".
594
- if (params.changeAddress.startsWith('i')) {
595
- txb.addOutput(identityPaymentScript(params.changeAddress), (0, index_js_2.toSafeNumber)(selection.nativeChange));
681
+ // Registration needs ~80-100 native, so Phase-2 selection is especially
682
+ // likely to exhaust pure-native UTXOs and pull a token-bearing one; return
683
+ // its token value as reserve-output change instead of forfeiting it to the
684
+ // miner. (The commitment input carries no currency.)
685
+ const hasTokenChange = selection.currencyChanges.size > 0;
686
+ if (hasTokenChange || selection.nativeChange > 0n) {
687
+ if (hasTokenChange) {
688
+ const tokenChangeScript = buildTokenChangeOutput((0, brands_js_1.parseAddress)(params.changeAddress, 'changeAddress'), selection.currencyChanges);
689
+ txb.addOutput(tokenChangeScript.script, (0, index_js_2.toSafeNumber)(selection.nativeChange));
690
+ }
691
+ else if (params.changeAddress.startsWith('i')) {
692
+ // utxo-lib's addOutput only resolves base58 R-addresses; an i-address
693
+ // changeAddress needs the explicit P2ID script (matching sendCurrency), or
694
+ // it throws an untyped "no matching Script".
695
+ txb.addOutput(identityPaymentScript((0, brands_js_1.parseIAddress)(params.changeAddress, 'changeAddress')), (0, index_js_2.toSafeNumber)(selection.nativeChange));
596
696
  }
597
697
  else {
598
698
  txb.addOutput(params.changeAddress, (0, index_js_2.toSafeNumber)(selection.nativeChange));
599
699
  }
600
700
  }
701
+ // Referral payouts are native; no token is paid out, so all token value in the
702
+ // selected inputs must return as change.
703
+ (0, index_js_4.assertTokenConservation)(selection.selected, new Map(), selection.currencyChanges, systemId, 'identity registration');
601
704
  const unsignedTx = txb.buildIncomplete();
602
705
  const allUtxos = [commitUtxo, ...selection.selected];
603
706
  // The registration fee is burned as an IMPLICIT miner fee (identity and
@@ -641,14 +744,38 @@ function _buildSubIdRegistration(params, identity, identityScript, reservationSc
641
744
  throw new errors_js_1.TransactionBuildError('registrationFeeAmount is required for sub-ID registration. ' +
642
745
  'Specify the fee in parent currency satoshis.');
643
746
  }
644
- const feeOutput = buildRegistrationFeeOutput(parentIAddress, registrationFeeAmount, systemId, params.changeAddress);
747
+ // The fee-output structure differs by the parent's proofprotocol (2 =
748
+ // reserve output, else reserve transfer), which the offline SDK cannot read —
749
+ // the caller must supply it (from `getcurrency <parent>.proofprotocol`).
750
+ if (params.parentProofProtocol === undefined) {
751
+ throw new errors_js_1.TransactionBuildError("parentProofProtocol is required for sub-ID registration: pass the parent currency's proofprotocol " +
752
+ '(from `getcurrency <parent>`) — 2 for a centralized/token currency, 1 for a PBaaS/fractional one.');
753
+ }
754
+ const feeOutput = buildRegistrationFeeOutput(parentIAddress, registrationFeeAmount, systemId, params.changeAddress, params.parentProofProtocol);
645
755
  const requiredCurrencies = new Map([
646
756
  [parentIAddress, registrationFeeAmount],
647
757
  ]);
648
- const nativeImportFee = params.nativeImportFee || 0n;
758
+ // The parent currency's idimportfees must leave the transaction as native fee;
759
+ // this SDK is offline and cannot read it, so the caller must pass it explicitly.
760
+ // Silently defaulting to 0 underfunds a currency that charges an import fee, and
761
+ // the daemon rejects the registration.
762
+ if (params.nativeImportFee === undefined) {
763
+ throw new errors_js_1.TransactionBuildError("nativeImportFee is required for sub-ID registration: pass the parent currency's idimportfees " +
764
+ '(from `getcurrency <parent>`), or 0n if it charges none.');
765
+ }
766
+ const nativeImportFee = params.nativeImportFee;
649
767
  const nativeTarget = feeOutput.nativeValue + nativeImportFee;
650
768
  const numOutputs = 4;
651
- const selection = (0, index_js_4.selectUtxos)(params.utxos, nativeTarget, requiredCurrencies, numOutputs, systemId, undefined, true);
769
+ const selection = (0, index_js_4.selectUtxos)(params.utxos, nativeTarget, requiredCurrencies, numOutputs, systemId, undefined, true,
770
+ // The identity and reservation outputs embed the full serialized identity /
771
+ // advanced name reservation (a large contentMultimap can make them
772
+ // multi-KB); size the fee from their real bytes, matching the VRSC
773
+ // registration / update / define paths, so a big sub-ID isn't fee-estimated
774
+ // below the relay minimum and rejected.
775
+ identityScript.length + reservationScript.length + feeOutput.script.length);
776
+ // The parent-currency fee (requiredCurrencies) is paid to the fee output and
777
+ // any excess returns as token change; guard that no token value is dropped.
778
+ (0, index_js_4.assertTokenConservation)(selection.selected, requiredCurrencies, selection.currencyChanges, systemId, 'sub-ID registration');
652
779
  const txb = new utxo_lib_1.TransactionBuilder(network);
653
780
  txb.setVersion(4);
654
781
  txb.setExpiryHeight((0, index_js_3.resolveExpiryHeight)(params.expiryHeight));
@@ -664,7 +791,7 @@ function _buildSubIdRegistration(params, identity, identityScript, reservationSc
664
791
  const hasTokenChange = selection.currencyChanges.size > 0;
665
792
  if (hasTokenChange || selection.nativeChange > 0n) {
666
793
  if (hasTokenChange) {
667
- const tokenChangeScript = buildTokenChangeOutput(params.changeAddress, selection.currencyChanges);
794
+ const tokenChangeScript = buildTokenChangeOutput((0, brands_js_1.parseAddress)(params.changeAddress, 'changeAddress'), selection.currencyChanges);
668
795
  txb.addOutput(tokenChangeScript.script, (0, index_js_2.toSafeNumber)(selection.nativeChange));
669
796
  }
670
797
  else {
@@ -672,7 +799,7 @@ function _buildSubIdRegistration(params, identity, identityScript, reservationSc
672
799
  // changeAddress needs the explicit P2ID script (matching sendCurrency), or
673
800
  // it throws an untyped "no matching Script".
674
801
  if (params.changeAddress.startsWith('i')) {
675
- txb.addOutput(identityPaymentScript(params.changeAddress), (0, index_js_2.toSafeNumber)(selection.nativeChange));
802
+ txb.addOutput(identityPaymentScript((0, brands_js_1.parseIAddress)(params.changeAddress, 'changeAddress')), (0, index_js_2.toSafeNumber)(selection.nativeChange));
676
803
  }
677
804
  else {
678
805
  txb.addOutput(params.changeAddress, (0, index_js_2.toSafeNumber)(selection.nativeChange));
@@ -742,6 +869,17 @@ function buildAndSignIdentityUpdate(params, network, operation = 'update', lockU
742
869
  throw new errors_js_1.TransactionBuildError(`the provided WIF (${signerAddress}) is not among the identity's primary addresses ` +
743
870
  `[${currentPrimaries.join(', ')}]; it cannot authorize a ${operation}.`);
744
871
  }
872
+ // Spending the identity input under primary authority needs min_sigs
873
+ // signatures, but this SDK signs with a single WIF and the bundled fork
874
+ // cannot attach a second signature to a CryptoCondition input. Emitting a
875
+ // 1-of-N-signed tx for a min_sigs>1 identity yields a hex the daemon rejects
876
+ // at broadcast; fail closed instead.
877
+ const currentMinSigs = identity.min_sigs?.toNumber?.() ?? 1;
878
+ if (currentMinSigs > 1) {
879
+ throw new errors_js_1.TransactionBuildError(`this identity requires ${currentMinSigs} signatures (min_sigs > 1); the SDK signs with a single ` +
880
+ `WIF and the bundled fork cannot multi-sign a CryptoCondition input, so a valid ${operation} ` +
881
+ `transaction cannot be produced. Use the daemon for multisig identities.`);
882
+ }
745
883
  }
746
884
  switch (operation) {
747
885
  case 'update': {
@@ -760,13 +898,21 @@ function buildAndSignIdentityUpdate(params, network, operation = 'update', lockU
760
898
  identity.setRecovery(params.recoveryAuthority);
761
899
  }
762
900
  if (params.contentMap) {
901
+ // The daemon REPLACES the whole contentmap when the field is provided
902
+ // (and the contentMultimap branch below already replaces via fromJson).
903
+ // Merging into the parsed on-chain map instead re-attested stale keys and
904
+ // gave no way to delete one. Clear first so the provided map is authoritative.
905
+ identity.content_map.clear();
763
906
  for (const [key, value] of Object.entries(params.contentMap)) {
764
- // Buffer.from(_, 'hex') silently drops non-hex characters and
765
- // truncates odd-length input, so a malformed value would be committed
766
- // to the identity on-chain as wrong/empty bytes with no error. Reject
767
- // it instead.
768
- if (!/^[0-9a-fA-F]*$/.test(value) || value.length % 2 !== 0) {
769
- throw new errors_js_1.TransactionBuildError(`contentMap["${key}"] must be an even-length hex string (got ${JSON.stringify(value)})`);
907
+ // Keys are vdxf i-addresses; the primitives run fromBase58Check(key),
908
+ // which discards the version byte guard it like the multimap branch.
909
+ assertAddressVersion(key, index_js_1.I_ADDR_VERSION, `contentMap key "${key}"`);
910
+ // Values serialize as a fixed uint256 (32 bytes). Buffer.from(_, 'hex')
911
+ // silently drops non-hex and truncates, so a wrong-length value would be
912
+ // committed on-chain as wrong bytes (or build a payload the daemon can't
913
+ // deserialize). Require exactly 64 hex chars.
914
+ if (!/^[0-9a-fA-F]{64}$/.test(value)) {
915
+ throw new errors_js_1.TransactionBuildError(`contentMap["${key}"] must be a 32-byte (64-hex-char) value (got ${JSON.stringify(value)})`);
770
916
  }
771
917
  identity.content_map.set(key, Buffer.from(value, 'hex'));
772
918
  }
@@ -804,6 +950,13 @@ function buildAndSignIdentityUpdate(params, network, operation = 'update', lockU
804
950
  if (params.primaryAddresses) {
805
951
  identity.setPrimaryAddresses(params.primaryAddresses);
806
952
  }
953
+ // Recovery commonly replaces the primary set; allow lowering min_sigs so a
954
+ // 2-of-2 identity can be recovered to a single fresh key (otherwise the
955
+ // stale min_sigs would exceed the new primary count — see the guard below).
956
+ if (params.minSigs !== undefined) {
957
+ validateMinSigs(params.minSigs, identity.primary_addresses?.length ?? 0);
958
+ identity.min_sigs = new bn_js_1.default(params.minSigs);
959
+ }
807
960
  if (params.revocationAuthority) {
808
961
  identity.setRevocation(params.revocationAuthority);
809
962
  }
@@ -813,11 +966,20 @@ function buildAndSignIdentityUpdate(params, network, operation = 'update', lockU
813
966
  break;
814
967
  }
815
968
  case 'lock': {
816
- const unlockAfter = lockUnlockParams?.unlockAfter;
817
- if (!unlockAfter) {
818
- throw new errors_js_1.TransactionBuildError('unlockAfter (block height) is required for lock operation');
969
+ const delay = lockUnlockParams?.unlockDelayBlocks;
970
+ if (delay === undefined || !Number.isInteger(delay) || delay <= 0) {
971
+ throw new errors_js_1.TransactionBuildError('unlockDelayBlocks (a positive integer RELATIVE delay in blocks, not a block height) is required for lock');
819
972
  }
820
- identity.lock(new bn_js_1.default(unlockAfter));
973
+ if (delay > MAX_UNLOCK_DELAY) {
974
+ throw new errors_js_1.TransactionBuildError(`unlockDelayBlocks ${delay} exceeds the consensus maximum ${MAX_UNLOCK_DELAY} (~22 years)`);
975
+ }
976
+ // A block height (millions) passed as a delay locks the identity for years.
977
+ // Require an explicit opt-in above ~1 year to catch that mistake.
978
+ if (delay > LOCK_DELAY_SANITY_BLOCKS && !lockUnlockParams?.sanityOverride) {
979
+ throw new errors_js_1.TransactionBuildError(`unlockDelayBlocks ${delay} is over ~1 year (${LOCK_DELAY_SANITY_BLOCKS} blocks) — this is a RELATIVE ` +
980
+ `delay, not a block height; pass sanityOverride: true if that long a lock is intended.`);
981
+ }
982
+ identity.lock(new bn_js_1.default(delay));
821
983
  break;
822
984
  }
823
985
  case 'unlock': {
@@ -834,6 +996,15 @@ function buildAndSignIdentityUpdate(params, network, operation = 'update', lockU
834
996
  break;
835
997
  }
836
998
  }
999
+ // A valid identity always has min_sigs <= number of primary addresses. Shrinking
1000
+ // the primary set (update/recover) without lowering minSigs would leave a stale
1001
+ // min_sigs the daemon rejects — or, worse, a permanently unspendable identity.
1002
+ const finalPrimaries = identity.primary_addresses?.length ?? 0;
1003
+ const finalMinSigs = identity.min_sigs?.toNumber?.() ?? 1;
1004
+ if (finalMinSigs > finalPrimaries) {
1005
+ throw new errors_js_1.TransactionBuildError(`resulting identity has min_sigs ${finalMinSigs} but only ${finalPrimaries} primary address(es); ` +
1006
+ `pass minSigs to lower it when reducing the primary set.`);
1007
+ }
837
1008
  const identityBuf = identity.toBuffer();
838
1009
  const unfundedHex = createUnfundedIdentityUpdate(identityBuf.toString('hex'), verusNetwork, (0, index_js_3.resolveExpiryHeight)(params.expiryHeight));
839
1010
  const selection = (0, index_js_4.selectUtxos)(params.utxos, 0n, new Map(), 1, systemId, undefined, true,
@@ -841,6 +1012,10 @@ function buildAndSignIdentityUpdate(params, network, operation = 'update', lockU
841
1012
  // contentMultimap can make it multi-KB); size the fee from its real bytes
842
1013
  // so a big update isn't fee-estimated below the relay minimum.
843
1014
  unfundedHex.length / 2);
1015
+ // Update spends only native fees and emits no token-change output, so a
1016
+ // token-bearing funding UTXO would be silently dropped. Fail closed if one
1017
+ // was selected (both maps empty ⇒ assert no token enters).
1018
+ (0, index_js_4.assertTokenConservation)(selection.selected, new Map(), new Map(), systemId, 'identity update');
844
1019
  const txb = new utxo_lib_1.TransactionBuilder(verusNetwork);
845
1020
  txb.setVersion(4);
846
1021
  txb.setExpiryHeight((0, index_js_3.resolveExpiryHeight)(params.expiryHeight));
@@ -857,7 +1032,7 @@ function buildAndSignIdentityUpdate(params, network, operation = 'update', lockU
857
1032
  // changeAddress needs the explicit P2ID script (matching sendCurrency), or
858
1033
  // it throws an untyped "no matching Script".
859
1034
  if (params.changeAddress.startsWith('i')) {
860
- txb.addOutput(identityPaymentScript(params.changeAddress), (0, index_js_2.toSafeNumber)(selection.nativeChange));
1035
+ txb.addOutput(identityPaymentScript((0, brands_js_1.parseIAddress)(params.changeAddress, 'changeAddress')), (0, index_js_2.toSafeNumber)(selection.nativeChange));
861
1036
  }
862
1037
  else {
863
1038
  txb.addOutput(params.changeAddress, (0, index_js_2.toSafeNumber)(selection.nativeChange));