bulletin-deploy 0.13.2 → 0.14.0-rc.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.
Files changed (37) hide show
  1. package/dist/auth-config.js +3 -3
  2. package/dist/bug-report.js +4 -4
  3. package/dist/{chunk-YKWQMXWJ.js → chunk-447MJ2ZY.js} +64 -23
  4. package/dist/{chunk-7PDAOD6W.js → chunk-4IPJMCP5.js} +48 -2
  5. package/dist/{chunk-RVSHGN74.js → chunk-7XC2U7R4.js} +1 -1
  6. package/dist/{chunk-64FAP55R.js → chunk-BB5EMKB5.js} +1 -1
  7. package/dist/{chunk-2E2KHTF2.js → chunk-KMDZ3ZV6.js} +1 -1
  8. package/dist/{chunk-KQOQN5CN.js → chunk-S2NLQSLC.js} +1 -1
  9. package/dist/{chunk-3QQ3KC6Q.js → chunk-URSX5FBI.js} +200 -70
  10. package/dist/{chunk-HAFE24QD.js → chunk-VDH5EBI2.js} +3 -3
  11. package/dist/{chunk-TLANVKWT.js → chunk-VWCSL736.js} +2 -2
  12. package/dist/{chunk-SRCUUO5A.js → chunk-WVDMUB6R.js} +21 -8
  13. package/dist/{chunk-RRBDBTOF.js → chunk-ZPJAJJBD.js} +1 -1
  14. package/dist/chunk-probe.js +3 -3
  15. package/dist/commands/login.js +10 -10
  16. package/dist/commands/logout.js +4 -4
  17. package/dist/commands/transfer.js +3 -3
  18. package/dist/commands/whoami.js +3 -3
  19. package/dist/deploy-actors.js +5 -5
  20. package/dist/deploy.d.ts +13 -1
  21. package/dist/deploy.js +12 -10
  22. package/dist/dotns.d.ts +35 -4
  23. package/dist/dotns.js +11 -3
  24. package/dist/index.js +11 -11
  25. package/dist/manifest/publish.d.ts +41 -1
  26. package/dist/manifest/publish.js +15 -13
  27. package/dist/memory-report.js +2 -2
  28. package/dist/merkle.js +10 -10
  29. package/dist/personhood/bootstrap.js +3 -3
  30. package/dist/personhood/people-client.js +3 -3
  31. package/dist/run-state.js +1 -1
  32. package/dist/sss-allowance-cache.js +4 -4
  33. package/dist/storage-signer.js +10 -10
  34. package/dist/telemetry.d.ts +3 -2
  35. package/dist/telemetry.js +4 -2
  36. package/dist/version-check.js +3 -3
  37. package/package.json +1 -1
@@ -9,10 +9,10 @@ import {
9
9
  getPeopleChainEndpoints,
10
10
  hasPersistedSession,
11
11
  resolveBulletinEndpoints
12
- } from "./chunk-RVSHGN74.js";
12
+ } from "./chunk-7XC2U7R4.js";
13
13
  import "./chunk-TSPERKUS.js";
14
- import "./chunk-7PDAOD6W.js";
15
- import "./chunk-KQOQN5CN.js";
14
+ import "./chunk-4IPJMCP5.js";
15
+ import "./chunk-S2NLQSLC.js";
16
16
  import "./chunk-3ZEMX4WD.js";
17
17
  import "./chunk-ZOC4GITL.js";
18
18
  export {
@@ -10,10 +10,10 @@ import {
10
10
  offerBugReport,
11
11
  scrubSecrets,
12
12
  setDeployContext
13
- } from "./chunk-HAFE24QD.js";
14
- import "./chunk-RRBDBTOF.js";
15
- import "./chunk-7PDAOD6W.js";
16
- import "./chunk-KQOQN5CN.js";
13
+ } from "./chunk-VDH5EBI2.js";
14
+ import "./chunk-ZPJAJJBD.js";
15
+ import "./chunk-4IPJMCP5.js";
16
+ import "./chunk-S2NLQSLC.js";
17
17
  export {
18
18
  buildCliFlagsSummary,
19
19
  buildLabels,
@@ -9,10 +9,16 @@ import {
9
9
  setBulletinEndpoints,
10
10
  storeDirectory,
11
11
  storeFile
12
- } from "./chunk-SRCUUO5A.js";
12
+ } from "./chunk-WVDMUB6R.js";
13
13
  import {
14
- DotNS
15
- } from "./chunk-3QQ3KC6Q.js";
14
+ DotNS,
15
+ TX_KIND_SKIPPED
16
+ } from "./chunk-URSX5FBI.js";
17
+ import {
18
+ setDeployAttribute,
19
+ withManifestSpan,
20
+ withSpan
21
+ } from "./chunk-4IPJMCP5.js";
16
22
  import {
17
23
  DEFAULT_ENV_ID,
18
24
  getPopSelfServeConfig,
@@ -40,19 +46,29 @@ async function publishManifest(opts) {
40
46
  `Manifest size preflight failed: ${failing}. Shrink displayName / description / paths or override BULLETIN_TEXT_BUDGET.`
41
47
  );
42
48
  }
