From 846d9cb31ce8127696a2f7ccf0a81c44aeb8d08c Mon Sep 17 00:00:00 2001 From: distributed-nerd Date: Mon, 19 Oct 2026 13:04:18 +0100 Subject: [PATCH] feat: add flag-gated integration architecture spike --- PROJECT_ARCHITECTURE.md | 4 +++ app/.well-known/stellar.toml/route.ts | 1 + app/chamber/loading.tsx | 3 +++ app/chamber/page.tsx | 4 +++ app/explore/loading.tsx | 3 +++ app/profile/[wallet]/loading.tsx | 3 +++ app/profile/[wallet]/page.tsx | 18 ++++++++----- app/signals/[id]/page.tsx | 3 ++- app/signals/loading.tsx | 3 +++ app/signals/page.tsx | 3 ++- app/world/[collection_id]/loading.tsx | 3 +++ app/world/[collection_id]/page.tsx | 14 +++++++--- components/crt-collection-preview.tsx | 10 +++++--- components/ipfs-display-img.tsx | 37 ++++++++++++++++++++++++--- components/wallet-avatar.tsx | 13 ++++++---- docs/TECHNICAL.md | 14 ++++++++++ lib/feature-flags.ts | 18 ++++++++++++- lib/phase-streaming.ts | 19 ++++++++++++++ next.config.mjs | 21 +++++++++++++++ scripts/package.json | 1 + scripts/set-issuer-home-domain.ts | 37 +++++++++++++++++++++++++++ 21 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 app/chamber/loading.tsx create mode 100644 app/explore/loading.tsx create mode 100644 app/profile/[wallet]/loading.tsx create mode 100644 app/signals/loading.tsx create mode 100644 app/world/[collection_id]/loading.tsx create mode 100644 lib/phase-streaming.ts diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 999fccb1..a3a84df2 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -211,6 +211,10 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-167` | `NEXT_PUBLIC_FEATURE_PHASE_167` / `FEATURE_PHASE_167` | Data portability export per wallet with GDPR Article 20 bundle (`lib/data-portability.ts`): cross-domain data aggregation (profile, follows, notifications, market, signals, badges, consents) with per-domain and bundle SHA-256 checksums, third-party PII redaction, and `Content-Disposition` download headers via `GET /api/user/export` and `GET /api/profile/export`. Spike 634 (issue #496) | off | Unset var, restart — export routes return 404, no data migration to revert | | `phase-168` | `NEXT_PUBLIC_FEATURE_PHASE_168` / `FEATURE_PHASE_168` | Audit exports for moderators and regulators with redaction and role-gating (`lib/moderation-audit-export.ts`): multi-format (CSV, NDJSON, JSON) streaming via `GET /api/moderation/export`, role authorization (`moderator` vs `regulator`), cryptographic wallet fingerprinting, and signature stripping for regulatory oversight. Spike 635 (issue #497) | off | Unset var, restart — export endpoint returns 404, moderation audit events untouched | | `phase-169` | `NEXT_PUBLIC_FEATURE_PHASE_169` / `FEATURE_PHASE_169` | Legal-hold preservation with deny-delete during investigation (`lib/legal-hold.ts`): WORM retention clock, custodian authorization, cascade enforcement across wallets, and hard denial of signal takedowns, profile overwrites, and market cancellations. Spike 636 (issue #498) | off | Unset var, restart — legal-hold routes return 404, deletion/modification gates bypassed; `legal-holds.json` stays inert | +| `phase-170` | `NEXT_PUBLIC_FEATURE_PHASE_170` / `FEATURE_PHASE_170` | RSC streaming boundary preparation: force-dynamic read pages, request-scoped React cache dedupe, and independent route loading fallbacks | off | Unset var, restart — legacy blocking reads remain; no data migration | +| `phase-171` | `NEXT_PUBLIC_FEATURE_PHASE_171` / `FEATURE_PHASE_171` | Proxy-first `next/image` rendering for IPFS assets with AVIF/WebP negotiation, responsive sizes, priority/LQIP hooks, and gateway rotation | off | Unset var, restart — `IpfsDisplayImg` uses the legacy raw-image gateway path | +| `phase-172` | `NEXT_PUBLIC_FEATURE_PHASE_172` / `FEATURE_PHASE_172` | Build-level import optimization and route splitting foundations for wallet, chart, animation, and Radix dependencies | off | Unset var, restart — existing bundle behavior remains; no persisted state | +| `phase-173` | `NEXT_PUBLIC_FEATURE_PHASE_173` / `FEATURE_PHASE_173` | Issuer home-domain ceremony preflight and Horizon confirmation: DNS, exact TOML row, HTTPS image, idempotent dry-run, and post-submit verification | off | Unset var, restart — manual issuer script behavior remains; no on-chain rollback required | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/app/.well-known/stellar.toml/route.ts b/app/.well-known/stellar.toml/route.ts index be265079..c7196c69 100644 --- a/app/.well-known/stellar.toml/route.ts +++ b/app/.well-known/stellar.toml/route.ts @@ -61,6 +61,7 @@ ${currencyBlocks} status: 200, headers: { "Content-Type": "text/plain; charset=utf-8", + "Content-Length": String(new TextEncoder().encode(body).byteLength), "Cache-Control": "no-store, max-age=0", // Freighter / dApp (Origin www.phasee.xyz) y stellar.expert deben poder leer el TOML (CORS). "Access-Control-Allow-Origin": "*", diff --git a/app/chamber/loading.tsx b/app/chamber/loading.tsx new file mode 100644 index 00000000..84c37e5e --- /dev/null +++ b/app/chamber/loading.tsx @@ -0,0 +1,3 @@ +export default function ChamberLoading() { + return
+} diff --git a/app/chamber/page.tsx b/app/chamber/page.tsx index 37bbdf05..02f1ca7b 100644 --- a/app/chamber/page.tsx +++ b/app/chamber/page.tsx @@ -5,6 +5,9 @@ import { FusionChamber } from "@/components/fusion-chamber" import { fetchCollectionInfo } from "@/lib/phase-protocol" import { publicPhaseSiteBaseUrl } from "@/lib/phase-nft-metadata-build" import { getWorldForCollection } from "@/lib/narrative-world-store" +import { preparePhaseStreaming } from "@/lib/phase-streaming" + +export const dynamic = "force-dynamic" type Props = { searchParams: Promise> @@ -101,6 +104,7 @@ export async function generateMetadata({ searchParams }: Props): Promise}> diff --git a/app/explore/loading.tsx b/app/explore/loading.tsx new file mode 100644 index 00000000..61e5dd0d --- /dev/null +++ b/app/explore/loading.tsx @@ -0,0 +1,3 @@ +export default function ExploreLoading() { + return
+} diff --git a/app/profile/[wallet]/loading.tsx b/app/profile/[wallet]/loading.tsx new file mode 100644 index 00000000..d9d6e917 --- /dev/null +++ b/app/profile/[wallet]/loading.tsx @@ -0,0 +1,3 @@ +export default function ProfileLoading() { + return
+} diff --git a/app/profile/[wallet]/page.tsx b/app/profile/[wallet]/page.tsx index b06726c5..337c3b3f 100644 --- a/app/profile/[wallet]/page.tsx +++ b/app/profile/[wallet]/page.tsx @@ -7,6 +7,10 @@ import { getSignals } from "@/lib/signal-store" import { getAchievements } from "@/lib/achievement-store" import { SocialChipsPublic } from "./social-chips-public" import { FollowButton, FollowSuggestions } from "./follow-button" +import { preparePhaseStreaming } from "@/lib/phase-streaming" +import { IpfsDisplayImg } from "@/components/ipfs-display-img" + +export const dynamic = "force-dynamic" type Props = { params: Promise<{ wallet: string }> @@ -73,6 +77,7 @@ export async function generateMetadata({ params }: Props): Promise { } export default async function ProfilePage({ params }: Props) { + preparePhaseStreaming() const { wallet } = await params if (!wallet || wallet.length < 10) notFound() @@ -159,10 +164,7 @@ export default async function ProfilePage({ params }: Props) { MINT - {nft.image && ( - // eslint-disable-next-line @next/next/no-img-element - {nft.name} - )} + {nft.image && }

{nft.name}

))} @@ -216,11 +218,13 @@ export default async function ProfilePage({ params }: Props) { title={nft.name} > {nft.image ? ( - // eslint-disable-next-line @next/next/no-img-element - {nft.name} ) : (
diff --git a/app/signals/[id]/page.tsx b/app/signals/[id]/page.tsx index cbd968c9..22ad5331 100644 --- a/app/signals/[id]/page.tsx +++ b/app/signals/[id]/page.tsx @@ -5,6 +5,7 @@ import { getSignal, getReplies, getSignalContributors, computeCreditLedger, isPh import { ModerationHoldNotice } from "@/components/moderation-hold-notice" import { isFaucetDenyListEnabled, isWalletDenied } from "@/lib/faucet-deny-list" import { SignalDetailClient } from "./signal-detail-client" +import { IpfsDisplayImg } from "@/components/ipfs-display-img" export const dynamic = "force-dynamic" @@ -140,7 +141,7 @@ export default async function SignalDetailPage({ params }: Props) {
{nftImageSrc && ( // eslint-disable-next-line @next/next/no-img-element - {signal.nft_name} + )}
{signal.nft_name} diff --git a/app/signals/loading.tsx b/app/signals/loading.tsx new file mode 100644 index 00000000..1a55438a --- /dev/null +++ b/app/signals/loading.tsx @@ -0,0 +1,3 @@ +export default function SignalsLoading() { + return
+} diff --git a/app/signals/page.tsx b/app/signals/page.tsx index 2589424e..32dba1ba 100644 --- a/app/signals/page.tsx +++ b/app/signals/page.tsx @@ -9,6 +9,7 @@ import { SignalCompose } from "@/components/signal-compose" import { SocialChips } from "@/components/profile-panel" import { WalletAvatar } from "@/components/wallet-avatar" import type { Signal } from "@/lib/signal-store" +import { IpfsDisplayImg } from "@/components/ipfs-display-img" type ChannelStat = { id: string; label: string; count: number } @@ -240,7 +241,7 @@ function PostCard({ > {signal.nft_image && ( // eslint-disable-next-line @next/next/no-img-element - {signal.nft_name} + )} {signal.nft_name} {nftVerified === "pending" ? ( diff --git a/app/world/[collection_id]/loading.tsx b/app/world/[collection_id]/loading.tsx new file mode 100644 index 00000000..44b58301 --- /dev/null +++ b/app/world/[collection_id]/loading.tsx @@ -0,0 +1,3 @@ +export default function WorldCollectionLoading() { + return
+} diff --git a/app/world/[collection_id]/page.tsx b/app/world/[collection_id]/page.tsx index 43a6d49c..561a0d84 100644 --- a/app/world/[collection_id]/page.tsx +++ b/app/world/[collection_id]/page.tsx @@ -15,6 +15,10 @@ import { buildPhaseTokenMetadataJson, publicPhaseSiteBaseUrl, } from "@/lib/phase-nft-metadata-build" +import { preparePhaseStreaming } from "@/lib/phase-streaming" +import { IpfsDisplayImg } from "@/components/ipfs-display-img" + +export const dynamic = "force-dynamic" const navLink = "inline-flex min-h-[36px] items-center border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-400 transition-colors hover:border-violet-500/50 hover:text-violet-300" @@ -119,6 +123,7 @@ function StoryTimelineVisualizer({ } export default async function WorldGalleryPage({ params }: Props) { + preparePhaseStreaming() const { collection_id } = await params const collectionId = parseInt(collection_id, 10) @@ -244,13 +249,14 @@ export default async function WorldGalleryPage({ params }: Props) { >
{nft.image ? ( - // eslint-disable-next-line @next/next/no-img-element - {nft.name} ) : (
diff --git a/components/crt-collection-preview.tsx b/components/crt-collection-preview.tsx index 27c09082..15d34e14 100644 --- a/components/crt-collection-preview.tsx +++ b/components/crt-collection-preview.tsx @@ -2,6 +2,7 @@ import Link from "next/link" import { useToastQueue } from "@/hooks/use-toast-queue" +import { IpfsDisplayImg } from "@/components/ipfs-display-img" type Attribute = { trait_type: string @@ -136,11 +137,14 @@ export function CrtCollectionPreview({ style={CARD_GLOW} > {image ? ( - // eslint-disable-next-line @next/next/no-img-element - {name} ) : (
diff --git a/components/ipfs-display-img.tsx b/components/ipfs-display-img.tsx index 4584ba6d..66d0c84b 100644 --- a/components/ipfs-display-img.tsx +++ b/components/ipfs-display-img.tsx @@ -1,6 +1,8 @@ "use client" +import Image from "next/image" import { ipfsHttpsGatewayUrls } from "@/lib/phase-protocol" +import { isFeatureEnabled } from "@/lib/feature-flags" import { useEffect, useMemo, useState, type ReactNode } from "react" type Props = { @@ -18,9 +20,13 @@ type Props = { * in an ordered hang (phase-142) and shifts every later work. */ fallback?: ReactNode + priority?: boolean + sizes?: string + width?: number + height?: number } -export function IpfsDisplayImg({ uri, alt = "", className, loading = "lazy", variant, focalPoint, onExhausted }: Props) { +export function IpfsDisplayImg({ uri, alt = "", className, loading = "lazy", variant, focalPoint, onExhausted, priority, sizes, width = 640, height = 640 }: Props) { const candidates = useMemo(() => { const urls = ipfsHttpsGatewayUrls(uri) if (!variant) return urls @@ -43,8 +49,33 @@ export function IpfsDisplayImg({ uri, alt = "", className, loading = "lazy", var if (!uri.trim() || !src || exhausted) return fallback ? <>{fallback} : null - return ( - // eslint-disable-next-line @next/next/no-img-element -- remote IPFS / HTTPS previews + const optimized = isFeatureEnabled("phase-171") + + return optimized ? ( + {alt} { + if (idx + 1 < candidates.length) setIdx((i) => i + 1) + else { + if (fallback) setExhausted(true) + onExhausted?.() + } + }} + /> + ) : ( + // eslint-disable-next-line @next/next/no-img-element -- legacy flag-off path - {/* eslint-disable-next-line @next/next/no-img-element */} - {avatar.name}
) @@ -207,4 +210,4 @@ export function WalletAvatar({ {initials}
) -} \ No newline at end of file +} diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index ddd3e023..05970802 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -435,6 +435,20 @@ Agent tuning (all optional, server-only): Rollback: unset the var or set `0` and restart. No ledger migration to revert; off-chain stores remain but are ignored when flag off. See `PROJECT_ARCHITECTURE.md` §10. +### 9.2 Integration architecture spike (phases 170–173) + +The integration slice is intentionally default-off. `phase-170` enables request-scoped RSC preparation and loading boundaries on latency-heavy server pages; `phase-171` enables `IpfsDisplayImg`'s proxy-first `next/image` path and responsive optimizer settings; `phase-172` enables the package import optimization build configuration; and `phase-173` governs the issuer ceremony's DNS/TOML/image preflight and Horizon re-read. Roll back by unsetting the corresponding environment variable and restarting. Existing JSON sidecars, contract state, and raw image URLs remain inert when disabled. + +Issuer ceremony commands: + +```sh +cd scripts +npm run verify:issuer-home-domain # preflight + signed XDR, no submit +npm run set:issuer-home-domain # preflight, submit, then re-read Horizon +``` + +Set `FEATURE_PHASE_173=1` for the verified ceremony path. `ISSUER_HOME_DOMAIN_SKIP_PREFLIGHT=1` is an explicit emergency escape hatch for a controlled testnet ceremony; `ISSUER_HOME_DOMAIN_DRY_RUN=1` never submits. The script does not change wallet signing boundaries: only the issuer secret signs `set_options`, and user trustline/payment signing remains client-side. + --- ## 10. Security and Operations diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 0ccf17cb..dada617e 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -62,6 +62,10 @@ * - phase-167: data portability export per wallet with GDPR Article 20 bundle (spike 634 / issue #496) * - phase-168: audit exports for moderators and regulators with redaction and role-gating (spike 635 / issue #497) * - phase-169: legal-hold preservation with deny-delete during investigation (spike 636 / issue #498) + * - phase-170: RSC streaming boundaries for latency-heavy read pages + * - phase-171: optimized, proxy-first IPFS image rendering + * - phase-172: route-level bundle and wallet loading optimizations + * - phase-173: verified issuer home-domain ceremony automation */ export type PhaseFeatureFlag = @@ -136,7 +140,11 @@ export type PhaseFeatureFlag = | "phase-166" | "phase-167" | "phase-168" - | "phase-169"; + | "phase-169" + | "phase-170" + | "phase-171" + | "phase-172" + | "phase-173"; const FLAG_ENV_MAP: Record = { "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], @@ -210,6 +218,10 @@ const FLAG_ENV_MAP: Record = { "phase-167": ["NEXT_PUBLIC_FEATURE_PHASE_167", "FEATURE_PHASE_167"], "phase-168": ["NEXT_PUBLIC_FEATURE_PHASE_168", "FEATURE_PHASE_168"], "phase-169": ["NEXT_PUBLIC_FEATURE_PHASE_169", "FEATURE_PHASE_169"], + "phase-170": ["NEXT_PUBLIC_FEATURE_PHASE_170", "FEATURE_PHASE_170"], + "phase-171": ["NEXT_PUBLIC_FEATURE_PHASE_171", "FEATURE_PHASE_171"], + "phase-172": ["NEXT_PUBLIC_FEATURE_PHASE_172", "FEATURE_PHASE_172"], + "phase-173": ["NEXT_PUBLIC_FEATURE_PHASE_173", "FEATURE_PHASE_173"], }; function isTruthy(v: string | undefined): boolean { @@ -316,6 +328,10 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-167", "phase-168", "phase-169", + "phase-170", + "phase-171", + "phase-172", + "phase-173", ]; // Filtering is evaluated at call time, allowing runtime test overrides. return all.filter(isFeatureEnabled) diff --git a/lib/phase-streaming.ts b/lib/phase-streaming.ts new file mode 100644 index 00000000..2f122743 --- /dev/null +++ b/lib/phase-streaming.ts @@ -0,0 +1,19 @@ +import { cache } from "react" +import { unstable_noStore as noStore } from "next/cache" +import { isFeatureEnabled } from "@/lib/feature-flags" + +/** Shared RSC primitives for latency-heavy pages. Disabled flags preserve legacy rendering. */ +export function preparePhaseStreaming(): void { + if (isFeatureEnabled("phase-170")) noStore() +} + +/** React.cache dedupes metadata reads within one server render request. */ +export const cachePhaseRead = ( + reader: (...args: TArgs) => Promise, +) => cache(reader) + +export function phaseReadHeaders(): HeadersInit { + return isFeatureEnabled("phase-170") + ? { "Cache-Control": "private, no-store", Vary: "RSC, Next-Router-State-Tree" } + : {} +} diff --git a/next.config.mjs b/next.config.mjs index 81318737..0fc2c156 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,11 +1,32 @@ +const phase172Enabled = ["1", "true", "yes", "on"].includes( + (process.env.NEXT_PUBLIC_FEATURE_PHASE_172 ?? "").trim().toLowerCase(), +) +const phase171Enabled = ["1", "true", "yes", "on"].includes( + (process.env.NEXT_PUBLIC_FEATURE_PHASE_171 ?? "").trim().toLowerCase(), +) + /** @type {import('next').NextConfig} */ const nextConfig = { transpilePackages: ["@creit.tech/stellar-wallets-kit"], + ...(phase172Enabled + ? { + optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "recharts", "date-fns"], + bundlePagesRouterDependencies: true, + } + : {}), // Rust build trees are committed in this repo; exclude from serverless traces (250 MB limit on Vercel). outputFileTracingExcludes: { "*": ["./contracts/**/*", "./scripts/**/*"], }, images: { + ...(phase171Enabled + ? { + formats: ["image/avif", "image/webp"], + minimumCacheTTL: 86400, + deviceSizes: [320, 480, 640, 768, 1024, 1280, 1536, 1920], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + } + : {}), remotePatterns: [ { protocol: "https", diff --git a/scripts/package.json b/scripts/package.json index c4910ee6..fcd21621 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -19,6 +19,7 @@ "issue:sac": "tsx issue-sac-token.ts", "setup:phase-v2": "tsx setup-phase-v2.ts", "set:issuer-home-domain": "tsx set-issuer-home-domain.ts", + "verify:issuer-home-domain": "FEATURE_PHASE_173=1 ISSUER_HOME_DOMAIN_DRY_RUN=1 tsx set-issuer-home-domain.ts", "issuer-to-distributor": "tsx issuer-pay-distributor.ts", "distributor-trust-and-pay": "tsx distributor-trust-and-payment.ts", "reset:phase": "tsx reset-phase.ts", diff --git a/scripts/set-issuer-home-domain.ts b/scripts/set-issuer-home-domain.ts index aeaecd93..f836bb11 100644 --- a/scripts/set-issuer-home-domain.ts +++ b/scripts/set-issuer-home-domain.ts @@ -12,6 +12,7 @@ * NEXT_PUBLIC_SITE_URL — si no hay la anterior, se usa el host de esta URL */ import * as dotenv from "dotenv" +import { lookup } from "node:dns/promises" import * as path from "node:path" import { fileURLToPath } from "node:url" import { BASE_FEE, Horizon, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk" @@ -45,7 +46,26 @@ function stripUrlToHost(raw: string): string { .toLowerCase() } +async function verifyPublishedToml(homeDomain: string, issuer: string, code: string): Promise { + const addresses = await lookup(homeDomain, { all: true }) + if (!addresses.length) throw new Error(`DNS lookup returned no address for ${homeDomain}`) + const url = `https://${homeDomain}/.well-known/stellar.toml` + const response = await fetch(url, { cache: "no-store", headers: { Accept: "text/plain" } }) + if (!response.ok) throw new Error(`TOML preflight failed (${response.status}) at ${url}`) + const body = await response.text() + if (!body.includes(`code=\"${code}\"`) || !body.includes(`issuer=\"${issuer}\"`)) { + throw new Error(`TOML preflight found no ${code}-${issuer} currency row`) + } + const image = body.match(/image=\"([^\"]+)\"/)?.[1] ?? "" + if (!image.startsWith("https://")) throw new Error("TOML image must be an absolute HTTPS URL") + const imageResponse = await fetch(image, { method: "HEAD", cache: "no-store" }) + if (!imageResponse.ok) throw new Error(`TOML image preflight failed (${imageResponse.status})`) +} + async function main() { + const phase173Enabled = ["1", "true", "yes", "on"].includes( + (process.env.FEATURE_PHASE_173 ?? process.env.NEXT_PUBLIC_FEATURE_PHASE_173 ?? "").trim().toLowerCase(), + ) const secret = process.env.CLASSIC_LIQ_ISSUER_SECRET?.trim() if (!secret || secret.length < 20) { console.error( @@ -70,6 +90,13 @@ async function main() { const server = new Horizon.Server(HORIZON_URL) const pub = kp.publicKey() + const assetCode = pub === LEGACY_PHASERLIQ_ISSUER + ? "PHASERLIQ" + : process.env.NEXT_PUBLIC_CLASSIC_LIQ_ASSET_CODE?.trim() || "PHASELQ" + if (phase173Enabled && process.env.ISSUER_HOME_DOMAIN_SKIP_PREFLIGHT !== "1") { + await verifyPublishedToml(homeDomain, pub, assetCode) + console.log(`Preflight OK: DNS + TOML + HTTPS image verified for ${homeDomain}`) + } console.log(`Emisor derivado del secret en uso: ${pub}`) const account = await server.loadAccount(pub) const current = (account.home_domain ?? "").trim() @@ -101,9 +128,19 @@ async function main() { .build() tx.sign(kp) + if (process.env.ISSUER_HOME_DOMAIN_DRY_RUN === "1") { + console.log("DRY RUN: transaction was built and signed but not submitted.") + console.log(tx.toXDR()) + return + } const res = await server.submitTransaction(tx) console.log("OK. Hash:", res.hash) console.log(`Horizon: ${HORIZON_URL.replace(/\/$/, "")}/transactions/${res.hash}`) + if (phase173Enabled) { + const updated = await server.loadAccount(pub) + if ((updated.home_domain ?? "").trim() !== homeDomain) throw new Error("Horizon confirmation did not reflect the requested home_domain") + console.log(`Verified on Horizon: home_domain=${homeDomain}`) + } } main().catch((e) => {