From 9eb640a2aa024f5ed1e945715effafb6c5f9d572 Mon Sep 17 00:00:00 2001 From: hard-nett Date: Wed, 15 Apr 2026 19:53:05 -0400 Subject: [PATCH 1/7] feat: add bootstrap command with network presets, cosmovisor, pruning, and thin Python installer - Add `terpd bootstrap` with --network (morocco-1/90u-4), --cosmovisor, --service, --pruning flags - Multi-RPC failover uses distinct endpoints instead of duplicating one - Add `terpd statesync` debug/test subcommands (list, info, query, test, fetch) - Create thin Python installer that delegates node setup to `terpd bootstrap` - Register StatesyncCmd and BootstrapCmd in root, add Bootstrap config to app.toml Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 22 +- .../terp.network/get/terp-installer.py | 384 +++++++++ cmd/terpd/cmd/bootstrap.go | 757 ++++++++++++++++++ cmd/terpd/cmd/root.go | 17 +- cmd/terpd/cmd/statesync.go | 607 ++++++++++++++ 5 files changed, 1773 insertions(+), 14 deletions(-) create mode 100644 abstract/websites/terp.network/get/terp-installer.py create mode 100644 cmd/terpd/cmd/bootstrap.go create mode 100644 cmd/terpd/cmd/statesync.go diff --git a/Dockerfile b/Dockerfile index 9ce2462..2847342 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,10 @@ ARG GO_VERSION=1.24 ARG RUNNER_IMAGE=alpine:3.17 + +# WASMVM_SOURCE controls where the static wasmvm library comes from: +# "github" (default) — download libwasmvm_muslc from CosmWasm GitHub releases +# "local" — use pre-built lib from build/wasmvm/ (for custom zk-wasmvm) +ARG WASMVM_SOURCE=github FROM golang:${GO_VERSION}-alpine AS go-builder @@ -16,8 +21,13 @@ WORKDIR /code # Pull in the go.mod file *first* so the layer can be cached ADD go.mod go.sum ./ +# Re-declare ARGs after FROM (Docker scoping rule) +ARG WASMVM_SOURCE=github + # --------------------------------------------------------- -# Pull in the exact wasmvm lib that the Cosmos SDK wants +# Pull in the wasmvm static library (github mode only). +# In local mode the lib is staged in build/wasmvm/ and will +# be copied after the full source COPY below. # --------------------------------------------------------- RUN ARCH=$(uname -m) && \ WASMVM_VERSION=$(go list -m github.com/CosmWasm/wasmvm/v3 | awk '{print $2}') && \ @@ -37,12 +47,12 @@ RUN echo "Ensuring binary is statically linked ..." \ && (file /code/build/terpd | grep "statically linked") # --------------------------------------------------------- -# 1️⃣ Runtime image – this is normal terpd binary +# 1. Runtime image — standard (github wasmvm) # --------------------------------------------------------- FROM ${RUNNER_IMAGE} AS runtime -# Minimal set of runtime deps (ca‑certs is enough for HTTPS RPC) -RUN apk add --no-cache ca-certificates +# Copy ca-certificates from builder (works on distroless, Alpine, and nonroot) +COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=go-builder /code/build/terpd /usr/local/bin/terpd @@ -55,7 +65,7 @@ EXPOSE 1317 26656 26657 CMD ["/usr/local/bin/terpd"] # --------------------------------------------------------- -# 2️⃣ Localterp bootstrap image – binary + init scripts + tools +# 2. Localterp bootstrap image # --------------------------------------------------------- FROM alpine:3.17 AS localterp RUN apk add --no-cache \ @@ -86,4 +96,4 @@ HEALTHCHECK --interval=5s --timeout=1s --retries=120 \ CMD bash -c 'curl -sfm1 http://localhost:26657/status && \ curl -s http://localhost:26657/status | jq -e "(.result.sync_info.latest_block_height | tonumber) > 0"' -ENTRYPOINT ["/code/bootstrap.sh"] \ No newline at end of file +ENTRYPOINT ["/code/bootstrap.sh"] diff --git a/abstract/websites/terp.network/get/terp-installer.py b/abstract/websites/terp.network/get/terp-installer.py new file mode 100644 index 0000000..d593f26 --- /dev/null +++ b/abstract/websites/terp.network/get/terp-installer.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +""" +Terp Network Node Installer + +Thin wrapper that downloads the terpd binary, collects user preferences +via interactive prompts, then delegates all node setup to `terpd bootstrap`. + +Usage: + python3 terp-installer.py + python3 terp-installer.py --version v5.0.0 + python3 terp-installer.py --help +""" + +import argparse +import os +import platform +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import urllib.request +import urllib.error + +# ─── Constants ─────────────────────────────────────────────────────────────── + +GITHUB_REPO = "terpnetwork/terp-core" +BINARY_NAME = "terpd" +DEFAULT_VERSION = "v5.0.0" + +NETWORKS = { + "morocco-1": { + "name": "Mainnet (morocco-1)", + "chain_id": "morocco-1", + }, + "90u-4": { + "name": "Testnet (90u-4)", + "chain_id": "90u-4", + }, +} + +PRUNING_OPTIONS = { + "1": ("default", "Keep recent state + periodic snapshots (recommended)"), + "2": ("nothing", "Keep all state (archival node, uses most disk)"), + "3": ("everything", "Prune aggressively (minimal disk, no historical queries)"), +} + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def clear_screen(): + os.system("cls" if os.name == "nt" else "clear") + + +def welcome_message(): + clear_screen() + print(r""" + ___________ _______ __ + \__ ___/___________ ____ \ \ _____/ |___ _ _____________ __ + | | / __ \_ __ \| _ \ / | \_/ __ \ __\ \/ \/ / _ \_ __ |/ / + | |\ ___/| | \/| |_) )/ | \ ___/| | \ ( (_) ) | \/ < + |____| \___ >__| | __/ \____|__ /\___ >__| \/\_/ \____/|__| |__| + \/ |__| \/ \/ + + Terp Network Node Installer + https://terp.network + """) + + +def prompt_choice(prompt, options, default=None): + """Display numbered options and return the user's choice.""" + print(f"\n{prompt}") + for key, (label, desc) in options.items(): + marker = " (*)" if key == default else "" + print(f" {key}) {label} — {desc}{marker}") + + while True: + choice = input(f"\nEnter choice [{default or ''}]: ").strip() + if not choice and default: + return default + if choice in options: + return choice + print(f"Invalid choice. Please enter one of: {', '.join(options.keys())}") + + +def prompt_yes_no(prompt, default=True): + """Simple yes/no prompt.""" + hint = "[Y/n]" if default else "[y/N]" + answer = input(f"{prompt} {hint}: ").strip().lower() + if not answer: + return default + return answer in ("y", "yes") + + +def prompt_string(prompt, default=""): + """Prompt for a string value with optional default.""" + if default: + answer = input(f"{prompt} [{default}]: ").strip() + return answer if answer else default + while True: + answer = input(f"{prompt}: ").strip() + if answer: + return answer + print("Please enter a value.") + + +def detect_platform(): + """Detect OS and architecture for binary download.""" + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "darwin": + os_name = "darwin" + elif system == "linux": + os_name = "linux" + else: + print(f"Error: Unsupported OS: {system}") + sys.exit(1) + + if machine in ("x86_64", "amd64"): + arch = "amd64" + elif machine in ("aarch64", "arm64"): + arch = "arm64" + else: + print(f"Error: Unsupported architecture: {machine}") + sys.exit(1) + + return os_name, arch + + +def download_binary(version, dest_dir): + """Download the terpd binary from GitHub releases.""" + os_name, arch = detect_platform() + + # Release tarball naming: terpd---.tar.gz + tarball = f"terpd-{version}-{os_name}-{arch}.tar.gz" + url = f"https://github.com/{GITHUB_REPO}/releases/download/{version}/{tarball}" + + print(f"\nDownloading {BINARY_NAME} {version} for {os_name}/{arch}...") + print(f" URL: {url}") + + with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: + tmp_path = tmp.name + + try: + urllib.request.urlretrieve(url, tmp_path) + except urllib.error.HTTPError as e: + if e.code == 404: + print(f"\nError: Release {version} not found for {os_name}/{arch}.") + print(f"Check available releases at: https://github.com/{GITHUB_REPO}/releases") + sys.exit(1) + raise + + # Extract the binary + with tarfile.open(tmp_path, "r:gz") as tar: + # Look for the terpd binary inside the tarball + members = tar.getmembers() + terpd_member = None + for m in members: + if m.name.endswith(BINARY_NAME) or os.path.basename(m.name) == BINARY_NAME: + terpd_member = m + break + + if terpd_member is None: + print(f"Error: {BINARY_NAME} not found in release tarball.") + sys.exit(1) + + # Extract to dest_dir + terpd_member.name = BINARY_NAME # flatten path + tar.extract(terpd_member, dest_dir) + + os.unlink(tmp_path) + + binary_path = os.path.join(dest_dir, BINARY_NAME) + os.chmod(binary_path, os.stat(binary_path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + print(f" Installed: {binary_path}") + return binary_path + + +def install_to_path(binary_path): + """Copy binary to a directory on PATH (e.g., /usr/local/bin).""" + install_dir = "/usr/local/bin" + dest = os.path.join(install_dir, BINARY_NAME) + + if not os.access(install_dir, os.W_OK): + print(f"\nCopying {BINARY_NAME} to {install_dir} (requires sudo)...") + subprocess.run(["sudo", "cp", binary_path, dest], check=True) + subprocess.run(["sudo", "chmod", "+x", dest], check=True) + else: + shutil.copy2(binary_path, dest) + os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC) + + print(f" {BINARY_NAME} available at: {dest}") + return dest + + +def patch_client_toml(home, chain_id): + """Minimal client.toml patching for client-only install.""" + client_toml = os.path.join(home, "config", "client.toml") + if not os.path.exists(client_toml): + return + + with open(client_toml, "r") as f: + content = f.read() + + # Set chain-id and a reasonable RPC endpoint + rpc = "https://rpc.terp.network:443" + if chain_id == "90u-4": + rpc = "https://testnet-rpc.terp.network:443" + + content = content.replace('chain-id = ""', f'chain-id = "{chain_id}"') + content = content.replace('node = "tcp://localhost:26657"', f'node = "{rpc}"') + + with open(client_toml, "w") as f: + f.write(content) + + print(f" client.toml updated (chain-id={chain_id}, node={rpc})") + + +# ─── Install Flows ────────────────────────────────────────────────────────── + +def install_node(args): + """Full node installation: download binary + delegate to terpd bootstrap.""" + # Select network + network_choices = { + "1": ("morocco-1", "Mainnet — production network"), + "2": ("90u-4", "Testnet — test network"), + } + net_key = prompt_choice("Which network?", network_choices, default="1") + network = network_choices[net_key][0] + chain_id = NETWORKS[network]["chain_id"] + print(f" Selected: {NETWORKS[network]['name']}") + + # Download binary + version = args.version + with tempfile.TemporaryDirectory() as tmpdir: + binary_path = download_binary(version, tmpdir) + installed_path = install_to_path(binary_path) + + # Node home directory + default_home = os.path.expanduser("~/.terp") + home = prompt_string("\nNode home directory", default=default_home) + + # Moniker + moniker = prompt_string("Node moniker (display name)") + + # Pruning + pruning_key = prompt_choice("Pruning strategy", PRUNING_OPTIONS, default="1") + pruning = PRUNING_OPTIONS[pruning_key][0] + + # Cosmovisor + cosmovisor = prompt_yes_no("\nInstall cosmovisor for automatic upgrades?", default=False) + + # Systemd service (Linux only) + service = False + if platform.system() == "Linux": + service = prompt_yes_no("Create systemd service?", default=False) + + # Build the bootstrap command + cmd = [ + installed_path, "bootstrap", + "--network", chain_id, + "--home", home, + "--moniker", moniker, + "--pruning", pruning, + ] + if cosmovisor: + cmd.append("--cosmovisor") + if service: + cmd.append("--service") + + print(f"\n{'─' * 60}") + print("Running: " + " ".join(cmd)) + print(f"{'─' * 60}\n") + + # Delegate to terpd bootstrap — it handles init, genesis, peers, sync, start + try: + os.execvp(cmd[0], cmd) + except FileNotFoundError: + print(f"Error: {cmd[0]} not found. Is it installed correctly?") + sys.exit(1) + + +def install_client(args): + """Client-only installation: binary + init + config.""" + # Select network + network_choices = { + "1": ("morocco-1", "Mainnet — production network"), + "2": ("90u-4", "Testnet — test network"), + } + net_key = prompt_choice("Which network?", network_choices, default="1") + network = network_choices[net_key][0] + chain_id = NETWORKS[network]["chain_id"] + + # Download binary + version = args.version + with tempfile.TemporaryDirectory() as tmpdir: + binary_path = download_binary(version, tmpdir) + installed_path = install_to_path(binary_path) + + # Node home directory + default_home = os.path.expanduser("~/.terp") + home = prompt_string("\nClient home directory", default=default_home) + + # Moniker + moniker = prompt_string("Client name") + + # Init + print(f"\nInitializing client config...") + subprocess.run( + [installed_path, "init", moniker, "--chain-id", chain_id, "--home", home], + check=True, + ) + + # Patch client.toml + patch_client_toml(home, chain_id) + + print(f"\n{'─' * 60}") + print(f"Client setup complete!") + print(f" Home : {home}") + print(f" Chain ID: {chain_id}") + print(f"\nYou can now run:") + print(f" terpd status --home {home}") + print(f" terpd query bank balances
--home {home}") + print(f"{'─' * 60}") + + +def install_localterp(args): + """Local development chain (single validator).""" + version = args.version + with tempfile.TemporaryDirectory() as tmpdir: + binary_path = download_binary(version, tmpdir) + installed_path = install_to_path(binary_path) + + home = os.path.expanduser("~/.terp-local") + print(f"\nStarting local development chain at {home}...") + print("This will create a single-validator chain for testing.\n") + + subprocess.run( + [installed_path, "init", "localterp", "--chain-id", "localterp-1", "--home", home], + check=True, + ) + + print(f"\n{'─' * 60}") + print(f"Local chain initialized at {home}") + print(f"Start with: terpd start --home {home}") + print(f"{'─' * 60}") + + +# ─── Main ──────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Terp Network Node Installer", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--version", default=DEFAULT_VERSION, + help=f"terpd version to install (default: {DEFAULT_VERSION})", + ) + args = parser.parse_args() + + welcome_message() + + install_types = { + "1": ("Node", "Full node — sync with the network and validate"), + "2": ("Client", "Client only — query the chain, no syncing"), + "3": ("LocalTerp", "Local dev chain — single validator for testing"), + } + + choice = prompt_choice("What would you like to install?", install_types, default="1") + + if choice == "1": + install_node(args) + elif choice == "2": + install_client(args) + elif choice == "3": + install_localterp(args) + + +if __name__ == "__main__": + main() diff --git a/cmd/terpd/cmd/bootstrap.go b/cmd/terpd/cmd/bootstrap.go new file mode 100644 index 0000000..56b41c7 --- /dev/null +++ b/cmd/terpd/cmd/bootstrap.go @@ -0,0 +1,757 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + cmtcfg "github.com/cometbft/cometbft/config" + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + "github.com/cosmos/cosmos-sdk/server" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/terpnetwork/terp-core/v5/app" +) + +// BootstrapConfig holds settings for automated node bootstrapping. +// Persisted in app.toml under [bootstrap]. +type BootstrapConfig struct { + SyncMode string `mapstructure:"sync-mode"` + GenesisURL string `mapstructure:"genesis-url"` + GenesisHash string `mapstructure:"genesis-hash"` + SnapshotURL string `mapstructure:"snapshot-url"` + StateSyncRPCs string `mapstructure:"statesync-rpcs"` + TrustOffset int64 `mapstructure:"trust-offset"` + MaxRetries int `mapstructure:"max-retries"` + Seeds string `mapstructure:"seeds"` + PersistentPeers string `mapstructure:"persistent-peers"` + PrivateMode bool `mapstructure:"private-mode"` + Cosmovisor bool `mapstructure:"cosmovisor"` + Service bool `mapstructure:"service"` + Pruning string `mapstructure:"pruning"` +} + +// networkPreset holds known-good configuration for a specific network. +type networkPreset struct { + ChainID string + GenesisURL string + RPCs string +} + +var networkPresets = map[string]networkPreset{ + "morocco-1": { + ChainID: "morocco-1", + GenesisURL: "https://raw.githubusercontent.com/terpnetwork/mainnet/main/morocco-1/genesis.json", + RPCs: "https://rpc.terp.network:443,https://rpc.terp.chaintools.tech:443", + }, + "90u-4": { + ChainID: "90u-4", + GenesisURL: "https://raw.githubusercontent.com/terpnetwork/test-net/master/90u-4/genesis.json", + RPCs: "https://testnet-rpc.terp.network:443", + }, +} + +// DefaultBootstrapConfig returns sensible defaults for mainnet bootstrapping. +func DefaultBootstrapConfig() BootstrapConfig { + return BootstrapConfig{ + SyncMode: "statesync", + GenesisURL: "https://raw.githubusercontent.com/terpnetwork/mainnet/main/morocco-1/genesis.json", + GenesisHash: "", + SnapshotURL: "", + StateSyncRPCs: "https://rpc.terp.network:443,https://rpc.terp.chaintools.tech:443", + TrustOffset: 1000, + MaxRetries: 6, + Seeds: "", + PersistentPeers: "", + PrivateMode: true, + Cosmovisor: false, + Service: false, + Pruning: "", + } +} + +// BootstrapConfigTemplate is the TOML template appended to app.toml. +const BootstrapConfigTemplate = ` +############################################################################### +### Bootstrap Configuration ### +############################################################################### + +[bootstrap] + +# Sync mode: "statesync" or "snapshot" +sync-mode = "{{ .Bootstrap.SyncMode }}" + +# Genesis file download URL +genesis-url = "{{ .Bootstrap.GenesisURL }}" + +# Expected SHA256 hash of genesis.json (empty = skip validation) +genesis-hash = "{{ .Bootstrap.GenesisHash }}" + +# Snapshot tarball URL (used when sync-mode = "snapshot") +snapshot-url = "{{ .Bootstrap.SnapshotURL }}" + +# State-sync RPC endpoints (comma-separated, tried in order on failure) +statesync-rpcs = "{{ .Bootstrap.StateSyncRPCs }}" + +# Blocks behind latest for state-sync trust height +trust-offset = {{ .Bootstrap.TrustOffset }} + +# Max RPC provider rotation retries before giving up +max-retries = {{ .Bootstrap.MaxRetries }} + +# Seed nodes (comma-separated id@host:port) +seeds = "{{ .Bootstrap.Seeds }}" + +# Persistent peers (comma-separated id@host:port) +persistent-peers = "{{ .Bootstrap.PersistentPeers }}" + +# Private mode (default true): disables PEX gossip, rejects inbound peers, +# only connects to configured persistent peers. Ideal for local state-sync +# testing without participating in the network. Use --public to disable. +private-mode = {{ .Bootstrap.PrivateMode }} +` + +// BootstrapCmd automates full node bootstrapping for Docker and manual use. +var BootstrapCmd = &cobra.Command{ + Use: "bootstrap", + Short: "Bootstrap and start a Terp node (init + config + sync + start)", + Long: `Fully automated node bootstrapping for Docker entrypoints and fresh nodes. + +Flow: + 1. Init node if not already initialized + 2. Download and validate genesis (known hash) + 3. Configure peers, seeds, state-sync or snapshot restore + 4. Exec into terpd start + +Settings are read from app.toml [bootstrap] section and can be overridden +with flags or environment variables. + +Docker usage: + ENTRYPOINT ["terpd"] + CMD ["bootstrap"] + +Direct usage: + terpd bootstrap + terpd bootstrap --sync-mode snapshot --snapshot-url https://...`, + RunE: runBootstrap, +} + +func init() { + BootstrapCmd.Flags().String("moniker", "", "node moniker (auto-generated if empty)") + BootstrapCmd.Flags().String("chain-id", "morocco-1", "chain ID") + BootstrapCmd.Flags().String("network", "", "preset network config: morocco-1 (mainnet) or 90u-4 (testnet)") + BootstrapCmd.Flags().String("sync-mode", "", "override sync mode: statesync or snapshot") + BootstrapCmd.Flags().String("genesis-url", "", "override genesis download URL") + BootstrapCmd.Flags().String("genesis-hash", "", "override expected genesis SHA256 hash") + BootstrapCmd.Flags().String("snapshot-url", "", "override snapshot tarball URL") + BootstrapCmd.Flags().String("statesync-rpcs", "", "override state-sync RPC endpoints (comma-separated)") + BootstrapCmd.Flags().Int64("trust-offset", 0, "override trust offset") + BootstrapCmd.Flags().Int("max-retries", 0, "override max retries") + BootstrapCmd.Flags().String("bootstrap-seeds", "", "override seed nodes") + BootstrapCmd.Flags().String("bootstrap-peers", "", "override persistent peers") + BootstrapCmd.Flags().Bool("public", false, "public mode: enable PEX gossip and accept inbound peers (default is private)") + BootstrapCmd.Flags().Bool("cosmovisor", false, "install cosmovisor via 'go install' and initialize it") + BootstrapCmd.Flags().Bool("service", false, "create a systemd service (Linux only, works with --cosmovisor)") + BootstrapCmd.Flags().String("pruning", "", "pruning strategy: default, nothing, or everything") +} + +func runBootstrap(cmd *cobra.Command, args []string) error { + home, _ := cmd.Flags().GetString("home") + if home == "" { + home = app.DefaultNodeHome + } + moniker, _ := cmd.Flags().GetString("moniker") + chainID, _ := cmd.Flags().GetString("chain-id") + + if moniker == "" { + moniker = fmt.Sprintf("terp-node-%d", time.Now().Unix()%10000) + } + + // Load bootstrap config from app.toml [bootstrap] section + bsCfg := DefaultBootstrapConfig() + serverCtx := server.GetServerContextFromCmd(cmd) + if serverCtx != nil && serverCtx.Viper != nil { + _ = serverCtx.Viper.UnmarshalKey("bootstrap", &bsCfg) + } + + // Apply --network preset (overrides defaults before flag overrides) + if network, _ := cmd.Flags().GetString("network"); network != "" { + preset, ok := networkPresets[network] + if !ok { + return fmt.Errorf("unknown network %q (available: morocco-1, 90u-4)", network) + } + chainID = preset.ChainID + bsCfg.GenesisURL = preset.GenesisURL + bsCfg.StateSyncRPCs = preset.RPCs + fmt.Printf("Using network preset: %s\n", network) + } + + // Override with flags (only when explicitly set) + applyBootstrapFlagOverrides(cmd, &bsCfg) + + // --public flag inverts private mode + if isPublic, _ := cmd.Flags().GetBool("public"); isPublic { + bsCfg.PrivateMode = false + } + + modeStr := "private" + if !bsCfg.PrivateMode { + modeStr = "public" + } + + fmt.Println("=== Terp Bootstrap ===") + fmt.Printf(" Home : %s\n", home) + fmt.Printf(" Chain ID : %s\n", chainID) + fmt.Printf(" Moniker : %s\n", moniker) + fmt.Printf(" Sync mode: %s\n", bsCfg.SyncMode) + fmt.Printf(" P2P mode : %s\n\n", modeStr) + + // ──── Step 1: Init if needed ──── + genesisPath := filepath.Join(home, "config", "genesis.json") + if _, err := os.Stat(genesisPath); os.IsNotExist(err) { + fmt.Println("Node not initialized. Running init...") + if err := runInit(home, moniker, chainID); err != nil { + return err + } + fmt.Println("Init complete.") + } else { + fmt.Println("Node already initialized.") + } + + // ──── Step 2: Genesis download + validation ──── + if bsCfg.GenesisURL != "" { + fmt.Printf("Downloading genesis from %s\n", bsCfg.GenesisURL) + if err := downloadFile(bsCfg.GenesisURL, genesisPath); err != nil { + return fmt.Errorf("genesis download failed: %w", err) + } + fmt.Println("Genesis downloaded.") + } + + if bsCfg.GenesisHash != "" { + fmt.Printf("Validating genesis hash...") + if err := validateFileHash(genesisPath, bsCfg.GenesisHash); err != nil { + return fmt.Errorf("genesis validation failed: %w", err) + } + fmt.Println(" OK") + } + + // ──── Step 3: Load and modify CometBFT config ──── + cmtCfg, err := loadCometConfig(home) + if err != nil { + return fmt.Errorf("failed to load config.toml: %w", err) + } + + // Apply seeds and persistent peers from bootstrap config + if bsCfg.Seeds != "" { + cmtCfg.P2P.Seeds = bsCfg.Seeds + } + if bsCfg.PersistentPeers != "" { + cmtCfg.P2P.PersistentPeers = bsCfg.PersistentPeers + } + + switch bsCfg.SyncMode { + case "statesync": + if err := configureStateSyncBootstrap(cmtCfg, bsCfg); err != nil { + return err + } + case "snapshot": + if err := configureSnapshotBootstrap(home, cmtCfg, bsCfg); err != nil { + return err + } + default: + return fmt.Errorf("unknown sync-mode: %q (use 'statesync' or 'snapshot')", bsCfg.SyncMode) + } + + // ──── Step 4: Apply P2P mode ──── + if bsCfg.PrivateMode { + applyPrivateMode(cmtCfg) + fmt.Println("Private mode: PEX disabled, inbound peers rejected, no gossip.") + } + + // Write updated config.toml + configTomlPath := filepath.Join(home, "config", "config.toml") + cmtcfg.WriteConfigFile(configTomlPath, cmtCfg) + fmt.Println("config.toml updated.") + + // ──── Step 5: Apply pruning to app.toml ──── + if bsCfg.Pruning != "" { + if err := applyPruningConfig(home, bsCfg.Pruning); err != nil { + return err + } + } + + // ──── Step 6: Cosmovisor setup ──── + binary, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to resolve executable path: %w", err) + } + + if bsCfg.Cosmovisor { + if err := installCosmovisor(binary, home); err != nil { + return err + } + } + + // ──── Step 7: Systemd service ──── + if bsCfg.Service { + if err := createSystemdService(home, bsCfg.Cosmovisor); err != nil { + return err + } + } + + // ──── Step 8: Exec into terpd start ──── + fmt.Println("Bootstrap complete. Starting node...") + + startArgs := []string{"terpd", "start", "--home", home} + if bsCfg.Cosmovisor { + cosmovisorBin, err := exec.LookPath("cosmovisor") + if err != nil { + fmt.Println("Warning: cosmovisor not found on PATH, falling back to terpd start") + } else { + startArgs = []string{"cosmovisor", "run", "start", "--home", home} + binary = cosmovisorBin + } + } + + // syscall.Exec replaces current process — clean for Docker PID 1 + return syscall.Exec(binary, startArgs, os.Environ()) +} + +// ──── State-sync configuration ──── + +func configureStateSyncBootstrap(cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) error { + rpcs := splitTrimmed(bsCfg.StateSyncRPCs, ",") + if len(rpcs) == 0 { + return fmt.Errorf("no statesync-rpcs configured") + } + + var lastErr error + for attempt := 0; attempt < bsCfg.MaxRetries; attempt++ { + rpc := rpcs[attempt%len(rpcs)] + fmt.Printf("Trying RPC %d/%d: %s\n", attempt+1, bsCfg.MaxRetries, rpc) + + trustHeight, trustHash, peers, err := fetchStateSyncInfo(rpc, bsCfg.TrustOffset) + if err != nil { + fmt.Printf(" Failed: %v\n", err) + lastErr = err + continue + } + + fmt.Printf(" Trust height: %d\n", trustHeight) + fmt.Printf(" Trust hash : %s\n", trustHash) + fmt.Printf(" Peers found : %d\n", len(peers)) + + cmtCfg.StateSync.Enable = true + // CometBFT requires >=2 RPC servers; use two distinct ones when possible + if len(rpcs) >= 2 { + cmtCfg.StateSync.RPCServers = []string{rpcs[0], rpcs[1]} + } else { + cmtCfg.StateSync.RPCServers = []string{rpc, rpc} + } + cmtCfg.StateSync.TrustHeight = trustHeight + cmtCfg.StateSync.TrustHash = trustHash + cmtCfg.StateSync.TrustPeriod = 168 * time.Hour + + // Merge discovered peers with any already configured + if len(peers) > 0 { + discovered := strings.Join(peers, ",") + if cmtCfg.P2P.PersistentPeers != "" { + cmtCfg.P2P.PersistentPeers += "," + discovered + } else { + cmtCfg.P2P.PersistentPeers = discovered + } + } + + fmt.Println("State-sync configured.") + return nil + } + + return fmt.Errorf("state-sync config failed after %d attempts: %w", bsCfg.MaxRetries, lastErr) +} + +// ──── Snapshot configuration ──── + +func configureSnapshotBootstrap(home string, cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) error { + if bsCfg.SnapshotURL == "" { + return fmt.Errorf("snapshot-url is required when sync-mode is 'snapshot'") + } + + dataDir := filepath.Join(home, "data") + fmt.Printf("Downloading snapshot from %s\n", bsCfg.SnapshotURL) + if err := downloadAndExtractTarball(bsCfg.SnapshotURL, dataDir); err != nil { + return fmt.Errorf("snapshot restore failed: %w", err) + } + fmt.Println("Snapshot extracted.") + + // Disable state-sync — node will catch up from snapshot height via block-sync + cmtCfg.StateSync.Enable = false + + // Still discover peers for block-sync connectivity + rpcs := splitTrimmed(bsCfg.StateSyncRPCs, ",") + if len(rpcs) > 0 && rpcs[0] != "" { + _, _, peers, err := fetchStateSyncInfo(rpcs[0], bsCfg.TrustOffset) + if err == nil && len(peers) > 0 { + discovered := strings.Join(peers, ",") + if cmtCfg.P2P.PersistentPeers != "" { + cmtCfg.P2P.PersistentPeers += "," + discovered + } else { + cmtCfg.P2P.PersistentPeers = discovered + } + } + } + + return nil +} + +// ──── RPC helpers ──── + +// fetchStateSyncInfo queries an RPC for trust height, hash, and peer addresses. +func fetchStateSyncInfo(rpcAddr string, trustOffset int64) (int64, string, []string, error) { + client, err := rpchttp.New(rpcAddr, "/websocket") + if err != nil { + return 0, "", nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + status, err := client.Status(ctx) + if err != nil { + return 0, "", nil, fmt.Errorf("status: %w", err) + } + + latestHeight := status.SyncInfo.LatestBlockHeight + trustHeight := latestHeight - trustOffset + if trustHeight < 1 { + trustHeight = 1 + } + + block, err := client.Block(ctx, &trustHeight) + if err != nil { + return 0, "", nil, fmt.Errorf("block at %d: %w", trustHeight, err) + } + trustHash := hex.EncodeToString(block.BlockID.Hash) + + // Collect peers + var peers []string + rpcNodeID := string(status.NodeInfo.DefaultNodeID) + rpcHost := extractHost(rpcAddr) // reuse helper from statesync.go + if rpcHost != "" { + peers = append(peers, fmt.Sprintf("%s@%s:26656", rpcNodeID, rpcHost)) + } + + netInfo, err := client.NetInfo(ctx) + if err == nil { + for _, p := range netInfo.Peers { + port := "26656" + if parts := strings.Split(p.NodeInfo.ListenAddr, ":"); len(parts) > 1 { + port = parts[len(parts)-1] + } + peers = append(peers, fmt.Sprintf("%s@%s:%s", p.NodeInfo.DefaultNodeID, p.RemoteIP, port)) + } + } + + return trustHeight, trustHash, peers, nil +} + +// ──── File / download helpers ──── + +func runInit(home, moniker, chainID string) error { + binary, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to resolve executable: %w", err) + } + initCmd := exec.Command(binary, "init", moniker, "--chain-id", chainID, "--home", home) + initCmd.Stdout = os.Stdout + initCmd.Stderr = os.Stderr + if err := initCmd.Run(); err != nil { + return fmt.Errorf("terpd init failed: %w", err) + } + return nil +} + +func downloadFile(url, destPath string) error { + resp, err := http.Get(url) //nolint:gosec // URL comes from operator config + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return os.WriteFile(destPath, data, 0o644) +} + +func validateFileHash(path, expectedHash string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + h := sha256.Sum256(data) + actual := hex.EncodeToString(h[:]) + if actual != expectedHash { + return fmt.Errorf("hash mismatch: expected %s, got %s", expectedHash, actual) + } + return nil +} + +func downloadAndExtractTarball(url, destDir string) error { + resp, err := http.Get(url) //nolint:gosec // URL comes from operator config + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + // Stream directly into tar — supports .tar.gz + tarCmd := exec.Command("tar", "-xzf", "-", "-C", destDir) + tarCmd.Stdin = resp.Body + tarCmd.Stdout = os.Stdout + tarCmd.Stderr = os.Stderr + return tarCmd.Run() +} + +func loadCometConfig(home string) (*cmtcfg.Config, error) { + cfg := cmtcfg.DefaultConfig() + cfg.SetRoot(home) + + configFile := filepath.Join(home, "config", "config.toml") + if _, err := os.Stat(configFile); err != nil { + return cfg, nil // return defaults if config.toml doesn't exist yet + } + + v := viper.New() + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("reading config.toml: %w", err) + } + if err := v.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("parsing config.toml: %w", err) + } + return cfg, nil +} + +// ──── P2P mode helpers ──── + +// applyPrivateMode locks down P2P so the node only connects to configured +// persistent peers, rejects all inbound connections, and never gossips. +// Ideal for pulling a local state-sync without participating in the network. +func applyPrivateMode(cfg *cmtcfg.Config) { + cfg.P2P.PexReactor = false // no peer exchange gossip + cfg.P2P.MaxNumInboundPeers = 0 // reject all inbound connections + cfg.P2P.MaxNumOutboundPeers = 10 // only our configured peers + cfg.P2P.AddrBookStrict = false // allow non-routable addrs (local testing) + cfg.P2P.Seeds = "" // no seeds — only persistent peers + cfg.Mempool.Broadcast = false // don't broadcast txs to peers +} + +// ──── Misc helpers ──── + +func applyBootstrapFlagOverrides(cmd *cobra.Command, bsCfg *BootstrapConfig) { + if v, _ := cmd.Flags().GetString("sync-mode"); v != "" { + bsCfg.SyncMode = v + } + if v, _ := cmd.Flags().GetString("genesis-url"); v != "" { + bsCfg.GenesisURL = v + } + if v, _ := cmd.Flags().GetString("genesis-hash"); v != "" { + bsCfg.GenesisHash = v + } + if v, _ := cmd.Flags().GetString("snapshot-url"); v != "" { + bsCfg.SnapshotURL = v + } + if v, _ := cmd.Flags().GetString("statesync-rpcs"); v != "" { + bsCfg.StateSyncRPCs = v + } + if v, _ := cmd.Flags().GetInt64("trust-offset"); v > 0 { + bsCfg.TrustOffset = v + } + if v, _ := cmd.Flags().GetInt("max-retries"); v > 0 { + bsCfg.MaxRetries = v + } + if v, _ := cmd.Flags().GetString("bootstrap-seeds"); v != "" { + bsCfg.Seeds = v + } + if v, _ := cmd.Flags().GetString("bootstrap-peers"); v != "" { + bsCfg.PersistentPeers = v + } + if v, _ := cmd.Flags().GetBool("cosmovisor"); v { + bsCfg.Cosmovisor = true + } + if v, _ := cmd.Flags().GetBool("service"); v { + bsCfg.Service = true + } + if v, _ := cmd.Flags().GetString("pruning"); v != "" { + bsCfg.Pruning = v + } +} + +// ──── Pruning configuration ──── + +func applyPruningConfig(home, pruning string) error { + switch pruning { + case "default", "nothing", "everything": + default: + return fmt.Errorf("unknown pruning strategy %q (use: default, nothing, everything)", pruning) + } + + appTomlPath := filepath.Join(home, "config", "app.toml") + data, err := os.ReadFile(appTomlPath) + if err != nil { + return fmt.Errorf("failed to read app.toml: %w", err) + } + + content := string(data) + // Replace the pruning line in app.toml + lines := strings.Split(content, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "pruning =") { + lines[i] = fmt.Sprintf("pruning = %q", pruning) + break + } + } + + if err := os.WriteFile(appTomlPath, []byte(strings.Join(lines, "\n")), 0o644); err != nil { + return fmt.Errorf("failed to write app.toml: %w", err) + } + fmt.Printf("Pruning strategy set to %q in app.toml\n", pruning) + return nil +} + +// ──── Cosmovisor installation ──── + +func installCosmovisor(terpdBinary, home string) error { + // Check if go is available + goPath, err := exec.LookPath("go") + if err != nil { + return fmt.Errorf("cosmovisor requires Go on PATH: %w", err) + } + fmt.Printf("Found Go at %s\n", goPath) + + // Install cosmovisor + fmt.Println("Installing cosmovisor...") + installCmd := exec.Command(goPath, "install", "cosmossdk.io/tools/cosmovisor/cmd/cosmovisor@latest") + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + installCmd.Env = append(os.Environ(), + fmt.Sprintf("DAEMON_NAME=terpd"), + fmt.Sprintf("DAEMON_HOME=%s", home), + ) + if err := installCmd.Run(); err != nil { + return fmt.Errorf("cosmovisor install failed: %w", err) + } + + // Initialize cosmovisor + fmt.Println("Initializing cosmovisor...") + cosmovisorBin, err := exec.LookPath("cosmovisor") + if err != nil { + return fmt.Errorf("cosmovisor not found after install: %w", err) + } + + initCmd := exec.Command(cosmovisorBin, "init", terpdBinary) + initCmd.Stdout = os.Stdout + initCmd.Stderr = os.Stderr + initCmd.Env = append(os.Environ(), + fmt.Sprintf("DAEMON_NAME=terpd"), + fmt.Sprintf("DAEMON_HOME=%s", home), + ) + if err := initCmd.Run(); err != nil { + return fmt.Errorf("cosmovisor init failed: %w", err) + } + + fmt.Println("Cosmovisor installed and initialized.") + return nil +} + +// ──── Systemd service creation ──── + +func createSystemdService(home string, cosmovisor bool) error { + if runtime.GOOS != "linux" { + fmt.Println("Warning: --service is only supported on Linux, skipping systemd setup.") + return nil + } + + currentUser := os.Getenv("USER") + if currentUser == "" { + currentUser = "root" + } + + var execStart, description string + if cosmovisor { + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(os.Getenv("HOME"), "go") + } + execStart = filepath.Join(gopath, "bin", "cosmovisor") + " run start --home " + home + description = "Terp Node (cosmovisor)" + } else { + binary, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to resolve executable path: %w", err) + } + execStart = binary + " start --home " + home + description = "Terp Node" + } + + unit := fmt.Sprintf(`[Unit] +Description=%s +After=network-online.target +Wants=network-online.target + +[Service] +User=%s +ExecStart=%s +Restart=always +RestartSec=3 +LimitNOFILE=65535 +Environment="DAEMON_NAME=terpd" +Environment="DAEMON_HOME=%s" +Environment="DAEMON_ALLOW_DOWNLOAD_BINARIES=false" +Environment="DAEMON_RESTART_AFTER_UPGRADE=true" + +[Install] +WantedBy=multi-user.target +`, description, currentUser, execStart, home) + + servicePath := "/etc/systemd/system/terpd.service" + if err := os.WriteFile(servicePath, []byte(unit), 0o644); err != nil { + return fmt.Errorf("failed to write systemd service (try running as root): %w", err) + } + + fmt.Printf("Systemd service created at %s\n", servicePath) + fmt.Println("Enable with: sudo systemctl enable terpd && sudo systemctl start terpd") + return nil +} + +func splitTrimmed(s, sep string) []string { + parts := strings.Split(s, sep) + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} diff --git a/cmd/terpd/cmd/root.go b/cmd/terpd/cmd/root.go index 8eae54c..1e2d1b2 100644 --- a/cmd/terpd/cmd/root.go +++ b/cmd/terpd/cmd/root.go @@ -43,7 +43,6 @@ import ( "github.com/cosmos/cosmos-sdk/version" authcmd "github.com/cosmos/cosmos-sdk/x/auth/client/cli" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" - "github.com/cosmos/cosmos-sdk/x/crisis" genutilcli "github.com/cosmos/cosmos-sdk/x/genutil/client/cli" "github.com/terpnetwork/terp-core/v5/app" @@ -150,7 +149,8 @@ func initAppConfig() (string, interface{}) { type CustomAppConfig struct { serverconfig.Config - Wasm wasmtypes.NodeConfig `mapstructure:"wasm"` + Wasm wasmtypes.NodeConfig `mapstructure:"wasm"` + Bootstrap BootstrapConfig `mapstructure:"bootstrap"` // SidecarQueryServerConfig sqs.Config `mapstructure:"terp-sqs"` // IndexerConfig indexer.Config `mapstructure:"terp-indexer"` @@ -175,12 +175,14 @@ func initAppConfig() (string, interface{}) { // srvCfg.BaseConfig.IAVLDisableFastNode = true // disable fastnode by default terpAppConfig := CustomAppConfig{ - Config: *srvCfg, - Wasm: wasmtypes.DefaultNodeConfig(), + Config: *srvCfg, + Wasm: wasmtypes.DefaultNodeConfig(), + Bootstrap: DefaultBootstrapConfig(), } customAppTemplate := serverconfig.DefaultConfigTemplate + - wasmtypes.DefaultConfigTemplate() + wasmtypes.DefaultConfigTemplate() + + BootstrapConfigTemplate return customAppTemplate, terpAppConfig } @@ -235,6 +237,8 @@ func initRootCmd(rootCmd *cobra.Command, encodingConfig params.EncodingConfig) { genutilcli.InitCmd(app.ModuleBasics, app.DefaultNodeHome), AddGenesisIcaCmd(app.DefaultNodeHome), tmcli.NewCompletionCmd(rootCmd, true), + StatesyncCmd, + BootstrapCmd, DebugCmd(), ConfigCmd(), pruning.Cmd(ac.newApp, app.DefaultNodeHome), @@ -252,12 +256,9 @@ func initRootCmd(rootCmd *cobra.Command, encodingConfig params.EncodingConfig) { txCommand(), keys.Commands(), ) - // add rosetta - // rootCmd.AddCommand(rosettaCmd.RosettaCommand(encodingConfig.InterfaceRegistry, encodingConfig.Marshaler)) } func addModuleInitFlags(startCmd *cobra.Command) { - crisis.AddModuleInitFlags(startCmd) wasm.AddModuleInitFlags(startCmd) } diff --git a/cmd/terpd/cmd/statesync.go b/cmd/terpd/cmd/statesync.go new file mode 100644 index 0000000..7f73fad --- /dev/null +++ b/cmd/terpd/cmd/statesync.go @@ -0,0 +1,607 @@ +package cmd + +import ( + "context" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "cosmossdk.io/log" + snapshots "cosmossdk.io/store/snapshots" + snapshottypes "cosmossdk.io/store/snapshots/types" + + wasmkeeper "github.com/CosmWasm/wasmd/x/wasm/keeper" + + abci "github.com/cometbft/cometbft/abci/types" + cmtcfg "github.com/cometbft/cometbft/config" + cmtlog "github.com/cometbft/cometbft/libs/log" + cmtnode "github.com/cometbft/cometbft/node" + "github.com/cometbft/cometbft/p2p" + pvm "github.com/cometbft/cometbft/privval" + "github.com/cometbft/cometbft/proxy" + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/cosmos-sdk/baseapp" + "github.com/cosmos/cosmos-sdk/server" + simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" + + "github.com/spf13/cobra" + + "github.com/terpnetwork/terp-core/v5/app" +) + +// StatesyncCmd provides tools to debug and test state-sync snapshots +var StatesyncCmd = &cobra.Command{ + Use: "statesync", + Short: "Debug and test state-sync snapshots (list, info, query, test, fetch)", + Long: `Advanced debugging tool for state-sync snapshots. + +Subcommands: + list List all local snapshots with metadata + info Show detailed info about a snapshot (auto-detects latest) + query Query snapshot metadata via ABCI ListSnapshots (lightweight) + test Dry-run full state-sync restore (OfferSnapshot + ApplySnapshotChunk) + fetch Fetch a snapshot from the production network via P2P state-sync`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, +} + +func init() { + StatesyncCmd.PersistentFlags().String("home", app.DefaultNodeHome, "directory for config and data") + StatesyncCmd.PersistentFlags().String("db-backend", "goleveldb", "database backend (goleveldb recommended)") + + StatesyncCmd.AddCommand( + listSnapshotsCmd(), + infoSnapshotCmd(), + querySnapshotsCmd(), + testStateSyncCmd(), + fetchSnapshotCmd(), + ) +} + +// getSnapshotStore opens the real snapshot store from your node home. +// Returns the store and the underlying DB (caller should close the DB when done). +func getSnapshotStore(home string) (*snapshots.Store, dbm.DB, error) { + if home == "" { + home = app.DefaultNodeHome + } + + snapshotDir := filepath.Join(home, "data", "snapshots") + + snapshotDB, err := dbm.NewDB("metadata", dbm.GoLevelDBBackend, snapshotDir) + if err != nil { + return nil, nil, fmt.Errorf("failed to open snapshot metadata DB at %s: %w", snapshotDir, err) + } + + store, err := snapshots.NewStore(snapshotDB, snapshotDir) + if err != nil { + snapshotDB.Close() + return nil, nil, fmt.Errorf("failed to create snapshot store: %w", err) + } + + fmt.Printf("Snapshot store opened\n") + fmt.Printf(" Directory : %s\n", snapshotDir) + fmt.Printf(" Metadata : %s/metadata.db\n\n", snapshotDir) + + return store, snapshotDB, nil +} + +// ====================== LIST COMMAND ====================== +func listSnapshotsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all local state-sync snapshots", + RunE: func(cmd *cobra.Command, args []string) error { + home, _ := cmd.Flags().GetString("home") + + store, snapshotDB, err := getSnapshotStore(home) + if err != nil { + return err + } + defer snapshotDB.Close() + + snapList, err := store.List() + if err != nil { + return fmt.Errorf("failed to list snapshots: %w", err) + } + + if len(snapList) == 0 { + fmt.Println("No snapshots found.") + return nil + } + + for i, snapshot := range snapList { + fmt.Printf("Snapshot #%d\n", i+1) + fmt.Printf(" Height : %d\n", snapshot.Height) + fmt.Printf(" Format : %d\n", snapshot.Format) + fmt.Printf(" Chunks : %d\n", snapshot.Chunks) + fmt.Printf(" Hash : %X\n", snapshot.Hash) + if len(snapshot.Metadata.ChunkHashes) > 0 { + fmt.Printf(" ChunkHashes: %d entries\n", len(snapshot.Metadata.ChunkHashes)) + } + fmt.Println(" ---") + } + fmt.Printf("\nTotal snapshots found: %d\n", len(snapList)) + return nil + }, + } + return cmd +} + +// ====================== INFO COMMAND ====================== +func infoSnapshotCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "info", + Short: "Show detailed information for a snapshot (auto-detects latest if --height not set)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + home, _ := cmd.Flags().GetString("home") + height, _ := cmd.Flags().GetUint64("height") + + store, snapshotDB, err := getSnapshotStore(home) + if err != nil { + return err + } + defer snapshotDB.Close() + + var snap *snapshottypes.Snapshot + if height == 0 { + snap, err = store.GetLatest() + if err != nil { + return fmt.Errorf("failed to get latest snapshot: %w", err) + } + if snap == nil { + return fmt.Errorf("no snapshots found; use 'statesync list' to verify") + } + fmt.Printf("Auto-detected latest snapshot at height %d\n\n", snap.Height) + } else { + snap, err = store.Get(height, snapshottypes.CurrentFormat) + if err != nil || snap == nil { + return fmt.Errorf("snapshot at height %d (format %d) not found", height, snapshottypes.CurrentFormat) + } + } + + fmt.Printf("Snapshot at height %d\n", snap.Height) + fmt.Printf(" Format : %d\n", snap.Format) + fmt.Printf(" Chunks : %d\n", snap.Chunks) + fmt.Printf(" Hash : %X\n", snap.Hash) + fmt.Printf(" ChunkHashes: %d entries\n", len(snap.Metadata.ChunkHashes)) + return nil + }, + } + cmd.Flags().Uint64("height", 0, "Snapshot height (0 = auto-detect latest)") + return cmd +} + +// ====================== QUERY (ABCI) COMMAND ====================== +func querySnapshotsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "query", + Short: "Query snapshot metadata via ABCI ListSnapshots (lightweight, no full node)", + Long: `Calls the ABCI ListSnapshots method exactly as CometBFT peers do when +discovering available snapshots. This is the most lightweight way to verify +what snapshot data your node would advertise to the network. + +No modules are loaded — only the snapshot store is opened and a minimal +BaseApp is created to service the ABCI call.`, + RunE: func(cmd *cobra.Command, args []string) error { + home, _ := cmd.Flags().GetString("home") + + store, snapshotDB, err := getSnapshotStore(home) + if err != nil { + return err + } + defer snapshotDB.Close() + + // Minimal BaseApp — no modules, no full app state + memDB := dbm.NewMemDB() + ba := baseapp.NewBaseApp( + "terpd", + log.NewNopLogger(), + memDB, + nil, + baseapp.SetSnapshot(store, snapshottypes.NewSnapshotOptions(0, 0)), + ) + + resp, err := ba.ListSnapshots(&abci.RequestListSnapshots{}) + if err != nil { + return fmt.Errorf("ABCI ListSnapshots failed: %w", err) + } + + if len(resp.Snapshots) == 0 { + fmt.Println("ABCI ListSnapshots returned 0 snapshots.") + fmt.Println("CometBFT peers would see no snapshots from this node.") + return nil + } + + fmt.Printf("ABCI ListSnapshots: %d snapshot(s) available\n", len(resp.Snapshots)) + fmt.Println("(This is exactly what CometBFT peers see over P2P channel 0x60)") + + for i, s := range resp.Snapshots { + fmt.Printf("Snapshot #%d\n", i+1) + fmt.Printf(" Height : %d\n", s.Height) + fmt.Printf(" Format : %d\n", s.Format) + fmt.Printf(" Chunks : %d\n", s.Chunks) + fmt.Printf(" Hash : %X\n", s.Hash) + if len(s.Metadata) > 0 { + fmt.Printf(" Metadata : %d bytes\n", len(s.Metadata)) + } + fmt.Println(" ---") + } + return nil + }, + } + return cmd +} + +// ====================== TEST (DRY-RUN) COMMAND ====================== +func testStateSyncCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "test", + Short: "Dry-run full state-sync restore (Offer + Apply chunks) - validates app state", + Long: `Simulates exactly what a state-syncing node does. Great for sanity-testing your app. +Auto-detects the latest snapshot unless --height is specified.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + home, _ := cmd.Flags().GetString("home") + height, _ := cmd.Flags().GetUint64("height") + + logger := log.NewLogger(cmd.OutOrStdout()) + + store, snapshotDB, err := getSnapshotStore(home) + if err != nil { + return err + } + defer snapshotDB.Close() + + var snap *snapshottypes.Snapshot + if height == 0 { + snap, err = store.GetLatest() + if err != nil { + return fmt.Errorf("failed to get latest snapshot: %w", err) + } + if snap == nil { + return fmt.Errorf("no snapshots found; use 'statesync list' to verify") + } + fmt.Printf("Auto-detected latest snapshot at height %d\n\n", snap.Height) + } else { + snap, err = store.Get(height, snapshottypes.CurrentFormat) + if err != nil || snap == nil { + return fmt.Errorf("snapshot at height %d not found", height) + } + } + + fmt.Printf("Starting state-sync dry-run for height %d...\n", snap.Height) + fmt.Printf(" Chunks to apply: %d\n\n", snap.Chunks) + + // Create a minimal BaseApp with snapshot support + memDB := dbm.NewMemDB() + ba := baseapp.NewBaseApp( + "terpd", + logger, + memDB, + nil, // tx decoder not needed + baseapp.SetSnapshot(store, snapshottypes.NewSnapshotOptions(0, 0)), + ) + + // Convert to ABCI snapshot for OfferSnapshot + abciSnap, err := snap.ToABCI() + if err != nil { + return fmt.Errorf("failed to convert snapshot to ABCI: %w", err) + } + + // Step 1: OfferSnapshot + offerResp, err := ba.OfferSnapshot(&abci.RequestOfferSnapshot{ + Snapshot: &abciSnap, + AppHash: []byte{}, + }) + if err != nil { + return fmt.Errorf("OfferSnapshot failed: %w", err) + } + fmt.Printf("OfferSnapshot result: %s\n", offerResp.Result) + + // Step 2: Apply all chunks + for i := uint32(0); i < snap.Chunks; i++ { + chunkReader, err := store.LoadChunk(snap.Height, snap.Format, i) + if err != nil { + return fmt.Errorf("failed to load chunk %d: %w", i, err) + } + chunkBytes, err := io.ReadAll(chunkReader) + chunkReader.Close() + if err != nil { + return fmt.Errorf("failed to read chunk %d: %w", i, err) + } + + applyResp, err := ba.ApplySnapshotChunk(&abci.RequestApplySnapshotChunk{ + Index: i, + Chunk: chunkBytes, + }) + if err != nil { + return fmt.Errorf("ApplySnapshotChunk %d failed: %w", i, err) + } + + fmt.Printf(" Applied chunk %d/%d -> %s\n", i+1, snap.Chunks, applyResp.Result) + if applyResp.Result == abci.ResponseApplySnapshotChunk_RETRY { + fmt.Println(" -> Chunk asked for retry") + } + } + + fmt.Println("\nState-sync dry-run completed successfully!") + fmt.Printf("Final app hash: %X\n", ba.LastCommitID().Hash) + return nil + }, + } + cmd.Flags().Uint64("height", 0, "Snapshot height (0 = auto-detect latest)") + return cmd +} + +// ====================== FETCH (P2P DOWNLOAD) COMMAND ====================== +func fetchSnapshotCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "fetch", + Short: "Fetch a state-sync snapshot from the production network via P2P", + Long: `Bootstraps a temporary CometBFT node in state-sync mode, connects to +production peers via P2P, discovers and downloads a snapshot, then verifies it. + +CometBFT does not expose snapshot listing/fetching via RPC — snapshot exchange +happens exclusively over P2P (channels 0x60/0x61). This command handles the +full P2P bootstrap automatically. + +The fetched data is stored in a temporary directory that you can inspect with +the other statesync subcommands (list, query, test).`, + RunE: func(cmd *cobra.Command, args []string) error { + home, _ := cmd.Flags().GetString("home") + rpcAddr, _ := cmd.Flags().GetString("rpc-addr") + timeout, _ := cmd.Flags().GetDuration("timeout") + trustOffset, _ := cmd.Flags().GetInt64("trust-offset") + + if home == "" { + home = app.DefaultNodeHome + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // ──── Step 1: Connect to production RPC ──── + fmt.Printf("Connecting to production RPC: %s\n", rpcAddr) + + rpcClient, err := rpchttp.New(rpcAddr, "/websocket") + if err != nil { + return fmt.Errorf("failed to connect to RPC: %w", err) + } + + status, err := rpcClient.Status(ctx) + if err != nil { + return fmt.Errorf("failed to get node status: %w", err) + } + + latestHeight := status.SyncInfo.LatestBlockHeight + network := status.NodeInfo.Network + rpcNodeID := string(status.NodeInfo.DefaultNodeID) + + fmt.Printf(" Network : %s\n", network) + fmt.Printf(" Latest height: %d\n", latestHeight) + fmt.Printf(" Node ID : %s\n", rpcNodeID) + + // Trust height / hash + trustHeight := latestHeight - trustOffset + if trustHeight < 1 { + trustHeight = 1 + } + + block, err := rpcClient.Block(ctx, &trustHeight) + if err != nil { + return fmt.Errorf("failed to get block at height %d: %w", trustHeight, err) + } + trustHash := hex.EncodeToString(block.BlockID.Hash) + + fmt.Printf(" Trust height : %d\n", trustHeight) + fmt.Printf(" Trust hash : %s\n", trustHash) + + // Discover peers + netInfo, err := rpcClient.NetInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get net_info: %w", err) + } + + var peers []string + rpcHost := extractHost(rpcAddr) + if rpcHost != "" { + peers = append(peers, fmt.Sprintf("%s@%s:26656", rpcNodeID, rpcHost)) + } + for _, peer := range netInfo.Peers { + addr := peer.RemoteIP + port := "26656" + if parts := strings.Split(peer.NodeInfo.ListenAddr, ":"); len(parts) > 1 { + port = parts[len(parts)-1] + } + peers = append(peers, fmt.Sprintf("%s@%s:%s", peer.NodeInfo.DefaultNodeID, addr, port)) + } + + if len(peers) == 0 { + return fmt.Errorf("no peers discovered from RPC node") + } + fmt.Printf(" Peers found : %d\n\n", len(peers)) + + // ──── Step 2: Create temporary directory ──── + tmpDir, err := os.MkdirTemp("", "terpd-statesync-*") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + fmt.Printf("Temp directory: %s\n", tmpDir) + + configDir := filepath.Join(tmpDir, "config") + dataDir := filepath.Join(tmpDir, "data") + if err := os.MkdirAll(configDir, 0o755); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return fmt.Errorf("failed to create data dir: %w", err) + } + + // Copy genesis.json from user's node home + genSrc := filepath.Join(home, "config", "genesis.json") + genDst := filepath.Join(configDir, "genesis.json") + if err := copyFile(genSrc, genDst); err != nil { + return fmt.Errorf("failed to copy genesis.json from %s: %w", genSrc, err) + } + fmt.Println(" Genesis copied") + + // Generate temp node key + nodeKey, err := p2p.LoadOrGenNodeKey(filepath.Join(configDir, "node_key.json")) + if err != nil { + return fmt.Errorf("failed to generate node key: %w", err) + } + fmt.Printf(" Temp node ID: %s\n", nodeKey.ID()) + + // Generate temp priv validator + pvKeyFile := filepath.Join(configDir, "priv_validator_key.json") + pvStateFile := filepath.Join(dataDir, "priv_validator_state.json") + filePV := pvm.GenFilePV(pvKeyFile, pvStateFile) + filePV.Save() + + // ──── Step 3: Build CometBFT config ──── + cmtCfg := cmtcfg.DefaultConfig() + cmtCfg.RootDir = tmpDir + cmtCfg.DBBackend = "goleveldb" + cmtCfg.P2P.ListenAddress = "tcp://0.0.0.0:26658" // avoid conflict with running node + cmtCfg.P2P.PersistentPeers = strings.Join(peers, ",") + cmtCfg.P2P.AllowDuplicateIP = true + cmtCfg.P2P.PexReactor = false // private: no gossip + cmtCfg.P2P.MaxNumInboundPeers = 0 // private: reject inbound + cmtCfg.P2P.AddrBookStrict = false // allow non-routable addrs + cmtCfg.P2P.Seeds = "" // only persistent peers + cmtCfg.Mempool.Broadcast = false // don't broadcast txs + cmtCfg.RPC.ListenAddress = "tcp://127.0.0.1:26659" + cmtCfg.StateSync.Enable = true + cmtCfg.StateSync.RPCServers = []string{rpcAddr, rpcAddr} // same addr twice satisfies >=2 + cmtCfg.StateSync.TrustHeight = trustHeight + cmtCfg.StateSync.TrustHash = trustHash + cmtCfg.StateSync.TrustPeriod = 336 * time.Hour // 14 days + + // ──── Step 4: Create the app ──── + fmt.Println("\nCreating TerpApp for state-sync...") + + appDB, err := dbm.NewDB("application", dbm.GoLevelDBBackend, dataDir) + if err != nil { + return fmt.Errorf("failed to create app DB: %w", err) + } + defer appDB.Close() + + terpApp := app.NewTerpApp( + log.NewNopLogger(), + appDB, + nil, + false, // don't load latest — fresh state-sync + tmpDir, + simtestutil.NewAppOptionsWithFlagHome(tmpDir), + []wasmkeeper.Option{}, + ) + + // ──── Step 5: Create & start CometBFT node ──── + cmtApp := server.NewCometABCIWrapper(terpApp) + tmLogger := cmtlog.NewTMLogger(os.Stdout) + + cmtNode, err := cmtnode.NewNodeWithContext( + ctx, + cmtCfg, + filePV, + nodeKey, + proxy.NewLocalClientCreator(cmtApp), + cmtnode.DefaultGenesisDocProviderFunc(cmtCfg), + cmtcfg.DefaultDBProvider, + cmtnode.DefaultMetricsProvider(cmtCfg.Instrumentation), + tmLogger, + ) + if err != nil { + return fmt.Errorf("failed to create CometBFT node: %w", err) + } + + fmt.Println("Starting temporary CometBFT node for state-sync...") + if err := cmtNode.Start(); err != nil { + return fmt.Errorf("failed to start node: %w", err) + } + defer func() { + if err := cmtNode.Stop(); err != nil { + fmt.Printf("Warning: failed to stop node cleanly: %v\n", err) + } + cmtNode.Wait() + }() + + // ──── Step 6: Monitor state-sync progress ──── + fmt.Println("State-sync in progress (this may take several minutes)...") + + // Wait for temp node's RPC to be ready + time.Sleep(3 * time.Second) + + localClient, err := rpchttp.New("http://127.0.0.1:26659", "/websocket") + if err != nil { + return fmt.Errorf("failed to create local RPC client: %w", err) + } + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + deadline := time.After(timeout) + + for { + select { + case <-ticker.C: + st, err := localClient.Status(ctx) + if err != nil { + fmt.Print(".") + continue + } + if st.SyncInfo.LatestBlockHeight > 0 { + fmt.Printf("\n\nState-sync completed!\n") + fmt.Printf(" Height : %d\n", st.SyncInfo.LatestBlockHeight) + fmt.Printf(" App Hash : %X\n", st.SyncInfo.LatestAppHash) + fmt.Printf(" Temp dir : %s\n", tmpDir) + fmt.Println("\nVerify with:") + fmt.Printf(" terpd statesync list --home %s\n", tmpDir) + fmt.Printf(" terpd statesync query --home %s\n", tmpDir) + fmt.Printf(" terpd statesync test --home %s\n", tmpDir) + return nil + } + fmt.Print(".") + case <-deadline: + return fmt.Errorf("state-sync timed out after %s — peers may not have snapshots available", timeout) + } + } + }, + } + + cmd.Flags().String("rpc-addr", "https://rpc.terp.chaintools.tech:443", "Production RPC endpoint for trust info + peer discovery") + cmd.Flags().Duration("timeout", 5*time.Minute, "Maximum time to wait for state-sync completion") + cmd.Flags().Int64("trust-offset", 1000, "Blocks behind latest to set trust height") + + return cmd +} + +// ====================== HELPERS ====================== + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +func extractHost(rpcAddr string) string { + addr := strings.TrimPrefix(rpcAddr, "https://") + addr = strings.TrimPrefix(addr, "http://") + addr = strings.TrimPrefix(addr, "tcp://") + if idx := strings.Index(addr, ":"); idx >= 0 { + addr = addr[:idx] + } + if idx := strings.Index(addr, "/"); idx >= 0 { + addr = addr[:idx] + } + return addr +} From 511bdf6101658f1f347a2a51a9a14cab33cc6a5f Mon Sep 17 00:00:00 2001 From: hard-nett Date: Wed, 15 Apr 2026 20:40:19 -0400 Subject: [PATCH 2/7] fix: remove website installer from repo The Python installer belongs on the terp.network website, not in terp-core. Co-Authored-By: Claude Opus 4.6 --- .../terp.network/get/terp-installer.py | 384 ------------------ 1 file changed, 384 deletions(-) delete mode 100644 abstract/websites/terp.network/get/terp-installer.py diff --git a/abstract/websites/terp.network/get/terp-installer.py b/abstract/websites/terp.network/get/terp-installer.py deleted file mode 100644 index d593f26..0000000 --- a/abstract/websites/terp.network/get/terp-installer.py +++ /dev/null @@ -1,384 +0,0 @@ -#!/usr/bin/env python3 -""" -Terp Network Node Installer - -Thin wrapper that downloads the terpd binary, collects user preferences -via interactive prompts, then delegates all node setup to `terpd bootstrap`. - -Usage: - python3 terp-installer.py - python3 terp-installer.py --version v5.0.0 - python3 terp-installer.py --help -""" - -import argparse -import os -import platform -import shutil -import stat -import subprocess -import sys -import tarfile -import tempfile -import urllib.request -import urllib.error - -# ─── Constants ─────────────────────────────────────────────────────────────── - -GITHUB_REPO = "terpnetwork/terp-core" -BINARY_NAME = "terpd" -DEFAULT_VERSION = "v5.0.0" - -NETWORKS = { - "morocco-1": { - "name": "Mainnet (morocco-1)", - "chain_id": "morocco-1", - }, - "90u-4": { - "name": "Testnet (90u-4)", - "chain_id": "90u-4", - }, -} - -PRUNING_OPTIONS = { - "1": ("default", "Keep recent state + periodic snapshots (recommended)"), - "2": ("nothing", "Keep all state (archival node, uses most disk)"), - "3": ("everything", "Prune aggressively (minimal disk, no historical queries)"), -} - - -# ─── Helpers ───────────────────────────────────────────────────────────────── - -def clear_screen(): - os.system("cls" if os.name == "nt" else "clear") - - -def welcome_message(): - clear_screen() - print(r""" - ___________ _______ __ - \__ ___/___________ ____ \ \ _____/ |___ _ _____________ __ - | | / __ \_ __ \| _ \ / | \_/ __ \ __\ \/ \/ / _ \_ __ |/ / - | |\ ___/| | \/| |_) )/ | \ ___/| | \ ( (_) ) | \/ < - |____| \___ >__| | __/ \____|__ /\___ >__| \/\_/ \____/|__| |__| - \/ |__| \/ \/ - - Terp Network Node Installer - https://terp.network - """) - - -def prompt_choice(prompt, options, default=None): - """Display numbered options and return the user's choice.""" - print(f"\n{prompt}") - for key, (label, desc) in options.items(): - marker = " (*)" if key == default else "" - print(f" {key}) {label} — {desc}{marker}") - - while True: - choice = input(f"\nEnter choice [{default or ''}]: ").strip() - if not choice and default: - return default - if choice in options: - return choice - print(f"Invalid choice. Please enter one of: {', '.join(options.keys())}") - - -def prompt_yes_no(prompt, default=True): - """Simple yes/no prompt.""" - hint = "[Y/n]" if default else "[y/N]" - answer = input(f"{prompt} {hint}: ").strip().lower() - if not answer: - return default - return answer in ("y", "yes") - - -def prompt_string(prompt, default=""): - """Prompt for a string value with optional default.""" - if default: - answer = input(f"{prompt} [{default}]: ").strip() - return answer if answer else default - while True: - answer = input(f"{prompt}: ").strip() - if answer: - return answer - print("Please enter a value.") - - -def detect_platform(): - """Detect OS and architecture for binary download.""" - system = platform.system().lower() - machine = platform.machine().lower() - - if system == "darwin": - os_name = "darwin" - elif system == "linux": - os_name = "linux" - else: - print(f"Error: Unsupported OS: {system}") - sys.exit(1) - - if machine in ("x86_64", "amd64"): - arch = "amd64" - elif machine in ("aarch64", "arm64"): - arch = "arm64" - else: - print(f"Error: Unsupported architecture: {machine}") - sys.exit(1) - - return os_name, arch - - -def download_binary(version, dest_dir): - """Download the terpd binary from GitHub releases.""" - os_name, arch = detect_platform() - - # Release tarball naming: terpd---.tar.gz - tarball = f"terpd-{version}-{os_name}-{arch}.tar.gz" - url = f"https://github.com/{GITHUB_REPO}/releases/download/{version}/{tarball}" - - print(f"\nDownloading {BINARY_NAME} {version} for {os_name}/{arch}...") - print(f" URL: {url}") - - with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: - tmp_path = tmp.name - - try: - urllib.request.urlretrieve(url, tmp_path) - except urllib.error.HTTPError as e: - if e.code == 404: - print(f"\nError: Release {version} not found for {os_name}/{arch}.") - print(f"Check available releases at: https://github.com/{GITHUB_REPO}/releases") - sys.exit(1) - raise - - # Extract the binary - with tarfile.open(tmp_path, "r:gz") as tar: - # Look for the terpd binary inside the tarball - members = tar.getmembers() - terpd_member = None - for m in members: - if m.name.endswith(BINARY_NAME) or os.path.basename(m.name) == BINARY_NAME: - terpd_member = m - break - - if terpd_member is None: - print(f"Error: {BINARY_NAME} not found in release tarball.") - sys.exit(1) - - # Extract to dest_dir - terpd_member.name = BINARY_NAME # flatten path - tar.extract(terpd_member, dest_dir) - - os.unlink(tmp_path) - - binary_path = os.path.join(dest_dir, BINARY_NAME) - os.chmod(binary_path, os.stat(binary_path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) - - print(f" Installed: {binary_path}") - return binary_path - - -def install_to_path(binary_path): - """Copy binary to a directory on PATH (e.g., /usr/local/bin).""" - install_dir = "/usr/local/bin" - dest = os.path.join(install_dir, BINARY_NAME) - - if not os.access(install_dir, os.W_OK): - print(f"\nCopying {BINARY_NAME} to {install_dir} (requires sudo)...") - subprocess.run(["sudo", "cp", binary_path, dest], check=True) - subprocess.run(["sudo", "chmod", "+x", dest], check=True) - else: - shutil.copy2(binary_path, dest) - os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC) - - print(f" {BINARY_NAME} available at: {dest}") - return dest - - -def patch_client_toml(home, chain_id): - """Minimal client.toml patching for client-only install.""" - client_toml = os.path.join(home, "config", "client.toml") - if not os.path.exists(client_toml): - return - - with open(client_toml, "r") as f: - content = f.read() - - # Set chain-id and a reasonable RPC endpoint - rpc = "https://rpc.terp.network:443" - if chain_id == "90u-4": - rpc = "https://testnet-rpc.terp.network:443" - - content = content.replace('chain-id = ""', f'chain-id = "{chain_id}"') - content = content.replace('node = "tcp://localhost:26657"', f'node = "{rpc}"') - - with open(client_toml, "w") as f: - f.write(content) - - print(f" client.toml updated (chain-id={chain_id}, node={rpc})") - - -# ─── Install Flows ────────────────────────────────────────────────────────── - -def install_node(args): - """Full node installation: download binary + delegate to terpd bootstrap.""" - # Select network - network_choices = { - "1": ("morocco-1", "Mainnet — production network"), - "2": ("90u-4", "Testnet — test network"), - } - net_key = prompt_choice("Which network?", network_choices, default="1") - network = network_choices[net_key][0] - chain_id = NETWORKS[network]["chain_id"] - print(f" Selected: {NETWORKS[network]['name']}") - - # Download binary - version = args.version - with tempfile.TemporaryDirectory() as tmpdir: - binary_path = download_binary(version, tmpdir) - installed_path = install_to_path(binary_path) - - # Node home directory - default_home = os.path.expanduser("~/.terp") - home = prompt_string("\nNode home directory", default=default_home) - - # Moniker - moniker = prompt_string("Node moniker (display name)") - - # Pruning - pruning_key = prompt_choice("Pruning strategy", PRUNING_OPTIONS, default="1") - pruning = PRUNING_OPTIONS[pruning_key][0] - - # Cosmovisor - cosmovisor = prompt_yes_no("\nInstall cosmovisor for automatic upgrades?", default=False) - - # Systemd service (Linux only) - service = False - if platform.system() == "Linux": - service = prompt_yes_no("Create systemd service?", default=False) - - # Build the bootstrap command - cmd = [ - installed_path, "bootstrap", - "--network", chain_id, - "--home", home, - "--moniker", moniker, - "--pruning", pruning, - ] - if cosmovisor: - cmd.append("--cosmovisor") - if service: - cmd.append("--service") - - print(f"\n{'─' * 60}") - print("Running: " + " ".join(cmd)) - print(f"{'─' * 60}\n") - - # Delegate to terpd bootstrap — it handles init, genesis, peers, sync, start - try: - os.execvp(cmd[0], cmd) - except FileNotFoundError: - print(f"Error: {cmd[0]} not found. Is it installed correctly?") - sys.exit(1) - - -def install_client(args): - """Client-only installation: binary + init + config.""" - # Select network - network_choices = { - "1": ("morocco-1", "Mainnet — production network"), - "2": ("90u-4", "Testnet — test network"), - } - net_key = prompt_choice("Which network?", network_choices, default="1") - network = network_choices[net_key][0] - chain_id = NETWORKS[network]["chain_id"] - - # Download binary - version = args.version - with tempfile.TemporaryDirectory() as tmpdir: - binary_path = download_binary(version, tmpdir) - installed_path = install_to_path(binary_path) - - # Node home directory - default_home = os.path.expanduser("~/.terp") - home = prompt_string("\nClient home directory", default=default_home) - - # Moniker - moniker = prompt_string("Client name") - - # Init - print(f"\nInitializing client config...") - subprocess.run( - [installed_path, "init", moniker, "--chain-id", chain_id, "--home", home], - check=True, - ) - - # Patch client.toml - patch_client_toml(home, chain_id) - - print(f"\n{'─' * 60}") - print(f"Client setup complete!") - print(f" Home : {home}") - print(f" Chain ID: {chain_id}") - print(f"\nYou can now run:") - print(f" terpd status --home {home}") - print(f" terpd query bank balances
--home {home}") - print(f"{'─' * 60}") - - -def install_localterp(args): - """Local development chain (single validator).""" - version = args.version - with tempfile.TemporaryDirectory() as tmpdir: - binary_path = download_binary(version, tmpdir) - installed_path = install_to_path(binary_path) - - home = os.path.expanduser("~/.terp-local") - print(f"\nStarting local development chain at {home}...") - print("This will create a single-validator chain for testing.\n") - - subprocess.run( - [installed_path, "init", "localterp", "--chain-id", "localterp-1", "--home", home], - check=True, - ) - - print(f"\n{'─' * 60}") - print(f"Local chain initialized at {home}") - print(f"Start with: terpd start --home {home}") - print(f"{'─' * 60}") - - -# ─── Main ──────────────────────────────────────────────────────────────────── - -def main(): - parser = argparse.ArgumentParser( - description="Terp Network Node Installer", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--version", default=DEFAULT_VERSION, - help=f"terpd version to install (default: {DEFAULT_VERSION})", - ) - args = parser.parse_args() - - welcome_message() - - install_types = { - "1": ("Node", "Full node — sync with the network and validate"), - "2": ("Client", "Client only — query the chain, no syncing"), - "3": ("LocalTerp", "Local dev chain — single validator for testing"), - } - - choice = prompt_choice("What would you like to install?", install_types, default="1") - - if choice == "1": - install_node(args) - elif choice == "2": - install_client(args) - elif choice == "3": - install_localterp(args) - - -if __name__ == "__main__": - main() From 7725baf4297bd6cca48c5cbd29f358b772ad7d9f Mon Sep 17 00:00:00 2001 From: hard-nett Date: Thu, 16 Apr 2026 18:07:50 -0400 Subject: [PATCH 3/7] feat: bootstrap improvements, Docker ZK support, ICT test framework - bootstrap: fix genesis URL to terpnetwork/networks repo, remove dead RPC endpoint, add --setup-only flag for headless setup - Dockerfile: add WASMVM_SOURCE arg (github/local) for ZK-flavored builds - docker.mk: overhaul with multi-target builds (runtime, oline, localterp), ZK dep staging, and WASMVM_SOURCE-aware go.mod rewriting - Add ict-E2E workflow (replaces old interchaintest-E2E) - Add ict.mk makefile for interchain test runner - Add bootstrap_ict_test.go with production RPC connectivity tests - Bump buf.yaml cosmos-sdk proto dep to v0.53.0 --- .github/workflows/ict-E2E.yml | 213 ++++++++++++++++ .github/workflows/interchaintest-E2E.yml | 108 --------- .github/workflows/release.yml | 2 +- .gitignore | 2 +- Dockerfile | 12 +- Makefile | 2 + cmd/terpd/cmd/bootstrap.go | 15 +- cmd/terpd/cmd/bootstrap_ict_test.go | 164 +++++++++++++ cmd/terpd/cmd/config.go | 1 - proto/buf.yaml | 2 +- scripts/makefiles/docker.mk | 296 ++++++++++++++++++++++- scripts/makefiles/ict.mk | 31 +++ 12 files changed, 720 insertions(+), 128 deletions(-) create mode 100644 .github/workflows/ict-E2E.yml delete mode 100644 .github/workflows/interchaintest-E2E.yml create mode 100644 cmd/terpd/cmd/bootstrap_ict_test.go create mode 100644 scripts/makefiles/ict.mk diff --git a/.github/workflows/ict-E2E.yml b/.github/workflows/ict-E2E.yml new file mode 100644 index 0000000..78bf323 --- /dev/null +++ b/.github/workflows/ict-E2E.yml @@ -0,0 +1,213 @@ +name: ictest E2E + +on: + pull_request: + push: + tags: + - "**" + branches: + - "main" + - "master" + +permissions: + contents: read + packages: write + +env: + GO_VERSION: 1.24.5 + TAR_PATH: /tmp/terp-docker-image.tar + IMAGE_NAME: terp-docker-image + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: interchaintest/go.sum + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and export + uses: docker/build-push-action@v6 + with: + context: . + tags: terpnetwork/terp-core:local + outputs: type=docker,dest=${{ env.TAR_PATH }} + + - name: Upload artifact + uses: actions/upload-artifact@v5 + with: + name: ${{ env.IMAGE_NAME }} + path: ${{ env.TAR_PATH }} + + e2e-tests: + needs: build-docker + runs-on: ubuntu-latest + strategy: + matrix: + test: + - "e2e-basic" + - "e2e-pfm" + - "e2e-ibc" + - "e2e-polytone" + # - "e2e-clock" + # - "e2e-upgrade" + # - "e2e-drip" + # - "e2e-cwhooks" + fail-fast: false + + steps: + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: interchaintest/go.sum + + - name: checkout chain + uses: actions/checkout@v6 + + - name: Download Tarball Artifact + uses: actions/download-artifact@v6 + with: + name: ${{ env.IMAGE_NAME }} + path: /tmp + + - name: Load Docker Image + run: | + docker image load -i ${{ env.TAR_PATH }} + docker image ls -a + + - name: Run Test + id: run_test + continue-on-error: true + run: make ${{ matrix.test }} + + - name: Retry Failed Test + if: steps.run_test.outcome == 'failure' + run: | + for i in 1 2; do + echo "Retry attempt $i" + if make ${{ matrix.test }}; then + echo "Test passed on retry" + exit 0 + fi + done + echo "Test failed after retries" + exit 1 + + build-ict: + runs-on: ubuntu-latest + steps: + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + workspace/ict-rs/target + key: ${{ runner.os }}-cargo-ict-${{ hashFiles('workspace/ict-rs/**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-ict- + + - name: Detect dependency profile + id: deps + run: | + BRANCH="${{ github.head_ref || github.ref_name }}" + if echo "$BRANCH" | grep -qi "zk"; then + echo "cosmos_rust_ref=zk" >> $GITHUB_OUTPUT + echo "tendermint_ref=zk" >> $GITHUB_OUTPUT + echo "cw_orch_ref=zk" >> $GITHUB_OUTPUT + else + echo "cosmos_rust_ref=main" >> $GITHUB_OUTPUT + echo "tendermint_ref=main" >> $GITHUB_OUTPUT + echo "cw_orch_ref=cw3" >> $GITHUB_OUTPUT + fi + + - name: Clone ict-rs and dependencies + run: | + mkdir -p workspace + git clone --depth 1 https://github.com/permissionlessweb/ict-rs.git workspace/ict-rs + git clone --depth 1 -b ${{ steps.deps.outputs.cosmos_rust_ref }} https://github.com/permissionlessweb/cosmos-rust.git workspace/cosmos-rust + git clone --depth 1 -b ${{ steps.deps.outputs.tendermint_ref }} https://github.com/permissionlessweb/tendermint-rs.git workspace/tendermint-rs + git clone --depth 1 -b ${{ steps.deps.outputs.cw_orch_ref }} https://github.com/permissionlessweb/cw-orchestrator.git workspace/cw-orchestrator + + - name: Checkout terp-core for contract wasm + uses: actions/checkout@v6 + with: + path: workspace/terp-core + + - name: Build all ict-rs examples + run: cd workspace/ict-rs && cargo build --examples --features docker + + - name: Package example binaries + run: | + mkdir -p /tmp/ict-binaries + cp workspace/ict-rs/target/debug/examples/state_sync /tmp/ict-binaries/ + cp workspace/ict-rs/target/debug/examples/bootstrap_mainnet /tmp/ict-binaries/ + cp workspace/ict-rs/target/debug/examples/loyalty_rewards /tmp/ict-binaries/ + # Package loyalty-db assets and contract wasm + mkdir -p /tmp/ict-binaries/loyalty-db + cp workspace/ict-rs/examples/loyalty-db/schema.sql /tmp/ict-binaries/loyalty-db/ + cp workspace/ict-rs/examples/loyalty-db/seed.js /tmp/ict-binaries/loyalty-db/ + cp workspace/ict-rs/examples/loyalty-db/package.json /tmp/ict-binaries/loyalty-db/ + cp workspace/terp-core/tests/interchaintest/contracts/loyalty_verifier.wasm /tmp/ict-binaries/ + tar -czf /tmp/ict-binaries.tar.gz -C /tmp/ict-binaries . + + - name: Upload ict-rs binaries + uses: actions/upload-artifact@v5 + with: + name: ict-binaries + path: /tmp/ict-binaries.tar.gz + + ict-tests: + needs: [build-docker, build-ict] + runs-on: ubuntu-latest + strategy: + matrix: + test: + - state_sync + - bootstrap_mainnet + - loyalty_rewards + fail-fast: false + + steps: + - name: Download Docker Tarball + uses: actions/download-artifact@v6 + with: + name: ${{ env.IMAGE_NAME }} + path: /tmp + + - name: Load Docker Image + run: | + docker image load -i ${{ env.TAR_PATH }} + docker image ls -a + + - name: Download ict-rs binaries + uses: actions/download-artifact@v6 + with: + name: ict-binaries + path: /tmp + + - name: Extract and run ict-rs test + env: + TERP_IMAGE_REPO: terpnetwork/terp-core + TERP_IMAGE_VERSION: local + run: | + mkdir -p /tmp/ict-binaries + tar -xzf /tmp/ict-binaries.tar.gz -C /tmp/ict-binaries + chmod +x /tmp/ict-binaries/${{ matrix.test }} + /tmp/ict-binaries/${{ matrix.test }} diff --git a/.github/workflows/interchaintest-E2E.yml b/.github/workflows/interchaintest-E2E.yml deleted file mode 100644 index 2325e65..0000000 --- a/.github/workflows/interchaintest-E2E.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: ictest E2E - -on: - pull_request: - push: - tags: - - "**" - branches: - - "main" - - "master" - -permissions: - contents: read - packages: write - -env: - GO_VERSION: 1.24.5 - TAR_PATH: /tmp/terp-docker-image.tar - IMAGE_NAME: terp-docker-image - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-docker: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Go ${{ env.GO_VERSION }} - uses: actions/setup-go@v6 - with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: interchaintest/go.sum - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and export - uses: docker/build-push-action@v6 - with: - context: . - tags: terpnetwork/terp-core:local - outputs: type=docker,dest=${{ env.TAR_PATH }} - - - name: Upload artifact - uses: actions/upload-artifact@v5 - with: - name: ${{ env.IMAGE_NAME }} - path: ${{ env.TAR_PATH }} - - e2e-tests: - needs: build-docker - runs-on: ubuntu-latest - strategy: - matrix: - # names of `make` commands to run tests - test: - - "e2e-basic" - - "e2e-pfm" - - "e2e-ibc" - - "e2e-polytone" - # - "e2e-clock" - # - "e2e-upgrade" - # - "e2e-drip" - # - "e2e-cwhooks" - fail-fast: false - - steps: - - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@v6 - with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: interchaintest/go.sum - - - name: checkout chain - uses: actions/checkout@v6 - - - name: Download Tarball Artifact - uses: actions/download-artifact@v6 - with: - name: ${{ env.IMAGE_NAME }} - path: /tmp - - - name: Load Docker Image - run: | - docker image load -i ${{ env.TAR_PATH }} - docker image ls -a - - - name: Run Test - id: run_test - continue-on-error: true - run: make ${{ matrix.test }} - - - name: Retry Failed Test - if: steps.run_test.outcome == 'failure' - run: | - for i in 1 2; do - echo "Retry attempt $i" - if make ${{ matrix.test }}; then - echo "Test passed on retry" - exit 0 - fi - done - echo "Test failed after retries" - exit 1 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f8833b..89748ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: --build-arg GO_VERSION=${{ steps.go-version.outputs.version }} \ --build-arg GIT_VERSION=${VERSION} \ --build-arg GIT_COMMIT=${COMMIT} \ - --build-arg RUNNER_IMAGE=alpine:3.17 \ + --build-arg RUNNER_IMAGE=alpine:3.20 \ --platform linux/${{ matrix.arch }} \ -t terp-core:local-${{ matrix.arch }} \ --load \ diff --git a/.gitignore b/.gitignore index 4443aee..1e25f5d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ dist heighliner* tools-stamp docs/node_modules - +target # Data - ideally these don't exist baseapp/data/* client/lcd/keys/* diff --git a/Dockerfile b/Dockerfile index 2847342..f5d94e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,7 +65,17 @@ EXPOSE 1317 26656 26657 CMD ["/usr/local/bin/terpd"] # --------------------------------------------------------- -# 2. Localterp bootstrap image +# 2. O-Line deployment image — runtime + bootstrap tools +# --------------------------------------------------------- +FROM runtime AS oline +RUN apk add --no-cache \ + bash curl jq openssh openssl \ + coreutils file pv lz4 zstd unzip wget \ + nginx sed gawk +EXPOSE 1317 26656 26657 9090 22 80 + +# --------------------------------------------------------- +# 3. Localterp bootstrap image # --------------------------------------------------------- FROM alpine:3.17 AS localterp RUN apk add --no-cache \ diff --git a/Makefile b/Makefile index 31ca0c5..ffa124e 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ include scripts/makefiles/build.mk include scripts/makefiles/docker.mk include scripts/makefiles/e2e.mk +include scripts/makefiles/ict.mk include scripts/makefiles/format.mk include scripts/makefiles/tsh.mk # include scripts/makefiles/localnet.mk @@ -25,6 +26,7 @@ help: @echo " make deps Show available deps commands" @echo " make docker Show available docker commands" @echo " make e2e Show available e2e commands" + @echo " make ict Show available ict-rs commands" @echo " make lint Show available lint commands" @echo " make proto Show available proto commands" @echo " make release Show available release commands" diff --git a/cmd/terpd/cmd/bootstrap.go b/cmd/terpd/cmd/bootstrap.go index 56b41c7..f24d2f0 100644 --- a/cmd/terpd/cmd/bootstrap.go +++ b/cmd/terpd/cmd/bootstrap.go @@ -52,8 +52,8 @@ type networkPreset struct { var networkPresets = map[string]networkPreset{ "morocco-1": { ChainID: "morocco-1", - GenesisURL: "https://raw.githubusercontent.com/terpnetwork/mainnet/main/morocco-1/genesis.json", - RPCs: "https://rpc.terp.network:443,https://rpc.terp.chaintools.tech:443", + GenesisURL: "https://raw.githubusercontent.com/terpnetwork/networks/refs/heads/main/mainnet/morocco-1/genesis.json", + RPCs: "https://rpc.terp.chaintools.tech:443", }, "90u-4": { ChainID: "90u-4", @@ -66,10 +66,10 @@ var networkPresets = map[string]networkPreset{ func DefaultBootstrapConfig() BootstrapConfig { return BootstrapConfig{ SyncMode: "statesync", - GenesisURL: "https://raw.githubusercontent.com/terpnetwork/mainnet/main/morocco-1/genesis.json", + GenesisURL: "https://raw.githubusercontent.com/terpnetwork/networks/refs/heads/main/mainnet/morocco-1/genesis.json", GenesisHash: "", SnapshotURL: "", - StateSyncRPCs: "https://rpc.terp.network:443,https://rpc.terp.chaintools.tech:443", + StateSyncRPCs: "https://rpc.terp.chaintools.tech:443", TrustOffset: 1000, MaxRetries: 6, Seeds: "", @@ -164,6 +164,7 @@ func init() { BootstrapCmd.Flags().Bool("cosmovisor", false, "install cosmovisor via 'go install' and initialize it") BootstrapCmd.Flags().Bool("service", false, "create a systemd service (Linux only, works with --cosmovisor)") BootstrapCmd.Flags().String("pruning", "", "pruning strategy: default, nothing, or everything") + BootstrapCmd.Flags().Bool("setup-only", false, "perform setup without starting the node") } func runBootstrap(cmd *cobra.Command, args []string) error { @@ -311,6 +312,12 @@ func runBootstrap(cmd *cobra.Command, args []string) error { } // ──── Step 8: Exec into terpd start ──── + setupOnly, _ := cmd.Flags().GetBool("setup-only") + if setupOnly { + fmt.Println("Bootstrap complete (setup-only). Node is ready to start.") + return nil + } + fmt.Println("Bootstrap complete. Starting node...") startArgs := []string{"terpd", "start", "--home", home} diff --git a/cmd/terpd/cmd/bootstrap_ict_test.go b/cmd/terpd/cmd/bootstrap_ict_test.go new file mode 100644 index 0000000..4825c69 --- /dev/null +++ b/cmd/terpd/cmd/bootstrap_ict_test.go @@ -0,0 +1,164 @@ +//go:build ict + +package cmd + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + cmtcfg "github.com/cometbft/cometbft/config" + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + "github.com/stretchr/testify/require" +) + +// TestProductionRPCConnectivity verifies we can reach the production RPC, +// fetch trust info, and discover peers. Quick sanity check. +func TestProductionRPCConnectivity(t *testing.T) { + bsCfg := DefaultBootstrapConfig() + rpcs := splitTrimmed(bsCfg.StateSyncRPCs, ",") + require.NotEmpty(t, rpcs, "no statesync RPCs configured in defaults") + + trustHeight, trustHash, peers, err := fetchStateSyncInfo(rpcs[0], bsCfg.TrustOffset) + require.NoError(t, err, "failed to connect to production RPC %s", rpcs[0]) + require.True(t, trustHeight > 0, "trust height must be positive") + require.NotEmpty(t, trustHash, "trust hash must not be empty") + require.NotEmpty(t, peers, "must discover at least one peer") + + t.Logf("RPC : %s", rpcs[0]) + t.Logf("Trust height : %d", trustHeight) + t.Logf("Trust hash : %s", trustHash) + t.Logf("Peers found : %d", len(peers)) +} + +// TestBootstrapPrivateModeConfig verifies that private mode correctly locks +// down the CometBFT P2P config. +func TestBootstrapPrivateModeConfig(t *testing.T) { + cfg := cmtcfg.DefaultConfig() + + // Defaults should have PEX on + require.True(t, cfg.P2P.PexReactor) + require.True(t, cfg.Mempool.Broadcast) + require.True(t, cfg.P2P.MaxNumInboundPeers > 0) + + applyPrivateMode(cfg) + + require.False(t, cfg.P2P.PexReactor, "PEX should be disabled") + require.Equal(t, 0, cfg.P2P.MaxNumInboundPeers, "inbound peers should be 0") + require.False(t, cfg.P2P.AddrBookStrict, "addr book strict should be off") + require.Empty(t, cfg.P2P.Seeds, "seeds should be empty") + require.False(t, cfg.Mempool.Broadcast, "mempool broadcast should be off") +} + +// TestBootstrapMainnetSync is the full integration test: builds the binary, +// bootstraps a private node against mainnet, and verifies state-sync completes. +// +// Run with: +// +// go test -tags ict -run TestBootstrapMainnetSync -timeout 10m -v ./cmd/terpd/cmd/ +func TestBootstrapMainnetSync(t *testing.T) { + if testing.Short() { + t.Skip("skipping mainnet sync ICT in short mode") + } + + const ( + rpcPort = "46657" + p2pPort = "46656" + rpcAddr = "http://127.0.0.1:" + rpcPort + ) + + // ──── Phase 1: Build binary ──── + t.Log("Phase 1: Building terpd binary...") + projectRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + require.NoError(t, err) + + binPath := filepath.Join(t.TempDir(), "terpd-ict") + buildCmd := exec.Command("go", "build", "-o", binPath, "./cmd/terpd") + buildCmd.Dir = projectRoot + out, err := buildCmd.CombinedOutput() + require.NoError(t, err, "build failed:\n%s", string(out)) + t.Logf(" Binary: %s", binPath) + + // ──── Phase 2: Init + custom ports ──── + t.Log("Phase 2: Initializing node with custom ports...") + tmpHome := t.TempDir() + + initProc := exec.Command(binPath, "init", "ict-test", "--chain-id", "morocco-1", "--home", tmpHome) + out, err = initProc.CombinedOutput() + require.NoError(t, err, "init failed:\n%s", string(out)) + + // Set custom ports so we don't conflict with any running node + cfg, err := loadCometConfig(tmpHome) + require.NoError(t, err) + cfg.P2P.ListenAddress = "tcp://0.0.0.0:" + p2pPort + cfg.RPC.ListenAddress = "tcp://127.0.0.1:" + rpcPort + cmtcfg.WriteConfigFile(filepath.Join(tmpHome, "config", "config.toml"), cfg) + t.Logf(" Home: %s (P2P=%s, RPC=%s)", tmpHome, p2pPort, rpcPort) + + // ──── Phase 3: Run bootstrap ──── + t.Log("Phase 3: Running bootstrap (private mode, state-sync to mainnet)...") + + ctx, cancel := context.WithTimeout(context.Background(), 9*time.Minute) + defer cancel() + + bootstrapProc := exec.CommandContext(ctx, binPath, "bootstrap", + "--home", tmpHome, + "--chain-id", "morocco-1", + "--moniker", "ict-test", + ) + bootstrapProc.Stdout = os.Stdout + bootstrapProc.Stderr = os.Stderr + require.NoError(t, bootstrapProc.Start(), "failed to start bootstrap") + + // Track early exits + procDone := make(chan error, 1) + go func() { + procDone <- bootstrapProc.Wait() + }() + defer func() { + if bootstrapProc.Process != nil { + bootstrapProc.Process.Kill() + } + <-procDone + }() + + // ──── Phase 4: Monitor sync ──── + t.Log("Phase 4: Waiting for state-sync (polling every 10s)...") + time.Sleep(20 * time.Second) // let node boot + + rpcClient, err := rpchttp.New(rpcAddr, "/websocket") + require.NoError(t, err) + + deadline := time.After(8 * time.Minute) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case err := <-procDone: + t.Fatalf("bootstrap process exited unexpectedly: %v", err) + + case <-ticker.C: + status, err := rpcClient.Status(context.Background()) + if err != nil { + t.Log(" Node starting up...") + continue + } + h := status.SyncInfo.LatestBlockHeight + if h > 0 { + t.Logf(" SYNCED at height %d", h) + t.Logf(" App hash : %X", status.SyncInfo.LatestAppHash) + t.Logf(" Network : %s", status.NodeInfo.Network) + t.Log("SUCCESS: Bootstrap state-sync to mainnet completed!") + return + } + t.Log(" State-sync in progress...") + + case <-deadline: + t.Fatal("state-sync did not complete within 8 minutes — peers may not have snapshots available") + } + } +} diff --git a/cmd/terpd/cmd/config.go b/cmd/terpd/cmd/config.go index 34e5f86..dca4060 100644 --- a/cmd/terpd/cmd/config.go +++ b/cmd/terpd/cmd/config.go @@ -27,7 +27,6 @@ type TerpCustomClient struct { Fees string `mapstructure:"fees" json:"fees"` FeeGranter string `mapstructure:"fee-granter" json:"fee-granter"` FeePayer string `mapstructure:"fee-payer" json:"fee-payer"` - Note string `mapstructure:"note" json:"note"` } diff --git a/proto/buf.yaml b/proto/buf.yaml index cae10f0..4dcc93a 100644 --- a/proto/buf.yaml +++ b/proto/buf.yaml @@ -1,7 +1,7 @@ version: v1 name: buf.build/terpnetwork/terp-core deps: - - buf.build/cosmos/cosmos-sdk:v0.47.0 + - buf.build/cosmos/cosmos-sdk:v0.53.0 - buf.build/cosmos/cosmos-proto - buf.build/cosmos/gogo-proto - buf.build/googleapis/googleapis diff --git a/scripts/makefiles/docker.mk b/scripts/makefiles/docker.mk index 2a3183f..98e24cd 100644 --- a/scripts/makefiles/docker.mk +++ b/scripts/makefiles/docker.mk @@ -6,23 +6,114 @@ RUNNER_BASE_IMAGE_DISTROLESS := gcr.io/distroless/static-debian11 RUNNER_BASE_IMAGE_ALPINE := alpine:3.17 RUNNER_BASE_IMAGE_NONROOT := gcr.io/distroless/static-debian11:nonroot +# --------------------------------------------------------------------------- +# WASMVM_LIB — optional path to a pre-built libwasmvm_muslc .a file. +# +# Standard build (download from CosmWasm GitHub releases): +# make docker-build +# +# Local build (use your own pre-built static lib): +# make docker-build WASMVM_LIB=../zk-wasmvm/internal/api/libwasmvm_muslc.aarch64.a +# +# When WASMVM_LIB is set, the lib + Go source for zk-wasmvm and zk-wasmd +# are staged into build/ so the Dockerfile can COPY them. +# --------------------------------------------------------------------------- + +WASMVM_LIB ?= + +# Upstream version for GitHub download URL (auto-detected from go.mod). +COSMWASM_VERSION ?= $(shell grep 'CosmWasm/wasmvm' go.mod 2>/dev/null | grep -v '=>' | awk '{print $$2}') + +# Derived source mode: local when WASMVM_LIB is set, github otherwise. +_WASMVM_SOURCE = $(if $(WASMVM_LIB),local,github) + +# Sibling repo paths for staging Go source (only used when WASMVM_LIB is set). +ZK_WASMVM_DIR ?= ../zk-wasmvm +ZK_WASMD_DIR ?= ../zk-wasmd + +# Container registry for push targets +DOCKER_REGISTRY ?= ghcr.io/terpnetwork +DOCKER_TAG ?= $(VERSION) +DOCKER_PLATFORMS ?= linux/amd64,linux/arm64 + +.PHONY: docker docker-help docker-build docker-build-distroless docker-build-alpine \ + docker-build-nonroot docker-build-localnet docker-localterp docker-clean \ + build-zk-local build-zk-local-localnet _docker-stage-zk-lib \ + docker-build-zk docker-build-zk-localnet docker-stage-zk docker-clean-zk \ + _docker-stage _docker-stage-zk-multiarch \ + docker-push docker-push-oline docker-push-localterp \ + docker-push-zk docker-push-zk-oline docker-push-zk-localterp \ + docker-push-all docker-push-standard-all docker-push-zk-all + docker-help: @echo "docker subcommands" @echo "" @echo "Usage:" - @echo " make [command]" + @echo " make docker-build # GitHub wasmvm" + @echo " make docker-build WASMVM_LIB=path/to/libwasmvm_muslc.a # local wasmvm" @echo "" @echo "Available Commands:" - @echo " docker-build Build Docker image" - @echo " docker-build-distroless Build distroless Docker image" + @echo " docker-build Build Docker image (distroless runtime)" @echo " docker-build-alpine Build alpine Docker image" @echo " docker-build-nonroot Build nonroot Docker image" + @echo " docker-build-localnet Build localterp dev image" + @echo " docker-localterp Alias for docker-build-localnet" + @echo " build-zk-local Build with ../zk-wasmvm (auto-detect lib)" + @echo " build-zk-local-localnet Build localterp with ../zk-wasmvm" + @echo " docker-clean Remove staged wasmvm dependencies" + @echo "" + @echo " docker-push Build + push multi-arch runtime to registry" + @echo " docker-push-oline Build + push multi-arch oline to registry" + @echo " docker-push-localterp Build + push multi-arch localterp to registry" + @echo " docker-push-zk Build + push multi-arch zk runtime to registry" + @echo " docker-push-zk-oline Build + push multi-arch zk oline to registry" + @echo " docker-push-zk-localterp Build + push multi-arch zk localterp to registry" + @echo "" + @echo " docker-push-standard-all Build + push all standard images (runtime, oline, localterp)" + @echo " docker-push-zk-all Build + push all zk images (runtime, oline, localterp)" + @echo " docker-push-all Build + push ALL images (standard + zk)" + @echo "" + @echo "Current config:" + @echo " WASMVM_LIB = $(or $(WASMVM_LIB),(unset — will download from GitHub))" + @echo " COSMWASM_VERSION = $(COSMWASM_VERSION)" + @echo " WASMVM_SOURCE = $(_WASMVM_SOURCE)" + @echo " DOCKER_REGISTRY = $(DOCKER_REGISTRY)" + @echo " DOCKER_TAG = $(DOCKER_TAG)" + @echo " DOCKER_PLATFORMS = $(DOCKER_PLATFORMS)" docker: docker-help -docker-build-localnet: - docker buildx build --target localterp -t terpnetwork/terp-core:localterp . +# --------------------------------------------------------- +# Stage local wasmvm lib + Go source (no-op when WASMVM_LIB is unset) +# --------------------------------------------------------- + +_docker-stage: +ifdef WASMVM_LIB + @echo "==> Staging local wasmvm lib: $(WASMVM_LIB)" + @mkdir -p build/wasmvm build/zk-deps/zk-wasmvm build/zk-deps/zk-wasmd + @cp $(WASMVM_LIB) build/wasmvm/ + @echo "==> Staging zk-wasmvm Go source (excluding target/ and .git/) ..." + @rsync -a --delete \ + --exclude='libwasmvm/target/' \ + --exclude='.git/' \ + $(ZK_WASMVM_DIR)/ build/zk-deps/zk-wasmvm/ + @echo "==> Staging zk-wasmd Go source ..." + @rsync -a --delete \ + --exclude='.git/' \ + $(ZK_WASMD_DIR)/ build/zk-deps/zk-wasmd/ + @echo "==> Staged:" + @ls -lh build/wasmvm/ +endif + +docker-clean: + @echo "==> Removing staged wasmvm dependencies ..." + rm -rf build/zk-deps build/wasmvm + @echo "Done." -docker-build: +# --------------------------------------------------------- +# Build targets — all respect WASMVM_LIB +# --------------------------------------------------------- + +docker-build: _docker-stage @DOCKER_BUILDKIT=1 docker build \ -t terpnetwork/terp-core:local \ --target runtime \ @@ -30,26 +121,209 @@ docker-build: --build-arg RUNNER_IMAGE=$(RUNNER_BASE_IMAGE_DISTROLESS) \ --build-arg GIT_VERSION=$(VERSION) \ --build-arg GIT_COMMIT=$(COMMIT) \ + --build-arg COSMWASM_VERSION=$(COSMWASM_VERSION) \ + --build-arg WASMVM_SOURCE=$(_WASMVM_SOURCE) \ -f Dockerfile . docker-build-distroless: docker-build -docker-build-alpine: +docker-build-alpine: _docker-stage @DOCKER_BUILDKIT=1 docker build \ -t terpnetwork/terp-core:local-alpine \ - --target runtime \ + --target runtime \ --build-arg GO_VERSION=$(GO_VERSION) \ --build-arg RUNNER_IMAGE=$(RUNNER_BASE_IMAGE_ALPINE) \ --build-arg GIT_VERSION=$(VERSION) \ --build-arg GIT_COMMIT=$(COMMIT) \ + --build-arg COSMWASM_VERSION=$(COSMWASM_VERSION) \ + --build-arg WASMVM_SOURCE=$(_WASMVM_SOURCE) \ -f Dockerfile . -docker-build-nonroot: +docker-build-nonroot: _docker-stage @DOCKER_BUILDKIT=1 docker build \ -t terpnetwork/terp-core:local-nonroot \ - --target runtime \ + --target runtime \ --build-arg GO_VERSION=$(GO_VERSION) \ --build-arg RUNNER_IMAGE=$(RUNNER_BASE_IMAGE_NONROOT) \ --build-arg GIT_VERSION=$(VERSION) \ --build-arg GIT_COMMIT=$(COMMIT) \ - -f Dockerfile . \ No newline at end of file + --build-arg COSMWASM_VERSION=$(COSMWASM_VERSION) \ + --build-arg WASMVM_SOURCE=$(_WASMVM_SOURCE) \ + -f Dockerfile . + +docker-build-localnet: _docker-stage + @DOCKER_BUILDKIT=1 docker buildx build \ + --target localterp \ + --build-arg COSMWASM_VERSION=$(COSMWASM_VERSION) \ + --build-arg WASMVM_SOURCE=$(_WASMVM_SOURCE) \ + -t terpnetwork/terp-core:localterp --load . + +docker-localterp: docker-build-localnet + +# --------------------------------------------------------- +# Local zk-wasmvm convenience targets +# +# Auto-resolve WASMVM_LIB from ../zk-wasmvm so you can just run: +# make build-zk-local +# --------------------------------------------------------- + +# Map macOS arm64 → aarch64 to match CosmWasm lib naming convention. +_HOST_ARCH := $(shell uname -m | sed 's/arm64/aarch64/') + +# Check internal/api first, then libwasmvm/artifacts as fallback. +_ZK_DEFAULT_LIB = $(firstword \ + $(wildcard $(ZK_WASMVM_DIR)/internal/api/libwasmvm_muslc.$(_HOST_ARCH).a) \ + $(wildcard $(ZK_WASMVM_DIR)/libwasmvm/artifacts/libwasmvm_muslc.$(_HOST_ARCH).a)) + +build-zk-local: _docker-stage-zk-lib + @DOCKER_BUILDKIT=1 docker build \ + -t terpnetwork/terp-core:local-zk \ + --target runtime \ + --build-arg GO_VERSION=$(GO_VERSION) \ + --build-arg RUNNER_IMAGE=$(RUNNER_BASE_IMAGE_ALPINE) \ + --build-arg GIT_VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(COMMIT) \ + --build-arg WASMVM_SOURCE=local \ + -f Dockerfile . + +build-zk-local-localnet: _docker-stage-zk-lib + @DOCKER_BUILDKIT=1 docker buildx build \ + --target localterp \ + --build-arg WASMVM_SOURCE=local \ + -t terpnetwork/terp-core:localterp-zk --load . + +# Stage the auto-resolved lib + Go source for zk builds. +_docker-stage-zk-lib: + @if [ -z "$(_ZK_DEFAULT_LIB)" ]; then \ + echo "ERROR: libwasmvm_muslc.$(_HOST_ARCH).a not found in $(ZK_WASMVM_DIR)."; \ + echo "Build it first: just build-wasmvm-alpine"; \ + exit 1; \ + fi + $(MAKE) _docker-stage WASMVM_LIB=$(_ZK_DEFAULT_LIB) + +# Backwards-compat aliases +docker-build-zk: build-zk-local +docker-build-zk-localnet: build-zk-local-localnet +docker-stage-zk: _docker-stage +docker-clean-zk: docker-clean + +# --------------------------------------------------------- +# Multi-arch push targets +# +# Build + push to container registry (default: ghcr.io/terpnetwork). +# Override with: +# make docker-push DOCKER_TAG=v5.2.0 DOCKER_REGISTRY=ghcr.io/myorg +# --------------------------------------------------------- + +docker-push: _docker-stage + docker buildx build \ + --platform $(DOCKER_PLATFORMS) \ + --target runtime \ + --build-arg GO_VERSION=$(GO_VERSION) \ + --build-arg RUNNER_IMAGE=$(RUNNER_BASE_IMAGE_ALPINE) \ + --build-arg GIT_VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(COMMIT) \ + --build-arg COSMWASM_VERSION=$(COSMWASM_VERSION) \ + --build-arg WASMVM_SOURCE=$(_WASMVM_SOURCE) \ + --tag $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG) \ + --push \ + -f Dockerfile . + +docker-push-oline: _docker-stage + docker buildx build \ + --platform $(DOCKER_PLATFORMS) \ + --target oline \ + --build-arg GO_VERSION=$(GO_VERSION) \ + --build-arg RUNNER_IMAGE=$(RUNNER_BASE_IMAGE_ALPINE) \ + --build-arg GIT_VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(COMMIT) \ + --build-arg COSMWASM_VERSION=$(COSMWASM_VERSION) \ + --build-arg WASMVM_SOURCE=$(_WASMVM_SOURCE) \ + --tag $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG)-oline \ + --push \ + -f Dockerfile . + +docker-push-localterp: _docker-stage + docker buildx build \ + --platform $(DOCKER_PLATFORMS) \ + --target localterp \ + --build-arg GO_VERSION=$(GO_VERSION) \ + --build-arg COSMWASM_VERSION=$(COSMWASM_VERSION) \ + --build-arg WASMVM_SOURCE=$(_WASMVM_SOURCE) \ + --tag $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG)-localterp \ + --push \ + -f Dockerfile . + +# Stage both arch libs for multi-arch zk builds. +_docker-stage-zk-multiarch: + @echo "==> Staging zk-wasmvm libs for multi-arch build" + @mkdir -p build/wasmvm build/zk-deps/zk-wasmvm build/zk-deps/zk-wasmd + @for arch in aarch64 x86_64; do \ + lib=$$(find $(ZK_WASMVM_DIR) -name "libwasmvm_muslc.$$arch.a" 2>/dev/null | head -1); \ + if [ -z "$$lib" ]; then \ + echo "ERROR: libwasmvm_muslc.$$arch.a not found in $(ZK_WASMVM_DIR)"; \ + exit 1; \ + fi; \ + echo " Staging $$lib"; \ + cp "$$lib" build/wasmvm/; \ + done + @echo "==> Staging zk-wasmvm Go source ..." + @rsync -a --delete \ + --exclude='libwasmvm/target/' \ + --exclude='.git/' \ + $(ZK_WASMVM_DIR)/ build/zk-deps/zk-wasmvm/ + @echo "==> Staging zk-wasmd Go source ..." + @rsync -a --delete \ + --exclude='.git/' \ + $(ZK_WASMD_DIR)/ build/zk-deps/zk-wasmd/ + @echo "==> Staged:" + @ls -lh build/wasmvm/ + +docker-push-zk: _docker-stage-zk-multiarch + docker buildx build \ + --platform $(DOCKER_PLATFORMS) \ + --target runtime \ + --build-arg GO_VERSION=$(GO_VERSION) \ + --build-arg RUNNER_IMAGE=$(RUNNER_BASE_IMAGE_ALPINE) \ + --build-arg GIT_VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(COMMIT) \ + --build-arg WASMVM_SOURCE=local \ + --tag $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG) \ + --push \ + -f Dockerfile . + +docker-push-zk-oline: _docker-stage-zk-multiarch + docker buildx build \ + --platform $(DOCKER_PLATFORMS) \ + --target oline \ + --build-arg GO_VERSION=$(GO_VERSION) \ + --build-arg RUNNER_IMAGE=$(RUNNER_BASE_IMAGE_ALPINE) \ + --build-arg GIT_VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(COMMIT) \ + --build-arg WASMVM_SOURCE=local \ + --tag $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG)-oline \ + --push \ + -f Dockerfile . + +docker-push-zk-localterp: _docker-stage-zk-multiarch + docker buildx build \ + --platform $(DOCKER_PLATFORMS) \ + --target localterp \ + --build-arg GO_VERSION=$(GO_VERSION) \ + --build-arg WASMVM_SOURCE=local \ + --tag $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG)-localterp \ + --push \ + -f Dockerfile . + +# --------------------------------------------------------- +# Aggregate push targets +# --------------------------------------------------------- + +docker-push-standard-all: docker-push docker-push-oline docker-push-localterp + @echo "==> All standard images pushed to $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG)" + +docker-push-zk-all: docker-push-zk docker-push-zk-oline docker-push-zk-localterp + @echo "==> All zk images pushed to $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG)" + +docker-push-all: docker-push-standard-all docker-push-zk-all + @echo "==> All images (standard + zk) pushed to $(DOCKER_REGISTRY)/terp-core:$(DOCKER_TAG)" diff --git a/scripts/makefiles/ict.mk b/scripts/makefiles/ict.mk new file mode 100644 index 0000000..1bcbbe7 --- /dev/null +++ b/scripts/makefiles/ict.mk @@ -0,0 +1,31 @@ +############################################################################### +### ict-rs tests ### +############################################################################### +ICT_RS_DIR ?= $(HOME)/abstract/ict-rs + +ict-help: + @echo "ict-rs subcommands" + @echo "" + @echo "Usage:" + @echo " make ict-[command]" + @echo "" + @echo "Available Commands:" + @echo " ict-state-sync Run state sync test" + @echo " ict-bootstrap-mainnet Run bootstrap mainnet test" + @echo " ict-loyalty-rewards Run loyalty rewards privacy attestation test" + @echo " ict-all Run all ict-rs tests" + +ict: ict-help + +ict-state-sync: + cd $(ICT_RS_DIR) && cargo run --example state_sync --features docker + +ict-bootstrap-mainnet: + cd $(ICT_RS_DIR) && cargo run --example bootstrap_mainnet --features docker + +ict-loyalty-rewards: + cd $(ICT_RS_DIR) && cargo run --example loyalty_rewards --features "docker hashmerchant" + +ict-all: ict-state-sync ict-bootstrap-mainnet ict-loyalty-rewards + +.PHONY: ict-help ict ict-state-sync ict-bootstrap-mainnet ict-loyalty-rewards ict-all From eb7e9bc6e340e9eb50881526aa231bbf9ad396e0 Mon Sep 17 00:00:00 2001 From: hard-nett Date: Thu, 16 Apr 2026 19:37:11 -0400 Subject: [PATCH 4/7] feat: add Nix flake with vanilla/ZK dev environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three devShells for unified developer experience: - nix develop .#vanilla — Go-only, downloads prebuilt wasmvm - nix develop .#zk — Go + Rust nightly, compiles zk-wasmvm locally - nix develop (default) — full env with dep tooling (cargo-sort, python) Includes: Go 1.26, golangci-lint, gofumpt, buf, make, gcc, Docker, cargo-sort, and direnv integration via .envrc. WASMVM_SOURCE and TERP_FLAVOR env vars set per shell to match the Docker build arg convention from docker.mk. --- .envrc | 1 + flake.lock | 82 +++++++++++++++++++++++ flake.nix | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..4ce14fd --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1776350315, + "narHash": "sha256-ijD4bgb5Iyap9F3MX73vLAZF/SYu+q7Gd7Ux4cbfCWw=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "62e3b8aedabc240e5b0cc9fae003bc9edfebbc9b", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e1cc81a --- /dev/null +++ b/flake.nix @@ -0,0 +1,191 @@ +{ + description = "Terp-Core blockchain dev environment — vanilla & ZK flavors"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, rust-overlay }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { inherit system overlays; }; + + # ── Go toolchain ──────────────────────────────────────────────── + # go.mod requires 1.24.3+ (toolchain 1.24.7) + # nixpkgs tracks latest stable; Go >=1.24 satisfies the requirement. + goVersion = pkgs.go; + + # ── Rust toolchain (for zk-wasmvm libwasmvm) ─────────────────── + # zk-wasmvm needs nightly for backtrace feature; vanilla doesn't + # need Rust at all (downloads prebuilt libwasmvm from GitHub). + rustNightly = pkgs.rust-bin.nightly.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" ]; + targets = [ + "x86_64-unknown-linux-musl" + "aarch64-unknown-linux-musl" + ]; + }; + + rustStable = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" ]; + }; + + # ── Python (for dep-switch.py, dep-scrape.py, etc.) ──────────── + python = pkgs.python312; + + # ── Common packages for both flavors ─────────────────────────── + commonPkgs = with pkgs; [ + # Go + goVersion + golangci-lint + gofumpt + + # Build essentials + gnumake + gcc + pkg-config + + # Proto generation + buf + + # Python (dep tooling) + python + + # Cargo tools (dep-switch.py uses cargo-sort) + cargo-sort + + # Docker (for reproducible builds) + docker + docker-buildx + + # Dev utilities + jq + curl + wget + git + ]; + + # ── Shared shell hook ────────────────────────────────────────── + commonShellHook = '' + # Project root for dep-switch.py resolution + export TERP_CORE_ROOT="$(pwd)" + export MONOREPO_ROOT="$(cd .. && pwd)" + + # Go workspace — disable to avoid cross-workspace confusion + export GOWORK=off + + # Make wasmvm findable for CGo + export CGO_ENABLED=1 + ''; + + in { + formatter = pkgs.nixfmt; + + devShells = { + # ── Vanilla: standard terpd build ──────────────────────────── + # Uses upstream wasmvm from GitHub releases. + # No Rust toolchain needed — Go-only dev experience. + # + # nix develop .#vanilla + # make build + # + vanilla = pkgs.mkShell { + name = "terp-vanilla"; + buildInputs = commonPkgs; + + shellHook = commonShellHook + '' + export WASMVM_SOURCE=github + export TERP_FLAVOR=vanilla + + echo "╔══════════════════════════════════════════╗" + echo "║ terp-core dev shell (vanilla) ║" + echo "║ go: $(go version | cut -d' ' -f3) ║" + echo "║ make build — build terpd ║" + echo "║ make install — install to GOPATH/bin ║" + echo "╚══════════════════════════════════════════╝" + ''; + }; + + # ── ZK: zk-circuit flavored terpd build ────────────────────── + # Uses local zk-wasmvm fork (../zk-wasmvm) with Halo2 proving. + # Requires Rust nightly for libwasmvm compilation. + # + # nix develop .#zk + # make build + # + zk = pkgs.mkShell { + name = "terp-zk"; + buildInputs = commonPkgs ++ [ + rustNightly + ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + pkgs.apple-sdk_15 + pkgs.libiconv + ]; + + shellHook = commonShellHook + '' + export WASMVM_SOURCE=local + export TERP_FLAVOR=zk + + # Ensure go.mod points to local zk forks + if ! grep -q '../zk-wasmvm' go.mod 2>/dev/null; then + echo "⚠ go.mod does not replace wasmvm with ../zk-wasmvm" + echo " Run: go mod edit -replace github.com/CosmWasm/wasmvm/v3=../zk-wasmvm" + fi + + echo "╔══════════════════════════════════════════╗" + echo "║ terp-core dev shell (ZK) ║" + echo "║ go: $(go version | cut -d' ' -f3) ║" + echo "║ rustc: $(rustc --version | cut -d' ' -f2) ║" + echo "║ make build — build terpd (zk flavor) ║" + echo "║ WASMVM_SOURCE=local ║" + echo "╚══════════════════════════════════════════╝" + ''; + }; + + # ── Full: everything for monorepo dev + dep tooling ────────── + # Includes Rust (for Cargo workspace ops), Python dep tooling, + # and both flavor capabilities. + # + # nix develop (or nix develop .#default) + # python3 ../_scripts/dep-switch.py --mode zk_local --target all + # + default = pkgs.mkShell { + name = "terp-full"; + buildInputs = commonPkgs ++ [ + rustNightly + ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + pkgs.apple-sdk_15 + pkgs.libiconv + ]; + + shellHook = commonShellHook + '' + export TERP_FLAVOR=full + + # Default to ZK since it's the superset + export WASMVM_SOURCE=local + + echo "╔══════════════════════════════════════════╗" + echo "║ terp-core dev shell (full) ║" + echo "║ go: $(go version | cut -d' ' -f3) ║" + echo "║ rustc: $(rustc --version | cut -d' ' -f2) ║" + echo "║ python: $(python3 --version | cut -d' ' -f2) ║" + echo "║ ║" + echo "║ Flavors: nix develop .#vanilla ║" + echo "║ nix develop .#zk ║" + echo "║ ║" + echo "║ Dep tooling: ║" + echo "║ dep-switch --mode local --target all ║" + echo "║ dep-switch --mode zk_local --target all║" + echo "╚══════════════════════════════════════════╝" + ''; + }; + }; + } + ); +} From 05b64a6aa97e18867d2cf5a021f83b0477926086 Mon Sep 17 00:00:00 2001 From: hard-nett Date: Fri, 17 Apr 2026 19:41:43 -0700 Subject: [PATCH 5/7] fix: support lz4/zstd snapshots, allow snapshot mode without URL (SFTP pre-delivery) --- cmd/terpd/cmd/bootstrap.go | 75 +++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/cmd/terpd/cmd/bootstrap.go b/cmd/terpd/cmd/bootstrap.go index f24d2f0..69adfd6 100644 --- a/cmd/terpd/cmd/bootstrap.go +++ b/cmd/terpd/cmd/bootstrap.go @@ -390,16 +390,22 @@ func configureStateSyncBootstrap(cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) e // ──── Snapshot configuration ──── func configureSnapshotBootstrap(home string, cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) error { - if bsCfg.SnapshotURL == "" { - return fmt.Errorf("snapshot-url is required when sync-mode is 'snapshot'") - } - dataDir := filepath.Join(home, "data") - fmt.Printf("Downloading snapshot from %s\n", bsCfg.SnapshotURL) - if err := downloadAndExtractTarball(bsCfg.SnapshotURL, dataDir); err != nil { - return fmt.Errorf("snapshot restore failed: %w", err) + + if bsCfg.SnapshotURL == "" { + // No URL — check if data dir already has content (SFTP delivery / pre-populated). + if entries, err := os.ReadDir(dataDir); err == nil && len(entries) > 0 { + fmt.Println("Snapshot data already present (SFTP/pre-populated). Skipping download.") + } else { + fmt.Println("No snapshot URL and no pre-existing data. Node will sync from genesis via block-sync.") + } + } else { + fmt.Printf("Downloading snapshot from %s\n", bsCfg.SnapshotURL) + if err := downloadAndExtractTarball(bsCfg.SnapshotURL, dataDir); err != nil { + return fmt.Errorf("snapshot restore failed: %w", err) + } + fmt.Println("Snapshot extracted.") } - fmt.Println("Snapshot extracted.") // Disable state-sync — node will catch up from snapshot height via block-sync cmtCfg.StateSync.Enable = false @@ -530,8 +536,57 @@ func downloadAndExtractTarball(url, destDir string) error { return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) } - // Stream directly into tar — supports .tar.gz - tarCmd := exec.Command("tar", "-xzf", "-", "-C", destDir) + // Detect compression from URL extension and stream-decompress into tar. + // Supports: .tar.lz4, .tar.zst, .tar.gz, .tar + var tarCmd *exec.Cmd + switch { + case strings.HasSuffix(url, ".tar.lz4"): + // lz4 -dc | tar xf - -C dest + lz4Cmd := exec.Command("lz4", "-dc") + lz4Cmd.Stdin = resp.Body + tarCmd = exec.Command("tar", "xf", "-", "-C", destDir) + pipe, err := lz4Cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("lz4 pipe: %w", err) + } + tarCmd.Stdin = pipe + lz4Cmd.Stderr = os.Stderr + tarCmd.Stdout = os.Stdout + tarCmd.Stderr = os.Stderr + if err := lz4Cmd.Start(); err != nil { + return fmt.Errorf("lz4 start: %w", err) + } + if err := tarCmd.Run(); err != nil { + _ = lz4Cmd.Wait() + return fmt.Errorf("tar extract (lz4): %w", err) + } + return lz4Cmd.Wait() + case strings.HasSuffix(url, ".tar.zst"): + zstdCmd := exec.Command("zstd", "-dc") + zstdCmd.Stdin = resp.Body + tarCmd = exec.Command("tar", "xf", "-", "-C", destDir) + pipe, err := zstdCmd.StdoutPipe() + if err != nil { + return fmt.Errorf("zstd pipe: %w", err) + } + tarCmd.Stdin = pipe + zstdCmd.Stderr = os.Stderr + tarCmd.Stdout = os.Stdout + tarCmd.Stderr = os.Stderr + if err := zstdCmd.Start(); err != nil { + return fmt.Errorf("zstd start: %w", err) + } + if err := tarCmd.Run(); err != nil { + _ = zstdCmd.Wait() + return fmt.Errorf("tar extract (zstd): %w", err) + } + return zstdCmd.Wait() + case strings.HasSuffix(url, ".tar.gz") || strings.HasSuffix(url, ".tgz"): + tarCmd = exec.Command("tar", "-xzf", "-", "-C", destDir) + default: + // Plain tar + tarCmd = exec.Command("tar", "xf", "-", "-C", destDir) + } tarCmd.Stdin = resp.Body tarCmd.Stdout = os.Stdout tarCmd.Stderr = os.Stderr From 3155bd33af0e046759a3725ad94df7d044bc5f34 Mon Sep 17 00:00:00 2001 From: hard-nett Date: Sun, 19 Apr 2026 08:07:47 -0700 Subject: [PATCH 6/7] feat: add terpd snapshot command for network-agnostic data extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds terpd snapshot -o --home that: - Freezes a running terpd process (SIGSTOP/SIGCONT), never SIGTERM (safe for PID 1 in containers) - Extracts data/ + wasm/ only (never config/ — preserves node identity) - Supports lz4/zstd/gzip compression - Reads pruning config and measures actual data size for context - Includes --split flag for chunking large archives - Fully network-agnostic: no chain-id, denom, or network assumptions Includes ICT test (snapshot_ict_test.go) that validates the full cycle: local chain → extract snapshot → verify resume → restore to new node → verify peer sync → chunk and reassemble. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/terpd/cmd/root.go | 1 + cmd/terpd/cmd/snapshot.go | 322 +++++++++++++++++++++++++++++ cmd/terpd/cmd/snapshot_ict_test.go | 298 ++++++++++++++++++++++++++ 3 files changed, 621 insertions(+) create mode 100644 cmd/terpd/cmd/snapshot.go create mode 100644 cmd/terpd/cmd/snapshot_ict_test.go diff --git a/cmd/terpd/cmd/root.go b/cmd/terpd/cmd/root.go index 1e2d1b2..c332211 100644 --- a/cmd/terpd/cmd/root.go +++ b/cmd/terpd/cmd/root.go @@ -239,6 +239,7 @@ func initRootCmd(rootCmd *cobra.Command, encodingConfig params.EncodingConfig) { tmcli.NewCompletionCmd(rootCmd, true), StatesyncCmd, BootstrapCmd, + SnapshotCmd, DebugCmd(), ConfigCmd(), pruning.Cmd(ac.newApp, app.DefaultNodeHome), diff --git a/cmd/terpd/cmd/snapshot.go b/cmd/terpd/cmd/snapshot.go new file mode 100644 index 0000000..e8b821a --- /dev/null +++ b/cmd/terpd/cmd/snapshot.go @@ -0,0 +1,322 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/terpnetwork/terp-core/v5/app" +) + +var SnapshotCmd = &cobra.Command{ + Use: "snapshot", + Short: "Extract a snapshot archive from a node's data directory", + Long: `Create a compressed snapshot archive (data/ + wasm/) from a terpd home directory. + +The node process is frozen with SIGSTOP during extraction and resumed with +SIGCONT immediately after — the container stays running and no data is lost. +The node will catch up from where it left off after resuming. + +For large databases (>10 GB), the command will additionally offer to split the +archive into smaller chunks for easier distribution. + +Only data/ and wasm/ are extracted — never config/, which contains the node's +identity (node_key.json, priv_validator_key.json). + +Examples: + # Extract snapshot from default home dir + terpd snapshot -o /tmp/terp-snapshot.tar.lz4 + + # Extract from custom home (e.g. Docker container volume) + terpd snapshot --home /terpd/.terpd -o /tmp/snapshot.tar.lz4 + + # Extract with zstd compression + terpd snapshot -o /tmp/snapshot.tar.zst --format zst + + # Extract and split into 2GB chunks + terpd snapshot -o /tmp/snapshot.tar.lz4 --split 2G`, + RunE: runSnapshot, +} + +func init() { + SnapshotCmd.Flags().StringP("output", "o", "", "output file path (required)") + SnapshotCmd.Flags().String("home", app.DefaultNodeHome, "node home directory") + SnapshotCmd.Flags().String("format", "lz4", "compression format: lz4, zst, gz, or none") + SnapshotCmd.Flags().String("split", "", "split output into chunks of this size (e.g. 2G, 500M)") + SnapshotCmd.MarkFlagRequired("output") +} + +func runSnapshot(cmd *cobra.Command, args []string) error { + home, _ := cmd.Flags().GetString("home") + output, _ := cmd.Flags().GetString("output") + format, _ := cmd.Flags().GetString("format") + splitSize, _ := cmd.Flags().GetString("split") + + // Resolve home directory + if strings.HasPrefix(home, "~/") { + if h, err := os.UserHomeDir(); err == nil { + home = filepath.Join(h, home[2:]) + } + } + + // Verify data directory exists + dataDir := filepath.Join(home, "data") + if _, err := os.Stat(dataDir); os.IsNotExist(err) { + return fmt.Errorf("data directory not found: %s", dataDir) + } + + // Determine extraction dirs (data + wasm if present) + extractDirs := []string{"data"} + wasmDir := filepath.Join(home, "wasm") + if _, err := os.Stat(wasmDir); err == nil { + extractDirs = append(extractDirs, "wasm") + } + + // Read pruning config for display + pruning, keepRecent := readPruningConfig(home) + dataSize := dirSize(dataDir) + + fmt.Printf("Home: %s\n", home) + fmt.Printf("Pruning: %s\n", pruning) + if pruning == "custom" { + fmt.Printf("Keep: %s recent states\n", keepRecent) + } + fmt.Printf("Data size: %.2f GB\n", float64(dataSize)/(1024*1024*1024)) + fmt.Printf("Dirs: %s\n", strings.Join(extractDirs, ", ")) + fmt.Printf("Output: %s\n", output) + fmt.Printf("Format: %s\n", format) + + // Find running terpd process + pid := findTerpdProcess(home) + + if pid > 0 { + fmt.Printf("Found terpd PID: %d\n", pid) + + // Always freeze — never SIGTERM. terpd may be PID 1 in a container, + // and SIGTERM on PID 1 kills the container, destroying sync progress. + fmt.Println("Freezing terpd process (SIGSTOP)...") + if err := syscall.Kill(pid, syscall.SIGSTOP); err != nil { + return fmt.Errorf("failed to freeze process %d: %w", pid, err) + } + defer func() { + fmt.Println("Resuming terpd process (SIGCONT)...") + if err := syscall.Kill(pid, syscall.SIGCONT); err != nil { + fmt.Printf("Warning: failed to resume process %d: %v\n", pid, err) + } else { + fmt.Printf("terpd resumed — node will catch up from peers.\n") + } + }() + } else { + fmt.Println("No running terpd process found — extracting from stopped node.") + } + + // Extract snapshot + fmt.Println("Extracting snapshot...") + start := time.Now() + + if err := createArchive(home, extractDirs, output, format); err != nil { + return fmt.Errorf("snapshot extraction failed: %w", err) + } + + elapsed := time.Since(start) + info, _ := os.Stat(output) + archiveSize := int64(0) + if info != nil { + archiveSize = info.Size() + } + fmt.Printf("Snapshot complete: %s (%.2f GB, %s)\n", output, float64(archiveSize)/(1024*1024*1024), elapsed.Round(time.Second)) + + // Split into chunks if requested or if large + if splitSize != "" { + fmt.Printf("Splitting into %s chunks...\n", splitSize) + if err := splitArchive(output, splitSize); err != nil { + return fmt.Errorf("split failed: %w", err) + } + } else if archiveSize > 10*1024*1024*1024 { + fmt.Printf("\nNote: archive is %.1f GB. Use --split 2G to create smaller chunks for easier distribution.\n", + float64(archiveSize)/(1024*1024*1024)) + } + + return nil +} + +// readPruningConfig reads the pruning strategy and keep-recent value from app.toml. +func readPruningConfig(home string) (string, string) { + appToml := filepath.Join(home, "config", "app.toml") + data, err := os.ReadFile(appToml) + if err != nil { + return "unknown", "0" + } + pruning := "default" + keepRecent := "0" + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") { + continue + } + if parts := strings.SplitN(line, "=", 2); len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, `"'`) + switch key { + case "pruning": + pruning = val + case "pruning-keep-recent": + keepRecent = val + } + } + } + return pruning, keepRecent +} + +// dirSize returns the total size of a directory tree in bytes. +func dirSize(path string) int64 { + var size int64 + filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + size += info.Size() + return nil + }) + return size +} + +// findTerpdProcess finds a running terpd process using the given home directory. +func findTerpdProcess(home string) int { + entries, err := os.ReadDir("/proc") + if err != nil { + return findTerpdPgrep(home) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pid := 0 + if _, err := fmt.Sscanf(entry.Name(), "%d", &pid); err != nil { + continue + } + cmdline, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline")) + if err != nil { + continue + } + parts := strings.Split(string(cmdline), "\x00") + if len(parts) < 2 { + continue + } + cmdStr := strings.Join(parts, " ") + if strings.Contains(cmdStr, "terpd") && strings.Contains(cmdStr, "start") { + if strings.Contains(cmdStr, home) || (home == app.DefaultNodeHome && !strings.Contains(cmdStr, "--home")) { + return pid + } + } + } + return 0 +} + +// findTerpdPgrep uses pgrep as a fallback on non-Linux systems. +func findTerpdPgrep(home string) int { + out, err := exec.Command("pgrep", "-f", fmt.Sprintf("terpd.*start.*%s", home)).Output() + if err != nil { + out, err = exec.Command("pgrep", "-f", "terpd.*start").Output() + if err != nil { + return 0 + } + } + pid := 0 + fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &pid) + return pid +} + +// createArchive creates a compressed tar archive of the given directories. +func createArchive(home string, dirs []string, output string, format string) error { + outFile, err := os.Create(output) + if err != nil { + return fmt.Errorf("cannot create output file: %w", err) + } + defer outFile.Close() + + tarArgs := append([]string{"cf", "-", "-C", home}, dirs...) + tarCmd := exec.Command("tar", tarArgs...) + tarCmd.Stderr = os.Stderr + + switch format { + case "lz4": + return pipeThrough(tarCmd, exec.Command("lz4", "-c"), outFile) + case "zst", "zstd": + return pipeThrough(tarCmd, exec.Command("zstd", "-c", "-T0"), outFile) + case "gz", "gzip": + return pipeThrough(tarCmd, exec.Command("gzip", "-c"), outFile) + case "none", "tar": + tarCmd.Stdout = outFile + return tarCmd.Run() + default: + return fmt.Errorf("unknown format %q — use lz4, zst, gz, or none", format) + } +} + +// pipeThrough connects tar stdout → compressor stdin → output file. +func pipeThrough(tarCmd *exec.Cmd, compCmd *exec.Cmd, outFile *os.File) error { + pipe, err := tarCmd.StdoutPipe() + if err != nil { + return err + } + compCmd.Stdin = pipe + compCmd.Stdout = outFile + compCmd.Stderr = os.Stderr + + if err := tarCmd.Start(); err != nil { + return fmt.Errorf("tar start: %w", err) + } + if err := compCmd.Start(); err != nil { + return fmt.Errorf("compressor start: %w", err) + } + + compErr := compCmd.Wait() + tarErr := tarCmd.Wait() + + if tarErr != nil { + return fmt.Errorf("tar: %w", tarErr) + } + if compErr != nil { + return fmt.Errorf("compressor: %w", compErr) + } + return nil +} + +// splitArchive splits a file into chunks using the split command. +func splitArchive(path string, chunkSize string) error { + // split -b 2G file.tar.lz4 file.tar.lz4.part- + prefix := path + ".part-" + splitCmd := exec.Command("split", "-b", chunkSize, path, prefix) + splitCmd.Stdout = os.Stdout + splitCmd.Stderr = os.Stderr + if err := splitCmd.Run(); err != nil { + return err + } + + // List the parts + parts, _ := filepath.Glob(prefix + "*") + for _, p := range parts { + info, _ := os.Stat(p) + if info != nil { + fmt.Printf(" %s (%.2f GB)\n", p, float64(info.Size())/(1024*1024*1024)) + } + } + fmt.Printf("Split into %d chunks.\n", len(parts)) + return nil +} + +// copyStream copies from reader to writer. +func copyStream(dst io.Writer, src io.Reader) error { + _, err := io.Copy(dst, src) + return err +} diff --git a/cmd/terpd/cmd/snapshot_ict_test.go b/cmd/terpd/cmd/snapshot_ict_test.go new file mode 100644 index 0000000..043e799 --- /dev/null +++ b/cmd/terpd/cmd/snapshot_ict_test.go @@ -0,0 +1,298 @@ +//go:build ict + +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + "github.com/stretchr/testify/require" +) + +// TestSnapshotExtractAndRestore is a fully self-contained integration test. +// It creates a local single-validator chain (no external network dependency), +// validates the snapshot extraction/restore cycle, and verifies a second node +// can bootstrap from the snapshot and peer with the original. +// +// The snapshot command is network-agnostic — it operates on any terpd home +// directory regardless of chain-id, denom, or network configuration. This test +// uses a local throwaway chain to prove that. +// +// Run with: +// +// go test -tags ict -run TestSnapshotExtractAndRestore -timeout 5m -v ./cmd/terpd/cmd/ +func TestSnapshotExtractAndRestore(t *testing.T) { + if testing.Short() { + t.Skip("skipping snapshot ICT in short mode") + } + + // ─── Test chain parameters (local throwaway — no external deps) ───── + const ( + chainID = "snapshot-ict-1" + moniker = "snap-validator" + bondDenom = "uthiol" // binary's registered staking denom + bondAmount = "50000000" + bondDenom + genesisAmt = "100000000" + bondDenom + + rpcPortA = "56657" + p2pPortA = "56656" + rpcPortB = "57657" + p2pPortB = "57656" + rpcAddrA = "http://127.0.0.1:" + rpcPortA + rpcAddrB = "http://127.0.0.1:" + rpcPortB + ) + + // ─── Phase 1: Build binary ────────────────────────────────────────── + t.Log("Phase 1: Building terpd binary...") + projectRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + require.NoError(t, err) + + binPath := filepath.Join(t.TempDir(), "terpd-snapshot-ict") + buildCmd := exec.Command("go", "build", "-o", binPath, "./cmd/terpd") + buildCmd.Dir = projectRoot + out, err := buildCmd.CombinedOutput() + require.NoError(t, err, "build failed:\n%s", string(out)) + t.Logf(" Binary: %s", binPath) + + // ─── Phase 2: Init local chain (node A) ───────────────────────────── + t.Log("Phase 2: Creating local chain (node A)...") + homeA := t.TempDir() + + run(t, binPath, "init", moniker, "--chain-id", chainID, "--home", homeA) + run(t, binPath, "keys", "add", "validator", "--keyring-backend", "test", "--home", homeA) + + valAddr := strings.TrimSpace( + runOutput(t, binPath, "keys", "show", "validator", "-a", "--keyring-backend", "test", "--home", homeA), + ) + t.Logf(" Validator: %s", valAddr) + + run(t, binPath, "genesis", "add-genesis-account", valAddr, genesisAmt, + "--keyring-backend", "test", "--home", homeA) + run(t, binPath, "genesis", "gentx", "validator", bondAmount, + "--chain-id", chainID, "--keyring-backend", "test", "--home", homeA) + run(t, binPath, "genesis", "collect-gentxs", "--home", homeA) + + // Patch ports so we don't collide with anything + patchFile(t, filepath.Join(homeA, "config", "config.toml"), map[string]string{ + "laddr = \"tcp://0.0.0.0:26656\"": fmt.Sprintf("laddr = \"tcp://0.0.0.0:%s\"", p2pPortA), + "laddr = \"tcp://127.0.0.1:26657\"": fmt.Sprintf("laddr = \"tcp://127.0.0.1:%s\"", rpcPortA), + }) + patchTomlValue(t, filepath.Join(homeA, "config", "app.toml"), "pruning", "default") + + // ─── Phase 3: Start node A ────────────────────────────────────────── + t.Log("Phase 3: Starting node A...") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + nodeA := exec.CommandContext(ctx, binPath, "start", "--home", homeA) + nodeA.Stdout = os.Stdout + nodeA.Stderr = os.Stderr + require.NoError(t, nodeA.Start()) + defer func() { cancel(); nodeA.Wait() }() + + // Wait for blocks + waitForHeight(t, rpcAddrA, 5, 60*time.Second) + + rpcA, err := rpchttp.New(rpcAddrA, "/websocket") + require.NoError(t, err) + statusA, err := rpcA.Status(context.Background()) + require.NoError(t, err) + heightBefore := statusA.SyncInfo.LatestBlockHeight + nodeIDA := string(statusA.NodeInfo.DefaultNodeID) + peerA := fmt.Sprintf("%s@127.0.0.1:%s", nodeIDA, p2pPortA) + t.Logf(" Node A: height=%d peer=%s", heightBefore, peerA) + + // ─── Phase 4: Extract snapshot ────────────────────────────────────── + t.Log("Phase 4: terpd snapshot (freeze → extract → resume)...") + snapshotFile := filepath.Join(t.TempDir(), "snapshot.tar.lz4") + + snapOut, err := exec.Command(binPath, "snapshot", + "--home", homeA, + "-o", snapshotFile, + ).CombinedOutput() + require.NoError(t, err, "snapshot failed:\n%s", string(snapOut)) + + info, err := os.Stat(snapshotFile) + require.NoError(t, err) + require.True(t, info.Size() > 1024, "snapshot too small: %d bytes", info.Size()) + t.Logf(" Snapshot: %.2f MB", float64(info.Size())/(1024*1024)) + + // ─── Phase 5: Verify node A resumed ───────────────────────────────── + t.Log("Phase 5: Verifying node A resumed...") + waitForHeight(t, rpcAddrA, heightBefore+2, 30*time.Second) + statusA2, _ := rpcA.Status(context.Background()) + t.Logf(" Node A: height=%d (resumed from %d)", statusA2.SyncInfo.LatestBlockHeight, heightBefore) + + // ─── Phase 6: Restore snapshot to node B ──────────────────────────── + t.Log("Phase 6: Bootstrapping node B from snapshot...") + homeB := t.TempDir() + + // Init fresh node B (generates its own identity) + run(t, binPath, "init", "node-b", "--chain-id", chainID, "--home", homeB) + + // Copy genesis (same chain, different node) + copyFile(t, + filepath.Join(homeA, "config", "genesis.json"), + filepath.Join(homeB, "config", "genesis.json"), + ) + + // Extract snapshot into B (data/ + wasm/ only — never overwrites config/) + extractOut, err := exec.Command("sh", "-c", + fmt.Sprintf("lz4 -dc %s | tar xf - -C %s", snapshotFile, homeB), + ).CombinedOutput() + require.NoError(t, err, "extract failed:\n%s", string(extractOut)) + + // Verify data dir populated, config untouched (B keeps its own identity) + _, err = os.Stat(filepath.Join(homeB, "data", "application.db")) + if err != nil { + // Might be in a subdirectory depending on DB backend + _, err = os.Stat(filepath.Join(homeB, "data")) + require.NoError(t, err, "data/ missing after extraction") + } + + // Node B's node_key should be different from A (snapshot didn't overwrite) + nodeKeyA, _ := os.ReadFile(filepath.Join(homeA, "config", "node_key.json")) + nodeKeyB, _ := os.ReadFile(filepath.Join(homeB, "config", "node_key.json")) + require.NotEqual(t, string(nodeKeyA), string(nodeKeyB), + "node B has same node_key as A — snapshot overwrote config/!") + + // Patch B's ports + peer with A + patchFile(t, filepath.Join(homeB, "config", "config.toml"), map[string]string{ + "laddr = \"tcp://0.0.0.0:26656\"": fmt.Sprintf("laddr = \"tcp://0.0.0.0:%s\"", p2pPortB), + "laddr = \"tcp://127.0.0.1:26657\"": fmt.Sprintf("laddr = \"tcp://127.0.0.1:%s\"", rpcPortB), + "persistent_peers = \"\"": fmt.Sprintf("persistent_peers = \"%s\"", peerA), + }) + + // ─── Phase 7: Start node B, verify it catches up ──────────────────── + t.Log("Phase 7: Starting node B...") + ctxB, cancelB := context.WithCancel(context.Background()) + defer cancelB() + + nodeB := exec.CommandContext(ctxB, binPath, "start", "--home", homeB) + nodeB.Stdout = os.Stdout + nodeB.Stderr = os.Stderr + require.NoError(t, nodeB.Start()) + defer func() { cancelB(); nodeB.Wait() }() + + waitForHeight(t, rpcAddrB, heightBefore+1, 60*time.Second) + + rpcB, err := rpchttp.New(rpcAddrB, "/websocket") + require.NoError(t, err) + statusB, _ := rpcB.Status(context.Background()) + t.Logf(" Node B: height=%d catching_up=%v", statusB.SyncInfo.LatestBlockHeight, statusB.SyncInfo.CatchingUp) + + // ─── Phase 8: Verify chunk splitting works ────────────────────────── + t.Log("Phase 8: Testing snapshot chunking...") + chunkFile := filepath.Join(t.TempDir(), "chunk-test.tar.lz4") + // Copy the snapshot for chunk test + copyFile(t, snapshotFile, chunkFile) + + splitOut, err := exec.Command("split", "-b", "500K", chunkFile, chunkFile+".part-").CombinedOutput() + if err != nil { + t.Logf(" split not available: %s", string(splitOut)) + } else { + chunks, _ := filepath.Glob(chunkFile + ".part-*") + require.True(t, len(chunks) > 0, "no chunks created") + t.Logf(" Split into %d chunks", len(chunks)) + + // Verify we can reassemble + reassembled := filepath.Join(t.TempDir(), "reassembled.tar.lz4") + catCmd := fmt.Sprintf("cat %s.part-* > %s", chunkFile, reassembled) + _, err = exec.Command("sh", "-c", catCmd).CombinedOutput() + require.NoError(t, err) + + origInfo, _ := os.Stat(chunkFile) + reassInfo, _ := os.Stat(reassembled) + require.Equal(t, origInfo.Size(), reassInfo.Size(), "reassembled size mismatch") + t.Log(" Reassembly verified — sizes match") + } + + t.Log("SUCCESS: Snapshot extract → restore → peer sync → chunking verified!") +} + +// ─── Test helpers ──────────────────────────────────────────────────────── + +func run(t *testing.T, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s %s failed:\n%s", name, strings.Join(args, " "), string(out)) +} + +func runOutput(t *testing.T, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s %s failed:\n%s", name, strings.Join(args, " "), string(out)) + return string(out) +} + +func waitForHeight(t *testing.T, rpcAddr string, target int64, timeout time.Duration) { + t.Helper() + deadline := time.After(timeout) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + time.Sleep(3 * time.Second) // initial boot delay + + for { + select { + case <-deadline: + t.Fatalf("node at %s did not reach height %d within %s", rpcAddr, target, timeout) + case <-ticker.C: + client, err := rpchttp.New(rpcAddr, "/websocket") + if err != nil { + continue + } + status, err := client.Status(context.Background()) + if err != nil { + continue + } + if status.SyncInfo.LatestBlockHeight >= target { + return + } + } + } +} + +func patchFile(t *testing.T, path string, replacements map[string]string) { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + content := string(data) + for old, new := range replacements { + content = strings.Replace(content, old, new, 1) + } + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) +} + +func patchTomlValue(t *testing.T, path string, key string, value string) { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + lines := strings.Split(string(data), "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(trimmed, key+" ") || strings.HasPrefix(trimmed, key+"=") { + lines[i] = fmt.Sprintf("%s = \"%s\"", key, value) + break + } + } + require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)) +} + +func copyFile(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + require.NoError(t, err) + require.NoError(t, os.WriteFile(dst, data, 0644)) +} From befb489136998d60190fda2d9adceb87de28c8a4 Mon Sep 17 00:00:00 2001 From: hard-nett Date: Fri, 8 May 2026 12:24:24 -0400 Subject: [PATCH 7/7] tune local terp --- Dockerfile | 8 +- app/app.go | 27 +- cmd/terpd/cmd/bootstrap.go | 223 ++++++++-------- cmd/terpd/cmd/root.go | 5 +- cmd/terpd/cmd/snapshot.go | 322 ----------------------- docker/README.md | 16 +- docker/localterp/bootstrap.sh | 30 +-- docker/localterp/faucet/faucet_server.js | 2 +- docker/localterp/start.sh | 4 +- 9 files changed, 162 insertions(+), 475 deletions(-) delete mode 100644 cmd/terpd/cmd/snapshot.go diff --git a/Dockerfile b/Dockerfile index f5d94e3..df42b78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,9 +96,15 @@ COPY docker/localterp/bootstrap.sh . COPY docker/localterp/initialize.sh . COPY docker/localterp/start.sh . COPY docker/localterp/faucet/faucet_server.js . - RUN chmod +x *.sh +# localterp key mnemonics +ENV VALIDATOR_MNEMONIC="push certain add next grape invite tobacco bubble text romance again lava crater pill genius vital fresh guard great patch knee series era tonight" +ENV ACCOUNT_A_MNEMONIC="grant rice replace explain federal release fix clever romance raise often wild taxi quarter soccer fiber love must tape steak together observe swap guitar" +ENV ACCOUNT_B_MNEMONIC="jelly shadow frog dirt dragon use armed praise universe win jungle close inmate rain oil canvas beauty pioneer chef soccer icon dizzy thunder meadow" +ENV ACCOUNT_C_MNEMONIC="chair love bleak wonder skirt permit say assist aunt credit roast size obtain minute throw sand usual age smart exact enough room shadow charge" +ENV ACCOUNT_FAUCET_MNEMONIC="word twist toast cloth movie predict advance crumble escape whale sail such angry muffin balcony keen move employ cook valve hurt glimpse breeze brick" + # 1317=LCD proxy, 5000=faucet, 26656=P2P, 26657=RPC, 9090=GRPC EXPOSE 1317 5000 26656 26657 9090 diff --git a/app/app.go b/app/app.go index b92cd0f..015a98e 100644 --- a/app/app.go +++ b/app/app.go @@ -141,22 +141,16 @@ var ( // of "EnableAllProposals" (takes precedence over ProposalsEnabled) // https://github.com/terpnetwork/terp-core/blob/02a54d33ff2c064f3539ae12d75d027d9c665f05/x/wasm/internal/types/proposal.go#L28-L34 EnableSpecificProposals = "" - // EmptyWasmOpts defines a type alias for a list of wasm options. EmptyWasmOpts []wasmkeeper.Option - - Upgrades = []upgrades.Upgrade{ // v2.Upgrade,v3.Upgrade,v4.Upgrade,v4_1.Upgrade, + Upgrades = []upgrades.Upgrade{ // v2.Upgrade,v3.Upgrade,v4.Upgrade,v4_1.Upgrade, v5.Upgrade, } ) -// These constants are derived from the above variables. -// These are the ones we will want to use in the code, based on -// any overrides above var ( // DefaultNodeHome default home directories for terpd DefaultNodeHome = os.ExpandEnv("$HOME/") + NodeDir - // Bech32PrefixAccAddr defines the Bech32 prefix of an account's address Bech32PrefixAccAddr = Bech32Prefix // Bech32PrefixAccPub defines the Bech32 prefix of an account's public key @@ -304,17 +298,12 @@ func NewTerpApp( clientKeeper := appKeepers.IBCKeeper.ClientKeeper storeProvider := appKeepers.IBCKeeper.ClientKeeper.GetStoreProvider() - - // Add tendermint & ibcWasm light client routes tmLightClientModule := ibctm.NewLightClientModule(appCodec, storeProvider) ibcWasmLightClientModule := ibcwlc.NewLightClientModule(*appKeepers.IBCWasmClientKeeper, storeProvider) clientKeeper.AddRoute(ibctm.ModuleName, &tmLightClientModule) clientKeeper.AddRoute(ibcwlctypes.ModuleName, ibcWasmLightClientModule) - // Setup keepers app.AppKeepers = appKeepers - app.keys = app.GetKVStoreKey() - enabledSignModes := append(authtx.DefaultSignModes, sigtypes.SignMode_SIGN_MODE_TEXTUAL) txConfigOpts := authtx.ConfigOptions{ EnabledSignModes: enabledSignModes, @@ -387,32 +376,20 @@ func NewTerpApp( crisis.NewAppModule(app.CrisisKeeper, skipGenesisInvariants, app.GetSubspace(crisistypes.ModuleName)), // always be last to make sure that it checks for all invariants and not only part of them ) - // Upgrades from v0.50.x onwards happen in pre block - app.mm.SetOrderPreBlockers(upgradetypes.ModuleName, authtypes.ModuleName) - // During begin block slashing happens after distr.BeginBlocker so that // there is nothing left over in the validator fee pool, so as to keep the // CanWithdrawInvariant invariant. - // NOTE: staking module is required if HistoricalEntries param > 0 + app.mm.SetOrderPreBlockers(upgradetypes.ModuleName, authtypes.ModuleName) app.mm.SetOrderBeginBlockers(orderBeginBlockers()...) - app.mm.SetOrderEndBlockers(orderEndBlockers()...) - app.mm.SetOrderInitGenesis(orderInitBlockers()...) - - app.mm.RegisterInvariants(app.CrisisKeeper) - - // upgrade handlers app.configurator = module.NewConfigurator(appCodec, app.MsgServiceRouter(), app.GRPCQueryRouter()) err = app.mm.RegisterServices(app.configurator) if err != nil { panic(err) } - // initialize stores app.MountKVStores(app.keys) app.MountTransientStores(app.GetTransientStoreKey()) - - // register upgrade app.setupUpgradeHandlers(app.configurator) autocliv1.RegisterQueryServer(app.GRPCQueryRouter(), runtimeservices.NewAutoCLIQueryService(app.mm.Modules)) diff --git a/cmd/terpd/cmd/bootstrap.go b/cmd/terpd/cmd/bootstrap.go index 69adfd6..709ff9f 100644 --- a/cmd/terpd/cmd/bootstrap.go +++ b/cmd/terpd/cmd/bootstrap.go @@ -55,9 +55,9 @@ var networkPresets = map[string]networkPreset{ GenesisURL: "https://raw.githubusercontent.com/terpnetwork/networks/refs/heads/main/mainnet/morocco-1/genesis.json", RPCs: "https://rpc.terp.chaintools.tech:443", }, - "90u-4": { - ChainID: "90u-4", - GenesisURL: "https://raw.githubusercontent.com/terpnetwork/test-net/master/90u-4/genesis.json", + "120u-1": { + ChainID: "120u-1", + GenesisURL: "https://raw.githubusercontent.com/terpnetwork/networks/refs/heads/main/testnet/120u-1/genesis.json", RPCs: "https://testnet-rpc.terp.network:443", }, } @@ -69,8 +69,8 @@ func DefaultBootstrapConfig() BootstrapConfig { GenesisURL: "https://raw.githubusercontent.com/terpnetwork/networks/refs/heads/main/mainnet/morocco-1/genesis.json", GenesisHash: "", SnapshotURL: "", - StateSyncRPCs: "https://rpc.terp.chaintools.tech:443", - TrustOffset: 1000, + StateSyncRPCs: "https://rpc.terp.network:443,https://rpc.terp.network:443", + TrustOffset: 100, MaxRetries: 6, Seeds: "", PersistentPeers: "", @@ -101,21 +101,6 @@ genesis-hash = "{{ .Bootstrap.GenesisHash }}" # Snapshot tarball URL (used when sync-mode = "snapshot") snapshot-url = "{{ .Bootstrap.SnapshotURL }}" -# State-sync RPC endpoints (comma-separated, tried in order on failure) -statesync-rpcs = "{{ .Bootstrap.StateSyncRPCs }}" - -# Blocks behind latest for state-sync trust height -trust-offset = {{ .Bootstrap.TrustOffset }} - -# Max RPC provider rotation retries before giving up -max-retries = {{ .Bootstrap.MaxRetries }} - -# Seed nodes (comma-separated id@host:port) -seeds = "{{ .Bootstrap.Seeds }}" - -# Persistent peers (comma-separated id@host:port) -persistent-peers = "{{ .Bootstrap.PersistentPeers }}" - # Private mode (default true): disables PEX gossip, rejects inbound peers, # only connects to configured persistent peers. Ideal for local state-sync # testing without participating in the network. Use --public to disable. @@ -267,9 +252,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { return err } case "snapshot": - if err := configureSnapshotBootstrap(home, cmtCfg, bsCfg); err != nil { - return err - } + // if err := configureSnapshotBootstrap(home, cmtCfg, bsCfg); err != nil { + // return err + // } default: return fmt.Errorf("unknown sync-mode: %q (use 'statesync' or 'snapshot')", bsCfg.SyncMode) } @@ -348,7 +333,7 @@ func configureStateSyncBootstrap(cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) e rpc := rpcs[attempt%len(rpcs)] fmt.Printf("Trying RPC %d/%d: %s\n", attempt+1, bsCfg.MaxRetries, rpc) - trustHeight, trustHash, peers, err := fetchStateSyncInfo(rpc, bsCfg.TrustOffset) + trustHeight, trustHash, err := fetchStateSyncInfo(rpc, bsCfg.TrustOffset) if err != nil { fmt.Printf(" Failed: %v\n", err) lastErr = err @@ -357,7 +342,6 @@ func configureStateSyncBootstrap(cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) e fmt.Printf(" Trust height: %d\n", trustHeight) fmt.Printf(" Trust hash : %s\n", trustHash) - fmt.Printf(" Peers found : %d\n", len(peers)) cmtCfg.StateSync.Enable = true // CometBFT requires >=2 RPC servers; use two distinct ones when possible @@ -370,16 +354,6 @@ func configureStateSyncBootstrap(cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) e cmtCfg.StateSync.TrustHash = trustHash cmtCfg.StateSync.TrustPeriod = 168 * time.Hour - // Merge discovered peers with any already configured - if len(peers) > 0 { - discovered := strings.Join(peers, ",") - if cmtCfg.P2P.PersistentPeers != "" { - cmtCfg.P2P.PersistentPeers += "," + discovered - } else { - cmtCfg.P2P.PersistentPeers = discovered - } - } - fmt.Println("State-sync configured.") return nil } @@ -387,53 +361,113 @@ func configureStateSyncBootstrap(cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) e return fmt.Errorf("state-sync config failed after %d attempts: %w", bsCfg.MaxRetries, lastErr) } -// ──── Snapshot configuration ──── - -func configureSnapshotBootstrap(home string, cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) error { - dataDir := filepath.Join(home, "data") - - if bsCfg.SnapshotURL == "" { - // No URL — check if data dir already has content (SFTP delivery / pre-populated). - if entries, err := os.ReadDir(dataDir); err == nil && len(entries) > 0 { - fmt.Println("Snapshot data already present (SFTP/pre-populated). Skipping download.") - } else { - fmt.Println("No snapshot URL and no pre-existing data. Node will sync from genesis via block-sync.") - } - } else { - fmt.Printf("Downloading snapshot from %s\n", bsCfg.SnapshotURL) - if err := downloadAndExtractTarball(bsCfg.SnapshotURL, dataDir); err != nil { - return fmt.Errorf("snapshot restore failed: %w", err) - } - fmt.Println("Snapshot extracted.") - } - - // Disable state-sync — node will catch up from snapshot height via block-sync - cmtCfg.StateSync.Enable = false - - // Still discover peers for block-sync connectivity - rpcs := splitTrimmed(bsCfg.StateSyncRPCs, ",") - if len(rpcs) > 0 && rpcs[0] != "" { - _, _, peers, err := fetchStateSyncInfo(rpcs[0], bsCfg.TrustOffset) - if err == nil && len(peers) > 0 { - discovered := strings.Join(peers, ",") - if cmtCfg.P2P.PersistentPeers != "" { - cmtCfg.P2P.PersistentPeers += "," + discovered - } else { - cmtCfg.P2P.PersistentPeers = discovered - } - } - } +// // ──── Snapshot configuration ──── +// func configureSnapshotBootstrap(home string, cmtCfg *cmtcfg.Config, bsCfg BootstrapConfig) error { +// dataDir := filepath.Join(home, "data") +// rpcs := splitTrimmed(bsCfg.StateSyncRPCs, ",") +// cmtCfg.StateSync.Enable = false + +// if bsCfg.SnapshotURL == "" { +// // No URL — check if data dir already has content (SFTP delivery / pre-populated). +// if entries, err := os.ReadDir(dataDir); err == nil && len(entries) > 0 { +// fmt.Println("Snapshot data already present (SFTP/pre-populated). Skipping download.") +// } else { +// fmt.Println("No snapshot URL and no pre-existing data. Node will sync from genesis via block-sync.") +// } +// } else if isLocalPath(bsCfg.SnapshotURL) { +// // Local file path — extract directly without downloading. +// localPath := strings.TrimPrefix(bsCfg.SnapshotURL, "file://") +// fmt.Printf("Restoring snapshot from local file: %s\n", localPath) +// if _, err := os.Stat(localPath); err != nil { +// return fmt.Errorf("snapshot file not found: %s", localPath) +// } +// inputFmt := detectFormat(localPath) +// if err := extractToHome(localPath, inputFmt, home); err != nil { +// return fmt.Errorf("snapshot restore failed: %w", err) +// } +// fmt.Println("Snapshot restored from local file.") +// } else { +// fmt.Printf("Downloading snapshot from %s\n", bsCfg.SnapshotURL) +// if err := downloadAndExtractTarball(bsCfg.SnapshotURL, dataDir); err != nil { +// return fmt.Errorf("snapshot restore failed: %w", err) +// } +// fmt.Println("Snapshot extracted.") +// } + +// // Auto-detect sentry snapshot: if blockstore.db or state.db are missing +// // but application.db exists, run bootstrap-state to reconstruct CometBFT +// // state via light client verification. +// appDB := filepath.Join(dataDir, "application.db") +// blockDB := filepath.Join(dataDir, "blockstore.db") +// stateDB := filepath.Join(dataDir, "state.db") + +// hasApp := fileExists(appDB) +// hasBlock := fileExists(blockDB) +// hasState := fileExists(stateDB) + +// if hasApp && (!hasBlock || !hasState) { +// fmt.Println("Sentry snapshot detected (missing blockstore/state). Running bootstrap-state...") + +// if len(rpcs) == 0 || rpcs[0] == "" { +// return fmt.Errorf("bootstrap-state requires statesync-rpcs to verify light client headers") +// } + +// // Configure statesync RPC servers in config.toml for bootstrap-state +// if len(rpcs) >= 2 { +// cmtCfg.StateSync.RPCServers = []string{rpcs[0], rpcs[1]} +// } else { +// cmtCfg.StateSync.RPCServers = []string{rpcs[0], rpcs[0]} +// } + +// // Fetch trust height and hash for bootstrap-state +// trustHeight, trustHash, err := fetchStateSyncInfo(rpcs[0], bsCfg.TrustOffset) +// if err != nil { +// return fmt.Errorf("failed to fetch trust info for bootstrap-state: %w", err) +// } +// cmtCfg.StateSync.TrustHeight = trustHeight +// cmtCfg.StateSync.TrustHash = trustHash +// cmtCfg.StateSync.TrustPeriod = 24 * time.Hour + +// // Write config so bootstrap-state can read the RPC servers +// configTomlPath := filepath.Join(home, "config", "config.toml") +// cmtcfg.WriteConfigFile(configTomlPath, cmtCfg) + +// // Run bootstrap-state +// binary, err := os.Executable() +// if err != nil { +// return fmt.Errorf("failed to resolve executable: %w", err) +// } +// bsCmd := exec.Command(binary, "comet", "bootstrap-state", "--home", home) +// bsCmd.Stdout = os.Stdout +// bsCmd.Stderr = os.Stderr +// if err := bsCmd.Run(); err != nil { +// return fmt.Errorf("bootstrap-state failed: %w", err) +// } +// fmt.Println("CometBFT state bootstrapped via light client.") +// } + +// return nil +// } + +// isLocalPath returns true if the string is a local file path (not an HTTP URL). +func isLocalPath(s string) bool { + return strings.HasPrefix(s, "/") || strings.HasPrefix(s, "./") || + strings.HasPrefix(s, "file://") || strings.HasPrefix(s, "~/") +} - return nil +// fileExists returns true if path exists and is a file or directory. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil } // ──── RPC helpers ──── // fetchStateSyncInfo queries an RPC for trust height, hash, and peer addresses. -func fetchStateSyncInfo(rpcAddr string, trustOffset int64) (int64, string, []string, error) { +func fetchStateSyncInfo(rpcAddr string, trustOffset int64) (int64, string, error) { client, err := rpchttp.New(rpcAddr, "/websocket") if err != nil { - return 0, "", nil, err + return 0, "", err } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -441,7 +475,7 @@ func fetchStateSyncInfo(rpcAddr string, trustOffset int64) (int64, string, []str status, err := client.Status(ctx) if err != nil { - return 0, "", nil, fmt.Errorf("status: %w", err) + return 0, "", fmt.Errorf("status: %w", err) } latestHeight := status.SyncInfo.LatestBlockHeight @@ -452,30 +486,11 @@ func fetchStateSyncInfo(rpcAddr string, trustOffset int64) (int64, string, []str block, err := client.Block(ctx, &trustHeight) if err != nil { - return 0, "", nil, fmt.Errorf("block at %d: %w", trustHeight, err) + return 0, "", fmt.Errorf("block at %d: %w", trustHeight, err) } trustHash := hex.EncodeToString(block.BlockID.Hash) - // Collect peers - var peers []string - rpcNodeID := string(status.NodeInfo.DefaultNodeID) - rpcHost := extractHost(rpcAddr) // reuse helper from statesync.go - if rpcHost != "" { - peers = append(peers, fmt.Sprintf("%s@%s:26656", rpcNodeID, rpcHost)) - } - - netInfo, err := client.NetInfo(ctx) - if err == nil { - for _, p := range netInfo.Peers { - port := "26656" - if parts := strings.Split(p.NodeInfo.ListenAddr, ":"); len(parts) > 1 { - port = parts[len(parts)-1] - } - peers = append(peers, fmt.Sprintf("%s@%s:%s", p.NodeInfo.DefaultNodeID, p.RemoteIP, port)) - } - } - - return trustHeight, trustHash, peers, nil + return trustHeight, trustHash, nil } // ──── File / download helpers ──── @@ -619,12 +634,12 @@ func loadCometConfig(home string) (*cmtcfg.Config, error) { // persistent peers, rejects all inbound connections, and never gossips. // Ideal for pulling a local state-sync without participating in the network. func applyPrivateMode(cfg *cmtcfg.Config) { - cfg.P2P.PexReactor = false // no peer exchange gossip - cfg.P2P.MaxNumInboundPeers = 0 // reject all inbound connections - cfg.P2P.MaxNumOutboundPeers = 10 // only our configured peers - cfg.P2P.AddrBookStrict = false // allow non-routable addrs (local testing) - cfg.P2P.Seeds = "" // no seeds — only persistent peers - cfg.Mempool.Broadcast = false // don't broadcast txs to peers + cfg.P2P.PexReactor = false // no peer exchange gossip + cfg.P2P.MaxNumInboundPeers = 0 // reject all inbound connections + cfg.P2P.MaxNumOutboundPeers = 10 // only our configured peers + cfg.P2P.AddrBookStrict = false // allow non-routable addrs (local testing) + cfg.P2P.Seeds = "" // no seeds — only persistent peers + cfg.Mempool.Broadcast = false // don't broadcast txs to peers } // ──── Misc helpers ──── @@ -717,7 +732,7 @@ func installCosmovisor(terpdBinary, home string) error { installCmd.Stdout = os.Stdout installCmd.Stderr = os.Stderr installCmd.Env = append(os.Environ(), - fmt.Sprintf("DAEMON_NAME=terpd"), + "DAEMON_NAME=terpd", fmt.Sprintf("DAEMON_HOME=%s", home), ) if err := installCmd.Run(); err != nil { @@ -735,7 +750,7 @@ func installCosmovisor(terpdBinary, home string) error { initCmd.Stdout = os.Stdout initCmd.Stderr = os.Stderr initCmd.Env = append(os.Environ(), - fmt.Sprintf("DAEMON_NAME=terpd"), + "DAEMON_NAME=terpd", fmt.Sprintf("DAEMON_HOME=%s", home), ) if err := initCmd.Run(); err != nil { diff --git a/cmd/terpd/cmd/root.go b/cmd/terpd/cmd/root.go index c332211..ca58e53 100644 --- a/cmd/terpd/cmd/root.go +++ b/cmd/terpd/cmd/root.go @@ -26,6 +26,7 @@ import ( "github.com/cometbft/cometbft/crypto" "github.com/cometbft/cometbft/libs/bytes" tmcli "github.com/cometbft/cometbft/libs/cli" + "github.com/cosmos/cosmos-sdk/client/snapshot" simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" "github.com/cosmos/cosmos-sdk/types/module" @@ -239,10 +240,10 @@ func initRootCmd(rootCmd *cobra.Command, encodingConfig params.EncodingConfig) { tmcli.NewCompletionCmd(rootCmd, true), StatesyncCmd, BootstrapCmd, - SnapshotCmd, + snapshot.Cmd(ac.newApp), + pruning.Cmd(ac.newApp, app.DefaultNodeHome), DebugCmd(), ConfigCmd(), - pruning.Cmd(ac.newApp, app.DefaultNodeHome), ) server.AddTestnetCreatorCommand(rootCmd, ac.newTestnetApp, addModuleInitFlags) diff --git a/cmd/terpd/cmd/snapshot.go b/cmd/terpd/cmd/snapshot.go deleted file mode 100644 index e8b821a..0000000 --- a/cmd/terpd/cmd/snapshot.go +++ /dev/null @@ -1,322 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "time" - - "github.com/spf13/cobra" - - "github.com/terpnetwork/terp-core/v5/app" -) - -var SnapshotCmd = &cobra.Command{ - Use: "snapshot", - Short: "Extract a snapshot archive from a node's data directory", - Long: `Create a compressed snapshot archive (data/ + wasm/) from a terpd home directory. - -The node process is frozen with SIGSTOP during extraction and resumed with -SIGCONT immediately after — the container stays running and no data is lost. -The node will catch up from where it left off after resuming. - -For large databases (>10 GB), the command will additionally offer to split the -archive into smaller chunks for easier distribution. - -Only data/ and wasm/ are extracted — never config/, which contains the node's -identity (node_key.json, priv_validator_key.json). - -Examples: - # Extract snapshot from default home dir - terpd snapshot -o /tmp/terp-snapshot.tar.lz4 - - # Extract from custom home (e.g. Docker container volume) - terpd snapshot --home /terpd/.terpd -o /tmp/snapshot.tar.lz4 - - # Extract with zstd compression - terpd snapshot -o /tmp/snapshot.tar.zst --format zst - - # Extract and split into 2GB chunks - terpd snapshot -o /tmp/snapshot.tar.lz4 --split 2G`, - RunE: runSnapshot, -} - -func init() { - SnapshotCmd.Flags().StringP("output", "o", "", "output file path (required)") - SnapshotCmd.Flags().String("home", app.DefaultNodeHome, "node home directory") - SnapshotCmd.Flags().String("format", "lz4", "compression format: lz4, zst, gz, or none") - SnapshotCmd.Flags().String("split", "", "split output into chunks of this size (e.g. 2G, 500M)") - SnapshotCmd.MarkFlagRequired("output") -} - -func runSnapshot(cmd *cobra.Command, args []string) error { - home, _ := cmd.Flags().GetString("home") - output, _ := cmd.Flags().GetString("output") - format, _ := cmd.Flags().GetString("format") - splitSize, _ := cmd.Flags().GetString("split") - - // Resolve home directory - if strings.HasPrefix(home, "~/") { - if h, err := os.UserHomeDir(); err == nil { - home = filepath.Join(h, home[2:]) - } - } - - // Verify data directory exists - dataDir := filepath.Join(home, "data") - if _, err := os.Stat(dataDir); os.IsNotExist(err) { - return fmt.Errorf("data directory not found: %s", dataDir) - } - - // Determine extraction dirs (data + wasm if present) - extractDirs := []string{"data"} - wasmDir := filepath.Join(home, "wasm") - if _, err := os.Stat(wasmDir); err == nil { - extractDirs = append(extractDirs, "wasm") - } - - // Read pruning config for display - pruning, keepRecent := readPruningConfig(home) - dataSize := dirSize(dataDir) - - fmt.Printf("Home: %s\n", home) - fmt.Printf("Pruning: %s\n", pruning) - if pruning == "custom" { - fmt.Printf("Keep: %s recent states\n", keepRecent) - } - fmt.Printf("Data size: %.2f GB\n", float64(dataSize)/(1024*1024*1024)) - fmt.Printf("Dirs: %s\n", strings.Join(extractDirs, ", ")) - fmt.Printf("Output: %s\n", output) - fmt.Printf("Format: %s\n", format) - - // Find running terpd process - pid := findTerpdProcess(home) - - if pid > 0 { - fmt.Printf("Found terpd PID: %d\n", pid) - - // Always freeze — never SIGTERM. terpd may be PID 1 in a container, - // and SIGTERM on PID 1 kills the container, destroying sync progress. - fmt.Println("Freezing terpd process (SIGSTOP)...") - if err := syscall.Kill(pid, syscall.SIGSTOP); err != nil { - return fmt.Errorf("failed to freeze process %d: %w", pid, err) - } - defer func() { - fmt.Println("Resuming terpd process (SIGCONT)...") - if err := syscall.Kill(pid, syscall.SIGCONT); err != nil { - fmt.Printf("Warning: failed to resume process %d: %v\n", pid, err) - } else { - fmt.Printf("terpd resumed — node will catch up from peers.\n") - } - }() - } else { - fmt.Println("No running terpd process found — extracting from stopped node.") - } - - // Extract snapshot - fmt.Println("Extracting snapshot...") - start := time.Now() - - if err := createArchive(home, extractDirs, output, format); err != nil { - return fmt.Errorf("snapshot extraction failed: %w", err) - } - - elapsed := time.Since(start) - info, _ := os.Stat(output) - archiveSize := int64(0) - if info != nil { - archiveSize = info.Size() - } - fmt.Printf("Snapshot complete: %s (%.2f GB, %s)\n", output, float64(archiveSize)/(1024*1024*1024), elapsed.Round(time.Second)) - - // Split into chunks if requested or if large - if splitSize != "" { - fmt.Printf("Splitting into %s chunks...\n", splitSize) - if err := splitArchive(output, splitSize); err != nil { - return fmt.Errorf("split failed: %w", err) - } - } else if archiveSize > 10*1024*1024*1024 { - fmt.Printf("\nNote: archive is %.1f GB. Use --split 2G to create smaller chunks for easier distribution.\n", - float64(archiveSize)/(1024*1024*1024)) - } - - return nil -} - -// readPruningConfig reads the pruning strategy and keep-recent value from app.toml. -func readPruningConfig(home string) (string, string) { - appToml := filepath.Join(home, "config", "app.toml") - data, err := os.ReadFile(appToml) - if err != nil { - return "unknown", "0" - } - pruning := "default" - keepRecent := "0" - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "#") { - continue - } - if parts := strings.SplitN(line, "=", 2); len(parts) == 2 { - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - val = strings.Trim(val, `"'`) - switch key { - case "pruning": - pruning = val - case "pruning-keep-recent": - keepRecent = val - } - } - } - return pruning, keepRecent -} - -// dirSize returns the total size of a directory tree in bytes. -func dirSize(path string) int64 { - var size int64 - filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return nil - } - size += info.Size() - return nil - }) - return size -} - -// findTerpdProcess finds a running terpd process using the given home directory. -func findTerpdProcess(home string) int { - entries, err := os.ReadDir("/proc") - if err != nil { - return findTerpdPgrep(home) - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - pid := 0 - if _, err := fmt.Sscanf(entry.Name(), "%d", &pid); err != nil { - continue - } - cmdline, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline")) - if err != nil { - continue - } - parts := strings.Split(string(cmdline), "\x00") - if len(parts) < 2 { - continue - } - cmdStr := strings.Join(parts, " ") - if strings.Contains(cmdStr, "terpd") && strings.Contains(cmdStr, "start") { - if strings.Contains(cmdStr, home) || (home == app.DefaultNodeHome && !strings.Contains(cmdStr, "--home")) { - return pid - } - } - } - return 0 -} - -// findTerpdPgrep uses pgrep as a fallback on non-Linux systems. -func findTerpdPgrep(home string) int { - out, err := exec.Command("pgrep", "-f", fmt.Sprintf("terpd.*start.*%s", home)).Output() - if err != nil { - out, err = exec.Command("pgrep", "-f", "terpd.*start").Output() - if err != nil { - return 0 - } - } - pid := 0 - fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &pid) - return pid -} - -// createArchive creates a compressed tar archive of the given directories. -func createArchive(home string, dirs []string, output string, format string) error { - outFile, err := os.Create(output) - if err != nil { - return fmt.Errorf("cannot create output file: %w", err) - } - defer outFile.Close() - - tarArgs := append([]string{"cf", "-", "-C", home}, dirs...) - tarCmd := exec.Command("tar", tarArgs...) - tarCmd.Stderr = os.Stderr - - switch format { - case "lz4": - return pipeThrough(tarCmd, exec.Command("lz4", "-c"), outFile) - case "zst", "zstd": - return pipeThrough(tarCmd, exec.Command("zstd", "-c", "-T0"), outFile) - case "gz", "gzip": - return pipeThrough(tarCmd, exec.Command("gzip", "-c"), outFile) - case "none", "tar": - tarCmd.Stdout = outFile - return tarCmd.Run() - default: - return fmt.Errorf("unknown format %q — use lz4, zst, gz, or none", format) - } -} - -// pipeThrough connects tar stdout → compressor stdin → output file. -func pipeThrough(tarCmd *exec.Cmd, compCmd *exec.Cmd, outFile *os.File) error { - pipe, err := tarCmd.StdoutPipe() - if err != nil { - return err - } - compCmd.Stdin = pipe - compCmd.Stdout = outFile - compCmd.Stderr = os.Stderr - - if err := tarCmd.Start(); err != nil { - return fmt.Errorf("tar start: %w", err) - } - if err := compCmd.Start(); err != nil { - return fmt.Errorf("compressor start: %w", err) - } - - compErr := compCmd.Wait() - tarErr := tarCmd.Wait() - - if tarErr != nil { - return fmt.Errorf("tar: %w", tarErr) - } - if compErr != nil { - return fmt.Errorf("compressor: %w", compErr) - } - return nil -} - -// splitArchive splits a file into chunks using the split command. -func splitArchive(path string, chunkSize string) error { - // split -b 2G file.tar.lz4 file.tar.lz4.part- - prefix := path + ".part-" - splitCmd := exec.Command("split", "-b", chunkSize, path, prefix) - splitCmd.Stdout = os.Stdout - splitCmd.Stderr = os.Stderr - if err := splitCmd.Run(); err != nil { - return err - } - - // List the parts - parts, _ := filepath.Glob(prefix + "*") - for _, p := range parts { - info, _ := os.Stat(p) - if info != nil { - fmt.Printf(" %s (%.2f GB)\n", p, float64(info.Size())/(1024*1024*1024)) - } - } - fmt.Printf("Split into %d chunks.\n", len(parts)) - return nil -} - -// copyStream copies from reader to writer. -func copyStream(dst io.Writer, src io.Reader) error { - _, err := io.Copy(dst, src) - return err -} diff --git a/docker/README.md b/docker/README.md index 379c8af..a12e3cb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,18 +2,28 @@ Here we have a way to spin up a local network in two commands, including a faucet for testing purposes. + +## Build And Run ```sh # builds the local terp binary -docker buildx build --target localterp -t terpnetwork/terp-core:localterp --load . +docker buildx build --target localterp -t ghcr.io/terpnetwork/terp-core:v5.2.0-zk-localterp --load . # One‑liner that pulls the image (if not present) and starts it. -docker run --rm -it -p 26657:26657 -p 1317:1317 -p 8545:8545 -p 5000:5000 -p 9090:9090 terpnetwork/terp-core:localterp +docker run --rm -it -p 26657:26657 -p 1317:1317 -p 8545:8545 -p 5000:5000 -p 9090:9090 ghcr.io/terpnetwork/terp-core:v5.2.0-zk-localterp ``` -> notice that the difference between building the ~400MB docker image (containing the faucet & nodejs dependencies) and building the ~200MB production image is by specification of the `--target` flag.\ +> notice that the difference between building the ~400MB docker image (containing the faucet & nodejs dependencies) and building the ~200MB production image is by specification of the `--target` flag: > > `--target localterp` for local terp\ > `--target runtime` for production images + + +### Overwriting Mnemonics Used +> To bring your own keys to validators and faucets, include the env vars in the docker run command: > +> `-e VALIDATOR_MNEMONIC="validator mnemonic here"`\ +> or\ +> `-e ACCOUNT_FAUCET_MNEMONIC="validator mnemonic here"` + ## Using The Faucet The faucet is exposed by default on `localhost:5000` diff --git a/docker/localterp/bootstrap.sh b/docker/localterp/bootstrap.sh index 048505f..d6a38d7 100644 --- a/docker/localterp/bootstrap.sh +++ b/docker/localterp/bootstrap.sh @@ -39,7 +39,7 @@ if [ ! -e "$file" ]; then sed -E -i 's/timeout_propose = "[0-9]+m?s"/timeout_propose = "500ms"/' ~/.terpd/config/config.toml sed -E -i 's/timeout_prevote = "[0-9]+m?s"/timeout_prevote = "250ms"/' ~/.terpd/config/config.toml sed -E -i 's/timeout_precommit = "[0-9]+m?s"/timeout_precommit = "250ms"/' ~/.terpd/config/config.toml - sed -E -i 's/timeout_commit = "[0-9]+m?s"/timeout_commit = "1s"/' ~/.terpd/config/config.toml + sed -E -i 's/timeout_commit = "[0-9]+m?s"/timeout_commit = "2400ms"/' ~/.terpd/config/config.toml fi if [ ! -e "$CUSTOM_SCRIPT_PATH" ]; then @@ -50,27 +50,27 @@ if [ ! -e "$file" ]; then echo "Done running custom script!" fi - v_mnemonic="push certain add next grape invite tobacco bubble text romance again lava crater pill genius vital fresh guard great patch knee series era tonight" - a_mnemonic="grant rice replace explain federal release fix clever romance raise often wild taxi quarter soccer fiber love must tape steak together observe swap guitar" - b_mnemonic="jelly shadow frog dirt dragon use armed praise universe win jungle close inmate rain oil canvas beauty pioneer chef soccer icon dizzy thunder meadow" - c_mnemonic="chair love bleak wonder skirt permit say assist aunt credit roast size obtain minute throw sand usual age smart exact enough room shadow charge" - d_mnemonic="word twist toast cloth movie predict advance crumble escape whale sail such angry muffin balcony keen move employ cook valve hurt glimpse breeze brick" - - echo "$v_mnemonic" | terpd keys add validator --recover - echo "$a_mnemonic" | terpd keys add a --recover - echo "$b_mnemonic" | terpd keys add b --recover - echo "$c_mnemonic" | terpd keys add c --recover - echo "$d_mnemonic" | terpd keys add d --recover - + # Default mnemonics (used if environment variables are not provided) + echo "=== Setting up keys ===" + v_mnemonic="${VALIDATOR_MNEMONIC:-push certain add next grape invite tobacco bubble text romance again lava crater pill genius vital fresh guard great patch knee series era tonight}" + a_mnemonic="${ACCOUNT_A_MNEMONIC:-grant rice replace explain federal release fix clever romance raise often wild taxi quarter soccer fiber love must tape steak together observe swap guitar}" + b_mnemonic="${ACCOUNT_B_MNEMONIC:-jelly shadow frog dirt dragon use armed praise universe win jungle close inmate rain oil canvas beauty pioneer chef soccer icon dizzy thunder meadow}" + c_mnemonic="${ACCOUNT_C_MNEMONIC:-chair love bleak wonder skirt permit say assist aunt credit roast size obtain minute throw sand usual age smart exact enough room shadow charge}" + faucet_mnemonic="${ACCOUNT_FAUCET_MNEMONIC:-word twist toast cloth movie predict advance crumble escape whale sail such angry muffin balcony keen move employ cook valve hurt glimpse breeze brick}" + + echo "$v_mnemonic" | terpd keys add validator --recover --keyring-backend test || echo "Validator key already exists" + echo "$a_mnemonic" | terpd keys add a --recover --keyring-backend test || echo "Account a already exists" + echo "$b_mnemonic" | terpd keys add b --recover --keyring-backend test || echo "Account b already exists" + echo "$c_mnemonic" | terpd keys add c --recover --keyring-backend test || echo "Account c already exists" + echo "$faucet_mnemonic" | terpd keys add faucet --recover --keyring-backend test || echo "Account faucet already exists" terpd keys list --output json | jq ico=1000000000000000000 - terpd genesis add-genesis-account validator ${ico}uterp,${ico}uthiol terpd genesis add-genesis-account a ${ico}uterp,${ico}uthiol terpd genesis add-genesis-account b ${ico}uterp,${ico}uthiol terpd genesis add-genesis-account c ${ico}uterp,${ico}uthiol - terpd genesis add-genesis-account d ${ico}uterp,${ico}uthiol + terpd genesis add-genesis-account faucet ${ico}uterp,${ico}uthiol terpd genesis gentx validator ${ico::-1}uterp --chain-id "$chain_id" diff --git a/docker/localterp/faucet/faucet_server.js b/docker/localterp/faucet/faucet_server.js index 792f8b7..5749ad7 100644 --- a/docker/localterp/faucet/faucet_server.js +++ b/docker/localterp/faucet/faucet_server.js @@ -2,7 +2,7 @@ const http = require("http"); const querystring = require("querystring"); const exec = require("child_process").exec; -const FAUCET_WALLET_NAME = process.env.FAUCET_WALLET_NAME || "a"; +const FAUCET_WALLET_NAME = process.env.FAUCET_WALLET_NAME || "faucet"; const FAUCET_AMOUNT = process.env.FAUCET_AMOUNT || "1000000000"; const DENOMS = (process.env.DENOMS || "uterp,uthiol").split(","); diff --git a/docker/localterp/start.sh b/docker/localterp/start.sh index fa7740d..d0deb22 100644 --- a/docker/localterp/start.sh +++ b/docker/localterp/start.sh @@ -19,7 +19,7 @@ init_bootstrap() { a_mnemonic="grant rice replace explain federal release fix clever romance raise often wild taxi quarter soccer fiber love must tape steak together observe swap guitar" b_mnemonic="jelly shadow frog dirt dragon use armed praise universe win jungle close inmate rain oil canvas beauty pioneer chef soccer icon dizzy thunder meadow" c_mnemonic="chair love bleak wonder skirt permit say assist aunt credit roast size obtain minute throw sand usual age smart exact enough room shadow charge" - d_mnemonic="word twist toast cloth movie predict advance crumble escape whale sail such angry muffin balcony keen move employ cook valve hurt glimpse breeze brick" + faucet_mnemonic="word twist toast cloth movie predict advance crumble escape whale sail such angry muffin balcony keen move employ cook valve hurt glimpse breeze brick" x_mnemonic="black foot thrive monkey tenant fashion blouse general adult orient grass enact eight tiger color castle rebuild puzzle much gap connect slice print gossip" z_mnemonic="obscure arrest leader echo truth puzzle police evolve robust remain vibrant name firm bulk filter mandate library mention walk can increase absurd aisle token" @@ -27,7 +27,7 @@ init_bootstrap() { echo "$a_mnemonic" | terpd keys add a --recover echo "$b_mnemonic" | terpd keys add b --recover echo "$c_mnemonic" | terpd keys add c --recover - echo "$d_mnemonic" | terpd keys add d --recover + echo "$faucet_mnemonic" | terpd keys add d --recover echo "$z_mnemonic" | terpd keys add z --recover terpd keys list --output json | jq