49
+ return withManifestSpan(config.domain, () => publishManifestBody(opts, config, sourcePath));
50
+ }
51
+ async function publishManifestBody(opts, config, sourcePath) {
43
52
  const configDir = path.dirname(sourcePath);
44
53
  const envId = opts.env ?? DEFAULT_ENV_ID;
45
54
  const { doc } = await loadEnvironments();
46
55
  const resolved = resolveEndpoints(doc, envId);
47
56
  const popSelfServe = getPopSelfServeConfig(doc, envId);
48
57
  setBulletinEndpoints(resolveBulletinEndpoints(resolved.bulletin, opts.rpc));
58
+ setDeployAttribute("deploy.manifest.executable_count", String(config.executables.length));
49
59
  const iconAbs = path.resolve(configDir, config.icon.path);
50
60
  const iconBytes = await readFileOrThrow(iconAbs, "icon");
51
61
  console.log(`
52
62
  Manifest publish \u2014 ${config.domain}`);
53
63
  console.log(` Loaded config: ${sourcePath}`);
54
64
  console.log(` Uploading icon (${iconBytes.length} B)\u2026`);
55
- const iconCid = await storeFile(iconBytes, { hashCode: BLAKE2B_256_MULTIHASH_CODE });
65
+ setDeployAttribute("deploy.manifest.icon_bytes", String(iconBytes.length));
66
+ const iconCid = await withSpan(
67
+ "deploy.manifest.icon-upload",
68
+ "manifest icon upload",
69
+ { "deploy.manifest.icon_bytes": String(iconBytes.length) },
70
+ () => storeFile(iconBytes, { hashCode: BLAKE2B_256_MULTIHASH_CODE })
71
+ );
56
72
  console.log(` Icon CID: ${iconCid}`);
57
73
  const executableCids = {};
58
74
  for (const exec of config.executables) {
@@ -74,37 +90,61 @@ Manifest publish \u2014 ${config.domain}`);
74
90
  const rootManifest = composeRoot(config, iconCid);
75
91
  const rootJson = JSON.stringify(rootManifest);
76
92
  console.log(` Writing root manifest text record on ${config.domain} (${Buffer.byteLength(rootJson, "utf8")} B)\u2026`);
77
- await dotns.setTextRecord(baseLabel, "manifest", rootJson);
93
+ const rootTextRes = await dotns.setTextRecord(baseLabel, "manifest", rootJson);
94
+ let recordsWritten = 0;
95
+ let recordsSkipped = 0;
96
+ let subnamesRegistered = 0;
97
+ if (rootTextRes.txHash === TX_KIND_SKIPPED) recordsSkipped++;
98
+ else recordsWritten++;
78
99
  let textRecordsWritten = 1;
79
100
  for (const exec of config.executables) {
80
101
  const cid = executableCids[exec.kind];
81
102
  if (!cid) throw new NonRetryableError(`Internal: missing CID for executable kind '${exec.kind}'`);
82
- const ownership = await dotns.checkSubdomainOwnership(exec.kind, baseLabel);
83
- if (!ownership.owned) {
84
- if (ownership.owner) {
85
- throw new NonRetryableError(
86
- `Subname ${exec.kind}.${config.domain} is owned by ${ownership.owner}, not the publisher. Aborting.`
87
- );
103
+ await withSpan(
104
+ "deploy.manifest.executable",
105
+ `manifest executable ${exec.kind}`,
106
+ { "deploy.manifest.executable_kind": exec.kind },
107
+ async () => {
108
+ const ownership = await dotns.checkSubdomainOwnership(exec.kind, baseLabel);
109
+ const { registered } = await registerOrEnsureResolver(dotns, ownership, exec.kind, baseLabel, config.domain);
110
+ if (registered) subnamesRegistered++;
111
+ const subContenthash = `0x${encodeContenthash(cid)}`;
112
+ console.log(` Setting contenthash on ${exec.kind}.${config.domain} \u2192 ${cid}\u2026`);
113
+ const contenthashRes = await dotns.setContenthash(`${exec.kind}.${baseLabel}`, subContenthash);
114
+ if (contenthashRes.skipped) recordsSkipped++;
115
+ const execManifest = composeExecutable(exec);
116
+ const execJson = JSON.stringify(execManifest);
117
+ console.log(` Writing executable manifest on ${exec.kind}.${config.domain} (${Buffer.byteLength(execJson, "utf8")} B)\u2026`);
118
+ const execTextRes = await dotns.setTextRecord(`${exec.kind}.${baseLabel}`, "executable", execJson);
119
+ if (execTextRes.txHash === TX_KIND_SKIPPED) recordsSkipped++;
120
+ else recordsWritten++;
88
121
  }
89
- console.log(` Registering subname ${exec.kind}.${config.domain}\u2026`);
90
- await dotns.registerSubdomain(exec.kind, baseLabel);
91
- }
92
- await dotns.ensureContentResolver(`${exec.kind}.${baseLabel}`);
93
- const subContenthash = `0x${encodeContenthash(cid)}`;
94
- console.log(` Setting contenthash on ${exec.kind}.${config.domain} \u2192 ${cid}\u2026`);
95
- await dotns.setContenthash(`${exec.kind}.${baseLabel}`, subContenthash);
96
- const execManifest = composeExecutable(exec);
97
- const execJson = JSON.stringify(execManifest);
98
- console.log(` Writing executable manifest on ${exec.kind}.${config.domain} (${Buffer.byteLength(execJson, "utf8")} B)\u2026`);
99
- await dotns.setTextRecord(`${exec.kind}.${baseLabel}`, "executable", execJson);
122
+ );
100
123
  textRecordsWritten++;
101
124
  }
125
+ setDeployAttribute("deploy.manifest.text_records_written", String(recordsWritten));
126
+ setDeployAttribute("deploy.manifest.text_records_skipped", String(recordsSkipped));
127
+ setDeployAttribute("deploy.manifest.subnames_registered", String(subnamesRegistered));
102
128
  console.log(` \u2713 ${textRecordsWritten} text record${textRecordsWritten === 1 ? "" : "s"} written.`);
103
129
  return { iconCid, executableCids, textRecordsWritten };
104
130
  } finally {
105
131
  dotns.disconnect();
106
132
  }
107
133
  }
134
+ async function registerOrEnsureResolver(dotns, ownership, execKind, baseLabel, domain) {
135
+ if (!ownership.owned) {
136
+ if (ownership.owner) {
137
+ throw new NonRetryableError(
138
+ `Subname ${execKind}.${domain} is owned by ${ownership.owner}, not the publisher. Aborting.`
139
+ );
140
+ }
141
+ console.log(` Registering subname ${execKind}.${domain}\u2026`);
142
+ await dotns.registerSubdomain(execKind, baseLabel);
143
+ return { registered: true };
144
+ }
145
+ await dotns.ensureContentResolver(`${execKind}.${baseLabel}`);
146
+ return { registered: false };
147
+ }
108
148
  async function readFileOrThrow(p, label) {
109
149
  try {
110
150
  return await fs.readFile(p);
@@ -165,5 +205,6 @@ function stripDotSuffix(domain) {
165
205
  }
166
206
 
167
207
  export {
168
- publishManifest
208
+ publishManifest,
209
+ registerOrEnsureResolver
169
210
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  package_default,
3
3
  writeRunState
4
- } from "./chunk-KQOQN5CN.js";
4
+ } from "./chunk-S2NLQSLC.js";
5
5
 
6
6
  // src/memory-report.ts
7
7
  import * as fs2 from "fs";
@@ -309,7 +309,7 @@ function getDeployAttributes(domain) {
309
309
  return attrs;
310
310
  }
311
311
  function isExpectedError(msg) {
312
- return /personhood|owned by|owner mismatch|reserved for original|invalid domain label|not authorized for bulletin|insufficient balance|insufficient funds|quota exhausted|insufficient .* authorization|bip39 mnemonic|ipfs cli not installed|base name is \d+ chars|NameNotAvailable|name must be lowercase/i.test(msg);
312
+ return /personhood|owned by|owner mismatch|reserved for original|invalid domain label|not authorized for bulletin|insufficient balance|insufficient funds|quota exhausted|insufficient .* authorization|bip39 mnemonic|ipfs cli not installed|base name is \d+ chars|cannot register it|NameNotAvailable|name must be lowercase/i.test(msg);
313
313
  }
314
314
  function classifyDeployError(msg) {
315
315
  if (isExpectedError(msg)) return "user";
@@ -348,6 +348,15 @@ var ERROR_KIND_RULES = [
348
348
  [/No contract deployed at .+ returned empty success data/i, "naming.contract_unavailable"],
349
349
  [/Contract call returned empty data — contract=/i, "naming.contract_unavailable"],
350
350
  [/Domain\s+\S+\.dot\s+is already owned by\s+0x[a-fA-F0-9]+/i, "naming.already_owned"],
351
+ // #1185: formatUnregistrableReason's distinctive phrase — present in both
352
+ // the unregistered ("...is not registered, and bulletin-deploy cannot
353
+ // register it: ...") and owned-by-another-account ("...is owned by
354
+ // 0x..., and bulletin-deploy cannot register it for a different account:
355
+ // ...") variants, so one rule classifies both. Ordered before
356
+ // naming.subdomain_orphan (a different message shape entirely) and after
357
+ // naming.already_owned (whose "already owned by 0x..." phrasing this rule
358
+ // never produces, so there's no overlap either way).
359
+ [/cannot register it/i, "naming.governance_reserved"],
351
360
  [/Cannot deploy\s+[\w.-]+\.dot:\s*parent\s+[\w.-]+\.dot\s+is owned by/i, "naming.subdomain_orphan"],
352
361
  [/Post-deploy verification failed for .+: on-chain contenthash is /i, "verify.contenthash_mismatch"],
353
362
  [/Deploy verification failed:\s*DAG-PB root.+not finalised/i, "verify.dagpb_not_finalised"],
@@ -576,6 +585,42 @@ async function withDeploySpan(domain, fn) {
576
585
  }
577
586
  }
578
587
  }
588
+ async function withManifestSpan(domain, fn) {
589
+ if (!Sentry) return fn();
590
+ const savedRootSpan = deployRootSpan;
591
+ const attrs = { ...getDeployAttributes(domain), "deploy.domain": domain };
592
+ try {
593
+ return await Sentry.startSpan(
594
+ { op: "deploy.manifest.publish", name: `manifest ${domain}`, attributes: attrs },
595
+ async (span) => {
596
+ deployRootSpan = span;
597
+ span.setAttribute("deploy.tool_version", VERSION);
598
+ span.setAttribute("deploy.domain", domain);
599
+ Sentry.setTags({
600
+ "deploy.repo": String(attrs["deploy.repo"] ?? ""),
601
+ "deploy.domain": domain
602
+ });
603
+ try {
604
+ const result = await fn();
605
+ span.setAttribute("deploy.manifest.status", "ok");
606
+ return result;
607
+ } catch (error) {
608
+ const msg = error.message ?? String(error);
609
+ setDeployErrorOnSpan(span, msg);
610
+ span.setAttribute("deploy.manifest.status", "error");
611
+ span.setStatus({ code: 2, message: "internal_error" });
612
+ throw error;
613
+ }
614
+ }
615
+ );
616
+ } finally {
617
+ deployRootSpan = savedRootSpan;
618
+ try {
619
+ await Sentry.flush(5e3);
620
+ } catch {
621
+ }
622
+ }
623
+ }
579
624
  function setDeployReportContext(patch) {
580
625
  reportContext = { ...reportContext, ...patch };
581
626
  }
@@ -788,6 +833,7 @@ export {
788
833
  withSpan,
789
834
  sampleMemory,
790
835
  withDeploySpan,
836
+ withManifestSpan,
791
837
  setDeployReportContext,
792
838
  setDeployAttribute,
793
839
  setDeployError,
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-TSPERKUS.js";
4
4
  import {
5
5
  VERSION
6
- } from "./chunk-7PDAOD6W.js";
6
+ } from "./chunk-4IPJMCP5.js";
7
7
  import {
8
8
  loadEnvironments
9
9
  } from "./chunk-3ZEMX4WD.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  captureWarning
3
- } from "./chunk-7PDAOD6W.js";
3
+ } from "./chunk-4IPJMCP5.js";
4
4
 
5
5
  // src/chunk-probe.ts
6
6
  import { Twox128, Blake2128Concat, decAnyMetadata, unifyMetadata } from "@polkadot-api/substrate-bindings";
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-GRPLHUYC.js";
4
4
  import {
5
5
  DOT_DAPP_ID
6
- } from "./chunk-RVSHGN74.js";
6
+ } from "./chunk-7XC2U7R4.js";
7
7
 
8
8
  // src/sss-allowance-cache.ts
9
9
  import { mkdir, readFile, writeFile, unlink } from "fs/promises";
@@ -6,7 +6,7 @@ import * as path from "path";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "bulletin-deploy",
9
- version: "0.13.2",
9
+ version: "0.14.0-rc.1",
10
10
  private: false,
11
11
  repository: {
12
12
  type: "git",
@@ -11,7 +11,7 @@ import {
11
11
  setDeploySentryTag,
12
12
  truncateAddress,
13
13
  withSpan
14
- } from "./chunk-7PDAOD6W.js";
14
+ } from "./chunk-4IPJMCP5.js";
15
15
  import {
16
16
  validateContractAddresses
17
17
  } from "./chunk-3ZEMX4WD.js";
@@ -578,28 +578,121 @@ function sanitizeDomainLabel(label) {
578
578
  console.log(` Domain label sanitized: "${label}" \u2192 "${stripped}" (normalized trailing digits to 0)`);
579
579
  return stripped;
580
580
  }
581
- function validateDomainLabel(label, opts = {}) {
582
- if (!/^[a-z0-9-]{3,63}$/.test(label)) throw new Error("Invalid domain label: must be 3-63 chars and contain only lowercase letters, digits, and hyphens");
583
- if (label.startsWith("-") || label.endsWith("-")) throw new Error("Invalid domain label: cannot start or end with hyphen");
584
- const sanitized = opts.skipSanitize ? label : sanitizeDomainLabel(label);
585
- if (!opts.skipSanitize && /-\d+$/.test(sanitized)) {
586
- const baseWithHyphen = sanitized.replace(/\d+$/, "");
587
- const dropHyphen = sanitized.replace(/-(\d+)$/, "$1");
588
- const insertSegment = sanitized.replace(/-(\d+)$/, "-pr$1");
589
- throw new Error(
590
- `Invalid domain label: "${sanitized}" \u2014 dotns base-name extraction leaves a trailing hyphen ("${baseWithHyphen}"), which the registry rejects with PopError("Name must be lowercase ASCII DNS label"). Drop the hyphen before the digits (e.g. "${dropHyphen}") or add a non-digit segment between (e.g. "${insertSegment}").`
591
- );
581
+ function classifyLabelStatus(label) {
582
+ const trailingDigits = countTrailingDigits(label);
583
+ const baseLength = label.length - trailingDigits;
584
+ if (trailingDigits === 1 || trailingDigits > 2 || baseLength <= 5) {
585
+ return { status: ProofOfPersonhoodStatus.Reserved, trailingDigits, baseLength };
592
586
  }
593
- if (opts.checkReserved !== false) {
594
- const classification = classifyDotnsLabel(sanitized);
595
- if (classification.status === ProofOfPersonhoodStatus.Reserved) {
596
- const sanitizeTrail = label !== sanitized ? `Input "${label}" was sanitized to "${sanitized}" (excess trailing digits trimmed). ` : "";
597
- throw new NonRetryableError(
598
- `${sanitizeTrail}Invalid domain label "${sanitized}": ${classification.message}`
599
- );
600
- }
587
+ if (baseLength <= 8) {
588
+ return { status: trailingDigits === 2 ? ProofOfPersonhoodStatus.ProofOfPersonhoodLite : ProofOfPersonhoodStatus.ProofOfPersonhoodFull, trailingDigits, baseLength };
589
+ }
590
+ return { status: ProofOfPersonhoodStatus.NoStatus, trailingDigits, baseLength };
591
+ }
592
+ function tierDescriptionFor(status) {
593
+ if (status === ProofOfPersonhoodStatus.NoStatus) return "open to any account";
594
+ if (status === ProofOfPersonhoodStatus.ProofOfPersonhoodLite) return "requires Personhood Lite";
595
+ if (status === ProofOfPersonhoodStatus.ProofOfPersonhoodFull) return "requires Personhood Full";
596
+ return "unavailable";
597
+ }
598
+ function noStatusFallbackBase(base) {
599
+ return `${base.padEnd(9, "x").slice(0, 9)}00`;
600
+ }
601
+ function buildLabelAlternatives(label) {
602
+ const trailingRun = label.slice(label.length - countTrailingDigits(label));
603
+ const base = stripTrailingDigits(label);
604
+ const twoDigitSuffix = trailingRun.length >= 2 ? trailingRun.slice(-2) : trailingRun.padStart(2, "0");
605
+ const noStatusBase = noStatusFallbackBase(base.replace(/[^a-z0-9-]/g, "x"));
606
+ const candidateLabels = [
607
+ `${base}${twoDigitSuffix}`,
608
+ // (a) 2 trailing digits
609
+ base,
610
+ // (b) 0 trailing digits
611
+ noStatusBase
612
+ // (c) NoStatus-safe fallback
613
+ ];
614
+ const seen = /* @__PURE__ */ new Set();
615
+ const alternatives = [];
616
+ for (const candidate of candidateLabels) {
617
+ if (seen.has(candidate)) continue;
618
+ seen.add(candidate);
619
+ if (!/^[a-z0-9-]{3,63}$/.test(candidate)) continue;
620
+ if (candidate.startsWith("-") || candidate.endsWith("-")) continue;
621
+ if (/-\d+$/.test(candidate)) continue;
622
+ const { status, baseLength } = classifyLabelStatus(candidate);
623
+ if (status === ProofOfPersonhoodStatus.Reserved) continue;
624
+ alternatives.push({ label: candidate, baseLength, status, tierDescription: tierDescriptionFor(status) });
625
+ }
626
+ return alternatives;
627
+ }
628
+ function formatAlternativesList(alternatives) {
629
+ return alternatives.map((a) => ` - ${a.label}.dot \u2014 base ${a.baseLength}, ${a.tierDescription}`).join("\n");
630
+ }
631
+ function classifyRegistrability(label) {
632
+ const { trailingDigits, baseLength } = classifyLabelStatus(label);
633
+ if (trailingDigits !== 0 && trailingDigits !== 2) {
634
+ const digitWord = trailingDigits === 1 ? "digit" : "digits";
635
+ return {
636
+ registrable: false,
637
+ rule: "trailing-digits",
638
+ message: `Name has ${trailingDigits} trailing ${digitWord}; DotNS allows exactly 0 or 2 trailing digits. Use a base name with no trailing digits or a 2-digit suffix.`
639
+ };
640
+ }
641
+ if (/-\d+$/.test(label)) {
642
+ const baseWithHyphen = label.replace(/\d+$/, "");
643
+ return {
644
+ registrable: false,
645
+ rule: "hyphen-base",
646
+ message: `dotns-cli's base-name extraction leaves a trailing hyphen ("${baseWithHyphen}"), which the registry rejects with PopError("Name must be lowercase ASCII DNS label").`
647
+ };
648
+ }
649
+ if (baseLength <= 5) {
650
+ return {
651
+ registrable: false,
652
+ rule: "reserved-base",
653
+ message: `Base name is ${baseLength} char${baseLength === 1 ? "" : "s"}; DotNS reserves base names of 5 chars or fewer for governance (PopRules).`
654
+ };
655
+ }
656
+ return { registrable: true };
657
+ }
658
+ function formatUnregistrableReason(args) {
659
+ const { label, registrability, existingOwner } = args;
660
+ const alternatives = buildLabelAlternatives(label);
661
+ const alternativesBlock = alternatives.length > 0 ? `
662
+
663
+ Alternatively, use a name you can register yourself:
664
+ ${formatAlternativesList(alternatives)}` : "";
665
+ if (existingOwner === null) {
666
+ return `${label}.dot is not registered, and bulletin-deploy cannot register it: ${registrability.message}
667
+
668
+ Names like this can only be registered by an account whitelisted on the DotNS controller:
669
+ - Request whitelisting at https://github.com/paritytech/dotns/issues/new/choose
670
+ - From the whitelisted account: dotns register domain -n ${label} --governance
671
+ - Transfer it to this signer if they differ
672
+ Then re-run this deploy unchanged.` + alternativesBlock;
601
673
  }
602
- return sanitized;
674
+ return `${label}.dot is owned by ${existingOwner}, and bulletin-deploy cannot register it for a different account: ${registrability.message}
675
+
676
+ This signer does not hold that name. If you control ${existingOwner}'s key, redeploy with that account (e.g. via --mnemonic) to update its content directly.` + alternativesBlock;
677
+ }
678
+ function decideRegistrabilityOutcome(args) {
679
+ const { label, registrability, existingOwner, selfAddress } = args;
680
+ if (existingOwner !== null && existingOwner === selfAddress) {
681
+ return { canProceed: true, plannedAction: "already-owned-by-us" };
682
+ }
683
+ if (registrability.registrable) {
684
+ return { canProceed: true, plannedAction: "register" };
685
+ }
686
+ return {
687
+ canProceed: false,
688
+ plannedAction: "abort",
689
+ reason: formatUnregistrableReason({ label, registrability, existingOwner, selfAddress })
690
+ };
691
+ }
692
+ function validateDomainLabel(label) {
693
+ if (!/^[a-z0-9-]{3,63}$/.test(label)) throw new Error("Invalid domain label: must be 3-63 chars and contain only lowercase letters, digits, and hyphens");
694
+ if (label.startsWith("-") || label.endsWith("-")) throw new Error("Invalid domain label: cannot start or end with hyphen");
695
+ return label;
603
696
  }
