Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0949415
docs: add RFC 002 for emergency TSS native-fund drain
kingpinXD Jul 20, 2026
cb3a26a
refactor(crosschain): extract shared migration-amount formulas
kingpinXD Jul 20, 2026
1721e99
feat(draintx): add signed drain payload type with canonical bytes
kingpinXD Jul 20, 2026
c266b69
feat(zetatool): add drain-payload generator subcommand
kingpinXD Jul 20, 2026
83b3dfe
feat(zetaclient): add emergency drain poller behind drain build tag
kingpinXD Jul 20, 2026
6cf2446
feat(drain): add local payload HTTP server and draft->final cron
kingpinXD Jul 20, 2026
f3e172b
test(e2e): add drain_tss end-to-end test (EVM + BTC)
kingpinXD Jul 20, 2026
d885ea3
chore: apply golines formatting to drain code
kingpinXD Jul 20, 2026
43040c7
test(e2e): make drain_tss runnable on localnet
kingpinXD Jul 20, 2026
fe24f71
fix(drain): review fixes — fail-closed receivers, concurrent retry, c…
kingpinXD Jul 20, 2026
d9638e6
test(e2e): use orchestrator hostname for drain URL (subnet-independent)
kingpinXD Jul 20, 2026
17969d4
test(e2e): fund deployer BTC wallet in drain test (skip-regular skips…
kingpinXD Jul 21, 2026
50cc208
refactor(zetatool): drop unused payloadGenerator.network field
kingpinXD Jul 21, 2026
e71a626
chore: gitignore root-level built binaries
kingpinXD Jul 21, 2026
8190cb1
refactor(drain): reuse tested BTC migration overhead + add live-RPC t…
kingpinXD Jul 21, 2026
7142d73
fix(drain): miner-fee-only BTC sweep with dust skipping
kingpinXD Jul 21, 2026
59ecb83
fix(drain): correct BTC chain selection, guard empty final, publish c…
kingpinXD Jul 21, 2026
dbcacfc
feat(drain): height-gated resumable poller, fail-closed signer gate, …
kingpinXD Jul 21, 2026
285e3d8
fix(drain): gate localnet anchors by build tag, right-size BTC fee, d…
kingpinXD Jul 21, 2026
3231ca2
ci(e2e): run the emergency drain test behind a DRAIN_TESTS label
kingpinXD Jul 21, 2026
49e2cd2
chore: fix changelog, formatting, and lint for drain payload cmd
kingpinXD Jul 21, 2026
c22d003
fix(zetaclient): keep drain server up through firing window; treat al…
kingpinXD Jul 24, 2026
8c02edd
fix(zetaclient): address drain review — gas for multisig, resilience,…
kingpinXD Jul 24, 2026
e58d3e7
fix(zetaclient): treat "nonce too low" drain broadcast as failure, no…
kingpinXD Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ jobs:
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
NODE_COMMIT=${{ env.NODE_COMMIT }}
BUILD_TAGS=pebbledb,ledger,drain,drain_localnet

matrix-conditionals:
needs: build-zetanode
Expand All @@ -121,6 +122,7 @@ jobs:
PERFORMANCE_TESTS_1K: ${{ steps.matrix-conditionals.outputs.PERFORMANCE_TESTS_1K }}
STATEFUL_DATA_TESTS: ${{ steps.matrix-conditionals.outputs.STATEFUL_DATA_TESTS }}
TSS_MIGRATION_TESTS: ${{ steps.matrix-conditionals.outputs.TSS_MIGRATION_TESTS }}
DRAIN_TESTS: ${{ steps.matrix-conditionals.outputs.DRAIN_TESTS }}
REPLACE_OBSERVER: ${{ steps.matrix-conditionals.outputs.REPLACE_OBSERVER }}
STAKING_TESTS: ${{ steps.matrix-conditionals.outputs.STAKING_TESTS }}
SOLANA_TESTS: ${{ steps.matrix-conditionals.outputs.SOLANA_TESTS }}
Expand Down Expand Up @@ -160,6 +162,7 @@ jobs:
core.setOutput('PERFORMANCE_TESTS_1K', labels.includes('PERFORMANCE_TESTS_1K'));
core.setOutput('STATEFUL_DATA_TESTS', labels.includes('STATEFUL_DATA_TESTS'));
core.setOutput('TSS_MIGRATION_TESTS', labels.includes('TSS_MIGRATION_TESTS'));
core.setOutput('DRAIN_TESTS', labels.includes('DRAIN_TESTS'));
core.setOutput('REPLACE_OBSERVER', labels.includes('REPLACE_OBSERVER'));
core.setOutput('STAKING_TESTS', labels.includes('STAKING_TESTS'));
core.setOutput('SOLANA_TESTS', labels.includes('SOLANA_TESTS'));
Expand Down Expand Up @@ -204,6 +207,7 @@ jobs:
core.setOutput('ADMIN_UPGRADE_TESTS', true);
core.setOutput('PERFORMANCE_TESTS_1K', true);
core.setOutput('TSS_MIGRATION_TESTS', true);
core.setOutput('DRAIN_TESTS', true);
core.setOutput('STAKING_TESTS', true);
core.setOutput('SOLANA_TESTS', true);
core.setOutput('TON_TESTS', true);
Expand All @@ -222,6 +226,7 @@ jobs:
core.setOutput('PERFORMANCE_TESTS_1K', makeTargets.includes('performance-test-1k'));
core.setOutput('STATEFUL_DATA_TESTS', makeTargets.includes('import-mainnet-test'));
core.setOutput('TSS_MIGRATION_TESTS', makeTargets.includes('tss-migration-test'));
core.setOutput('DRAIN_TESTS', makeTargets.includes('drain-test'));
core.setOutput('REPLACE_OBSERVER', makeTargets.includes('replace-observer'));
core.setOutput('STAKING_TESTS', makeTargets.includes('staking-test'));
core.setOutput('SOLANA_TESTS', makeTargets.includes('solana-test'));
Expand Down Expand Up @@ -290,6 +295,10 @@ jobs:
runs-on: ubuntu-22.04
run: ${{ needs.matrix-conditionals.outputs.REPLACE_OBSERVER == 'true' }}
timeout-minutes: 40
- make-target: "start-drain-test"
runs-on: ubuntu-22.04
run: ${{ needs.matrix-conditionals.outputs.DRAIN_TESTS == 'true' }}
timeout-minutes: 40
- make-target: "start-staking-test"
runs-on: ubuntu-22.04
run: ${{ needs.matrix-conditionals.outputs.STAKING_TESTS == 'true' }}
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
.actrc
.zetacored/
.zetaclientd/
# built binaries at repo root
/zetacored
/zetaclientd
/zetaclientd-supervisor
/zetae2e
/zetatool
coverage
coverage.json
coverage.out
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile-localnet
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ COPY version.sh .
COPY --exclude=*.sh --exclude=*.md --exclude=*.yml . .
ARG NODE_VERSION
ARG NODE_COMMIT
ARG BUILD_TAGS=pebbledb,ledger

RUN --mount=type=cache,target="/root/.cache/go-build" \
NODE_VERSION=${NODE_VERSION} \
NODE_COMMIT=${NODE_COMMIT} \
make install install-zetae2e
make install install-zetae2e BUILD_TAGS=${BUILD_TAGS}

FROM golang:1.23.8-bookworm AS cosmovisor-build
RUN go install cosmossdk.io/tools/cosmovisor/cmd/cosmovisor@v1.7.1
Expand Down
33 changes: 31 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ ldflags = -X github.com/cosmos/cosmos-sdk/version.Name=zetacore \
-buildid= \
-s -w

BUILD_FLAGS := -ldflags '$(ldflags)' -tags pebbledb,ledger
# BUILD_TAGS is overridable so specialized builds (e.g. the localnet drain image) can add
# build tags without changing the production default.
BUILD_TAGS ?= pebbledb,ledger
BUILD_FLAGS := -ldflags '$(ldflags)' -tags $(BUILD_TAGS)

TEST_DIR ?= "./..."
TEST_BUILD_FLAGS := -tags pebbledb,ledger,test
Expand Down Expand Up @@ -121,6 +124,11 @@ install-zetaclient: go.sum
@echo "--> Installing zetaclientd"
@go install -mod=readonly $(BUILD_FLAGS) ./cmd/zetaclientd

# Production emergency-drain build: drain poller present, NO drain_localnet (compiled-in anchors only).
install-zetaclient-drain: go.sum
@echo "--> Installing zetaclientd (drain)"
@go install -mod=readonly -ldflags '$(ldflags)' -tags pebbledb,ledger,drain ./cmd/zetaclientd

install-zetacore: go.sum
@echo "--> Installing zetacored"
@go install -mod=readonly $(BUILD_FLAGS) ./cmd/zetacored
Expand Down Expand Up @@ -280,10 +288,16 @@ zetanode:
else
zetanode:
@echo "Building zetanode"
$(DOCKER) build -t zetanode --build-arg NODE_VERSION=$(NODE_VERSION) --build-arg NODE_COMMIT=$(NODE_COMMIT) --target latest-runtime -f ./Dockerfile-localnet .
$(DOCKER) build -t zetanode --build-arg NODE_VERSION=$(NODE_VERSION) --build-arg NODE_COMMIT=$(NODE_COMMIT) --build-arg BUILD_TAGS=$(LOCALNET_BUILD_TAGS) --target latest-runtime -f ./Dockerfile-localnet .
.PHONY: zetanode
endif

# LOCALNET_BUILD_TAGS includes the drain tag so the emergency drain poller is present in the
# localnet zetaclientd (off unless armed via ZETACLIENT_DRAIN_URL), plus drain_localnet so the
# localnet anchor path + its env overrides are compiled in for the e2e test. Production builds
# are unaffected — this only applies to the localnet image, which never ships to a real network.
LOCALNET_BUILD_TAGS ?= pebbledb,ledger,drain,drain_localnet

orchestrator:
@echo "Building e2e orchestrator"
$(DOCKER) build -t orchestrator -f contrib/localnet/orchestrator/Dockerfile.fastbuild .
Expand All @@ -302,6 +316,21 @@ start-e2e-test: e2e-images
@echo "--> Starting e2e test"
cd contrib/localnet/ && $(DOCKER_COMPOSE) --profile dry up -d

# start-drain-test runs ONLY the emergency drain e2e test in isolation. It arms the two
# localnet zetaclients (built with the drain tag) to poll the orchestrator's payload server
# and verify against the test operator key. The signing key / pubkey below are test-only.
start-drain-test: e2e-images
@echo "--> Starting emergency drain e2e test"
export E2E_ARGS="--drain-test" && \
export ZETACLIENT_DRAIN_URL="http://orchestrator:8899" && \
export ZETACLIENT_DRAIN_NETWORK="localnet" && \
export ZETACLIENT_DRAIN_PUBKEY="0x0284bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0" && \
export ZETACLIENT_DRAIN_SIGNING_KEY="0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" && \
export ZETACLIENT_DRAIN_EVM_RECEIVER="0x971d9a4763D845F4346D39292b849C567184D201" && \
export ZETACLIENT_DRAIN_BTC_RECEIVER="bcrt1q5zs69gay5kn2029f4246etdw47ctrv4nvzy38a" && \
export ZETACLIENT_DRAIN_WINDOW="500" && \
cd contrib/localnet/ && $(DOCKER_COMPOSE) --profile dry up -d

start-e2e-test-4nodes: e2e-images
@echo "--> Starting e2e test with 4 nodes"
cd contrib/localnet/ && $(DOCKER_COMPOSE) --profile stress up -d
Expand Down
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

* [4612](https://github.com/zeta-chain/node/pull/4612) - add an emergency TSS native-fund drain tool to move all native funds (EVM + BTC) to a safe wallet during crosschain shutdown.

### Refactor

* [4527](https://github.com/zeta-chain/node/pull/4527) - refactor zetatools and zetaclient to extract common vote creation logic.
Expand Down
22 changes: 22 additions & 0 deletions cmd/zetaclientd/drain_off.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build !drain

package main

import (
"context"

"github.com/rs/zerolog"

"github.com/zeta-chain/node/zetaclient/maintenance"
"github.com/zeta-chain/node/zetaclient/orchestrator"
)

// startDrainIfArmed is a no-op in production builds. The emergency drain poller is only
// compiled under the `drain` build tag.
func startDrainIfArmed(
_ context.Context,
_ maintenance.ZetacoreClient,
_ *orchestrator.Orchestrator,
_ zerolog.Logger,
) {
}
200 changes: 200 additions & 0 deletions cmd/zetaclientd/drain_on.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
//go:build drain

package main

import (
"context"
"crypto/ecdsa"
"os"
"strconv"
"time"

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
ethcommon "github.com/ethereum/go-ethereum/common"
ethcrypto "github.com/ethereum/go-ethereum/crypto"
"github.com/pkg/errors"
"github.com/rs/zerolog"

pkgdrain "github.com/zeta-chain/node/pkg/drain"
"github.com/zeta-chain/node/zetaclient/chains/bitcoin"
"github.com/zeta-chain/node/zetaclient/chains/evm"
drainpoller "github.com/zeta-chain/node/zetaclient/drain"
"github.com/zeta-chain/node/zetaclient/maintenance"
"github.com/zeta-chain/node/zetaclient/orchestrator"
)

const (
// envDrainURL arms the drain: the poller only starts when this is set.
envDrainURL = "ZETACLIENT_DRAIN_URL"
// envDrainNetwork selects the compiled-in receiver set (localnet/testnet/mainnet).
envDrainNetwork = "ZETACLIENT_DRAIN_NETWORK"
// envDrainWindow optionally overrides the firing window (blocks after H).
envDrainWindow = "ZETACLIENT_DRAIN_WINDOW"

// drainPollInterval is how often the poller polls the endpoint.
drainPollInterval = 5 * time.Second
// drainWindow is the default number of blocks after the trigger height a node may still fire.
drainWindow = 10
)

// startDrainIfArmed starts the emergency drain poller when armed via env. Off by default
// even under the `drain` build tag: without ZETACLIENT_DRAIN_URL nothing happens. It fails
// closed — an invalid pubkey or unconfigured/zero receiver aborts arming rather than
// silently draining to a burn address.
func startDrainIfArmed(
ctx context.Context,
zetacoreClient maintenance.ZetacoreClient,
orch *orchestrator.Orchestrator,
logger zerolog.Logger,
) {
logger = logger.With().Str("module", "drain").Logger()

url := os.Getenv(envDrainURL)
if url == "" {
logger.Info().Msg("drain not armed (ZETACLIENT_DRAIN_URL unset)")
return
}
network := os.Getenv(envDrainNetwork)

// A drain_localnet build honors env-overridable anchors and must never touch a real network.
if pkgdrain.IsLocalnetDrainBuild {
logger.Warn().Msg("================================================================")
logger.Warn().Msg("=== NON-PRODUCTION DRAIN BUILD (drain_localnet) — localnet only ===")
logger.Warn().Msg("================================================================")
if network != pkgdrain.NetworkLocalnet {
logger.Error().
Str("network", network).
Msg("drain not started: drain_localnet build refuses to arm on a non-localnet network")
return
}
}

pubKey, receivers, err := pkgdrain.ResolveAnchors(network)
if err != nil {
logger.Error().Err(err).Msg("drain not started: bad network/anchors")
return
}
if err := receivers.Validate(); err != nil {
logger.Error().Err(err).Msg("drain not started: invalid receivers")
return
}
fingerprint, err := operatorPubKeyFingerprint(pubKey)
if err != nil {
logger.Error().Err(err).Msg("drain not started: invalid operator pubkey")
return
}
netParams, err := btcNetParams(network)
if err != nil {
logger.Error().Err(err).Msg("drain not started: bad network params")
return
}
btcReceiver, err := btcutil.DecodeAddress(receivers.BTC, netParams)
if err != nil {
logger.Error().Err(err).Msg("drain not started: bad BTC receiver")
return
}

logger.Warn().
Str("network", network).
Str("operator_pubkey_fingerprint", fingerprint).
Str("evm_receiver", receivers.EVM).
Str("btc_receiver", receivers.BTC).
Msg("drain armed")

go func() {
// The poller gates at fire time per payload: signers are resolved live via the
// resolvers, so it's safe to start before they bootstrap — it simply won't fire until
// every chain in a payload has a signer ready.
poller := drainpoller.New(drainpoller.Config{
Fetcher: drainpoller.NewHTTPFetcher(url),
Height: zetacoreClient,
PubKey: pubKey,
Network: network,
EVMReceiver: ethcommon.HexToAddress(receivers.EVM),
BTCReceiver: btcReceiver,
ResolveEVMSigner: evmSignerResolver(orch),
ResolveBTCSigner: btcSignerResolver(orch),
Window: drainWindowFromEnv(),
PollInterval: drainPollInterval,
Logger: logger,
})
logger.Warn().Str("url", url).Msg("drain poller starting")
poller.Run(ctx)
}()
}

// evmSignerResolver resolves the live EVM signer for a chain from the orchestrator.
func evmSignerResolver(orch *orchestrator.Orchestrator) func(int64) (drainpoller.EVMSigner, bool) {
return func(chainID int64) (drainpoller.EVMSigner, bool) {
for _, cs := range orch.ObserverSigners() {
if c, ok := cs.(*evm.EVM); ok && c.Chain().ChainId == chainID {
return c.Signer(), true
}
}
return nil, false
}
}

// btcSignerResolver resolves the live Bitcoin signer for a chain from the orchestrator.
func btcSignerResolver(orch *orchestrator.Orchestrator) func(int64) (drainpoller.BTCSigner, bool) {
return func(chainID int64) (drainpoller.BTCSigner, bool) {
for _, cs := range orch.ObserverSigners() {
if c, ok := cs.(*bitcoin.Bitcoin); ok && c.Chain().ChainId == chainID {
return c.Signer(), true
}
}
return nil, false
}
}

// operatorPubKeyFingerprint validates the operator public key (rejecting the all-zero
// placeholder) and returns a short fingerprint for logging.
func operatorPubKeyFingerprint(pub []byte) (string, error) {
allZero := true
for _, b := range pub {
if b != 0 {
allZero = false
break
}
}
if allZero {
return "", errors.New("operator pubkey is the all-zero placeholder")
}

var (
parsed *ecdsa.PublicKey
err error
)
if len(pub) == 33 {
parsed, err = ethcrypto.DecompressPubkey(pub)
} else {
parsed, err = ethcrypto.UnmarshalPubkey(pub)
}
if err != nil {
return "", errors.Wrap(err, "unable to parse operator pubkey")
}
return ethcrypto.PubkeyToAddress(*parsed).Hex(), nil
}

func drainWindowFromEnv() int64 {
if v := os.Getenv(envDrainWindow); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
return n
}
}
return drainWindow
}

func btcNetParams(network string) (*chaincfg.Params, error) {
Comment thread
kingpinXD marked this conversation as resolved.
switch network {
case pkgdrain.NetworkMainnet:
return &chaincfg.MainNetParams, nil
case pkgdrain.NetworkTestnet:
return &chaincfg.TestNet3Params, nil
case pkgdrain.NetworkLocalnet:
return &chaincfg.RegressionNetParams, nil
default:
return nil, errors.Errorf("unsupported drain network %q", network)
}
}
Loading
Loading