604
697
  function isCommitmentMature(chainNowSeconds, commitTimestampSeconds, minimumAgeSeconds) {
605
698
  return chainNowSeconds > commitTimestampSeconds + minimumAgeSeconds;
@@ -608,26 +701,27 @@ function isCommitmentTimingBarerevert(msg) {
608
701
  return /bare-revert.*\(empty 0x\)/i.test(msg) || /commitment.*too new.*expired/i.test(msg) || /expired.*commitment/i.test(msg);
609
702
  }
610
703
  function classifyDotnsLabel(label) {
611
- const totalLength = label.length;
612
- const trailingDigits = countTrailingDigits(label);
613
- if (trailingDigits === 1 || trailingDigits > 2) {
614
- return {
615
- status: ProofOfPersonhoodStatus.Reserved,
616
- message: `Name has ${trailingDigits} trailing digit${trailingDigits === 1 ? "" : "s"}; DotNS allows exactly 0 or 2 trailing digits. Use a base name with no trailing digits or a 2-digit suffix.`
617
- };
618
- }
619
- const baselength = totalLength - trailingDigits;
620
- if (baselength <= 5) {
704
+ const { status, trailingDigits, baseLength } = classifyLabelStatus(label);
705
+ if (status === ProofOfPersonhoodStatus.Reserved) {
706
+ if (trailingDigits === 1 || trailingDigits > 2) {
707
+ return {
708
+ status,
709
+ message: `Name has ${trailingDigits} trailing digit${trailingDigits === 1 ? "" : "s"}; DotNS allows exactly 0 or 2 trailing digits. Use a base name with no trailing digits or a 2-digit suffix.`
710
+ };
711
+ }
712
+ const alternatives = buildLabelAlternatives(label);
713
+ const suggestion = alternatives.length > 0 ? `
714
+
715
+ Use a name you can register instead:
716
+ ${formatAlternativesList(alternatives)}` : "";
621
717
  return {
622
- status: ProofOfPersonhoodStatus.Reserved,
623
- message: `Base name is ${baselength} char${baselength === 1 ? "" : "s"}; DotNS reserves base names of 5 chars or fewer for governance (PopRules). Use a base name of 6+ chars \u2014 role prefixes like 'rc<N>pool' / 'rc<N>dir' / 'nightly-<role>' work well.`
718
+ status,
719
+ message: `Base name is ${baseLength} char${baseLength === 1 ? "" : "s"}; DotNS reserves base names of 5 chars or fewer for governance (PopRules).${suggestion}`
624
720
  };
625
721
  }
626
- if (baselength >= 6 && baselength <= 8) {
627
- if (trailingDigits === 2) return { status: ProofOfPersonhoodStatus.ProofOfPersonhoodLite, message: "Requires Light personhood verification" };
628
- return { status: ProofOfPersonhoodStatus.ProofOfPersonhoodFull, message: "Requires Full personhood verification" };
629
- }
630
- return { status: ProofOfPersonhoodStatus.NoStatus, message: "Available to all" };
722
+ if (status === ProofOfPersonhoodStatus.ProofOfPersonhoodLite) return { status, message: "Requires Light personhood verification" };
723
+ if (status === ProofOfPersonhoodStatus.ProofOfPersonhoodFull) return { status, message: "Requires Full personhood verification" };
724
+ return { status, message: "Available to all" };
631
725
  }
632
726
  function canRegister(requiredStatus, userStatus) {
633
727
  if (requiredStatus === ProofOfPersonhoodStatus.Reserved) return false;
@@ -640,8 +734,8 @@ function canRegister(requiredStatus, userStatus) {
640
734
  return true;
641
735
  }
642
736
  function exampleNoStatusLabel(label) {
643
- const base = stripTrailingDigits(validateDomainLabel(label, { checkReserved: false })).replace(/[^a-z0-9-]/g, "x");
644
- return `${base.padEnd(9, "x").slice(0, 9)}00.dot`;
737
+ const base = stripTrailingDigits(label).replace(/[^a-z0-9-]/g, "x");
738
+ return `${noStatusFallbackBase(base)}.dot`;
645
739
  }
646
740
  function parseDomainName(input) {
647
741
  const name = input.replace(/\.dot$/, "");
@@ -651,7 +745,7 @@ function parseDomainName(input) {
651
745
  return { isSubdomain: false, label: sanitized, sublabel: null, parentLabel: null, fullName: `${sanitized}.dot` };
652
746
  }
653
747
  if (parts.length === 2) {
654
- const sanitizedSub = validateDomainLabel(parts[0], { checkReserved: false, skipSanitize: true });
748
+ const sanitizedSub = validateDomainLabel(parts[0]);
655
749
  const sanitizedParent = validateDomainLabel(parts[1]);
656
750
  const fullLabel = `${sanitizedSub}.${sanitizedParent}`;
657
751
  return { isSubdomain: true, label: fullLabel, sublabel: sanitizedSub, parentLabel: sanitizedParent, fullName: `${fullLabel}.dot` };
@@ -1887,6 +1981,9 @@ var DotNS = class {
1887
1981
  label
1888
1982
  );
1889
1983
  }
1984
+ // `skipped` (#1187): set true on the pre-check fast path so callers (e.g.
1985
+ // manifest publish's telemetry counters) can tell a no-op skip from a real
1986
+ // on-chain write without a second read of their own.
1890
1987
  async setContenthash(domainName, contenthashHex, opts = {}) {
1891
1988
  return withSpan("deploy.dotns.set-contenthash", "2b. set-contenthash", {}, async () => {
1892
1989
  this.ensureConnected();
@@ -1907,7 +2004,7 @@ var DotNS = class {
1907
2004
  if (current === expected) {
1908
2005
  console.log(` Contenthash already set: ${ipfsCid} \u2014 skipping tx`);
1909
2006
  setDeployAttribute("deploy.dotns.contenthash_unchanged", "true");
1910
- return { node };
2007
+ return { node, skipped: true };
1911
2008
  }
1912
2009
  } catch (_) {
1913
2010
  }
@@ -1979,7 +2076,7 @@ var DotNS = class {
1979
2076
  }
1980
2077
  console.log(` Verified on-chain: ${ipfsCid}
1981
2078
  `);
1982
- return { node };
2079
+ return { node, skipped: false };
1983
2080
  });
1984
2081
  }
1985
2082
  /**
@@ -2498,29 +2595,39 @@ var DotNS = class {
2498
2595
  const trailingDigits = countTrailingDigits(validated);
2499
2596
  const baselength = validated.length - trailingDigits;
2500
2597
  const classification = classifyDotnsLabel(validated);
2501
- if (classification.status === ProofOfPersonhoodStatus.Reserved) {
2502
- const sanitizeTrail = label !== validated ? `Input "${label}" was sanitized to "${validated}" (excess trailing digits trimmed). ` : "";
2503
- return {
2504
- label: validated,
2505
- classification,
2506
- userStatus: 0,
2507
- trailingDigits,
2508
- baselength,
2509
- isAvailable: false,
2510
- existingOwner: null,
2511
- isBaseNameReserved: false,
2512
- reservationOwner: null,
2513
- isTestnet: false,
2514
- canProceed: false,
2515
- reason: `${sanitizeTrail}${classification.message}`,
2516
- plannedAction: "abort",
2517
- needsPopUpgrade: false
2518
- };
2519
- }
2598
+ const registrability = classifyRegistrability(validated);
2520
2599
  const baseName = stripTrailingDigits(validated);
2521
2600
  const [userStatus, baseReservation, ownership, isTestnet, signerFreeBalance] = await Promise.all([
2522
2601
  this.getUserPopStatus(),
2523
- withTimeout(this.contractCall(this._contracts.POP_RULES, POP_RULES_ABI, "isBaseNameReserved", [baseName]), 3e4, "isBaseNameReserved"),
2602
+ // Non-fatal (#1185): deleting the early Reserved abort above widens
2603
+ // what reaches this call — every 1-digit, 3+-digit, hyphen-base, and
2604
+ // short-base label now hits isBaseNameReserved where previously only
2605
+ // "registrable" labels did. A hyphen-adjacent base (e.g. from
2606
+ // `foo--88`, where stripTrailingDigits only removes ONE trailing
2607
+ // hyphen) can revert here; treat a revert/empty-data the same as
2608
+ // "not reserved" rather than failing preflight over a read that is
2609
+ // advisory context, not an authorization gate. Uses the same
2610
+ // this.contractCall the pre-#1185 code used (not contractCallNullable
2611
+ // — a different RPC path with its own stub surface); only the
2612
+ // fatal-vs-non-fatal handling changes, via a plain try/catch.
2613
+ //
2614
+ // Code review finding: a blanket catch-all here would also swallow a
2615
+ // genuine RPC/connection failure (timeout, WS drop) and silently
2616
+ // default to "not reserved" — fail-open on network noise, not just
2617
+ // on a real revert. Rethrow anything that looks like the read itself
2618
+ // never completed; only default when the dry-run call DID complete
2619
+ // (a revert or empty-data response).
2620
+ (async () => {
2621
+ try {
2622
+ return await withTimeout(this.contractCall(this._contracts.POP_RULES, POP_RULES_ABI, "isBaseNameReserved", [baseName]), 3e4, "isBaseNameReserved");
2623
+ } catch (e) {
2624
+ const msg = e?.message ?? String(e);
2625
+ if (/timed out after \d+ms|heartbeat timeout|WS halt|Unable to connect|ChainHead disjointed|websocket.*closed|socket closed|disconnect/i.test(msg)) {
2626
+ throw e;
2627
+ }
2628
+ return [false, zeroAddress, 0n];
2629
+ }
2630
+ })(),
2524
2631
  this.checkOwnership(validated),
2525
2632
  this.isTestnet(),
2526
2633
  this.readFreeBalance(this.substrateAddress)
@@ -2567,6 +2674,28 @@ var DotNS = class {
2567
2674
  signerFreeBalance
2568
2675
  };
2569
2676
  }
2677
+ if (!registrability.registrable) {
2678
+ const decision = decideRegistrabilityOutcome({ label: validated, registrability, existingOwner, selfAddress });
2679
+ if (!decision.canProceed) {
2680
+ return {
2681
+ label: validated,
2682
+ classification,
2683
+ userStatus,
2684
+ trailingDigits,
2685
+ baselength,
2686
+ isAvailable: true,
2687
+ existingOwner,
2688
+ isBaseNameReserved: isReserved,
2689
+ reservationOwner,
2690
+ isTestnet,
2691
+ canProceed: false,
2692
+ reason: decision.reason,
2693
+ plannedAction: "abort",
2694
+ needsPopUpgrade: false,
2695
+ signerFreeBalance
2696
+ };
2697
+ }
2698
+ }
2570
2699
  let transferFeeNative = 0n;
2571
2700
  if (transferRecipientH160) {
2572
2701
  try {
@@ -2790,10 +2919,10 @@ var DotNS = class {
2790
2919
  return withSpan("deploy.dotns.register", `2a. register ${label}.dot`, {}, async () => {
2791
2920
  if (!this.connected) await this.connect(options);
2792
2921
  label = validateDomainLabel(label);
2793
- const preClassification = classifyDotnsLabel(label);
2794
- const preRequiredStatus = preClassification.status;
2795
- if (preRequiredStatus === ProofOfPersonhoodStatus.Reserved) {
2796
- throw new Error(preClassification.message);
2922
+ const registrability = classifyRegistrability(label);
2923
+ if (!registrability.registrable) {
2924
+ const decision = decideRegistrabilityOutcome({ label, registrability, existingOwner: null, selfAddress: this.evmAddress.toLowerCase() });
2925
+ throw new NonRetryableError(decision.reason);
2797
2926
  }
2798
2927
  const isTestnet = await this.isTestnet();
2799
2928
  const registerAliasState = isTestnet && this._popSelfServe?.stateAwareGuidance === true && this.substrateAddress ? await this.classifyAliasAccountState(this.substrateAddress) : null;
@@ -2817,9 +2946,6 @@ var DotNS = class {
2817
2946
  this.ensureNotRegistered(label)
2818
2947
  ]);
2819
2948
  const requiredStatus = classification.requiredStatus;
2820
- if (requiredStatus === ProofOfPersonhoodStatus.Reserved) {
2821
- throw new Error(classification.message);
2822
- }
2823
2949
  const userStatus = await this.getUserPopStatus();
2824
2950
  if (!canRegister(requiredStatus, userStatus)) {
2825
2951
  rejectIneligible(requiredStatus, userStatus);
@@ -3018,6 +3144,10 @@ export {
3018
3144
  countTrailingDigits,
3019
3145
  stripTrailingDigits,
3020
3146
  sanitizeDomainLabel,
3147
+ buildLabelAlternatives,
3148
+ classifyRegistrability,
3149
+ formatUnregistrableReason,
3150
+ decideRegistrabilityOutcome,
3021
3151
  validateDomainLabel,
3022
3152
  isCommitmentMature,
3023
3153
  isCommitmentTimingBarerevert,