A Threshold Signature Scheme (TSS) implementation for the Liberdus cross-chain bridge. Uses the GG18 multi-party ECDSA protocol to enable distributed, trustless signing with a 3-of-5 threshold configuration — no single party can sign unilaterally.
Supports bridging between the Liberdus network and EVM chains (Polygon Amoy, BSC Testnet).
┌─────────────────────────────────────────────────────┐
│ Observer Nodes (×5, observer/) │
│ • Monitor EVM chains for BridgedOut/BridgedIn │
│ • Poll Liberdus for coin-to-token transfers │
│ • Persist bridge transactions to local SQLite │
│ • Serve HTTP on localhost:810N │
└──────────────────────▲──────────────────────────────┘
│ HTTP (localhost:810N)
┌──────────────────────┴──────────────────────────────┐
│ TSS Party Nodes (×5) │
│ scripts/tss-party.ts — compiled to dist/ │
│ • Poll local observer every 10s for pending txs │
│ • Sign through native BNB TSS flows │
│ • Submit signed txs to destination chain │
└─────────────────────────────────────────────────────┘
Each party index (1–5) runs a paired observer and TSS party process. Both must be running for the bridge to operate.
| Layer | Language | Purpose |
|---|---|---|
tss/ |
Go | Liberdus fork of bnb-chain/tss with the tss-lib v3 upgrade and signing/discovery fixes |
tss-tools/ |
TypeScript + Go | Native TSS build/init/keygen/verify/sign/regroup helpers |
scripts/tss-party.ts |
TypeScript | Orchestration: transaction queue, signing flow, chain submission |
observer/ |
TypeScript (Node.js) | On-chain monitoring, SQLite transaction DB, local HTTP API |
shared/ |
TypeScript | Config, DB types, RPC helpers, utilities shared across layers |
- Node.js 18+
- PM2 (
npm install -g pm2)
For a full walkthrough of the multi-operator setup process (keypair generation, keygen, verify, start), see PARTY_SETUP.md.
npm install
npm run compileFirst fetch the Liberdus fork of the BNB TSS submodule:
git submodule update --init --recursiveIf npm run tss-build says the Go toolchain is missing, bootstrap the local vendored Go first:
./tss-tools/setup-mise-go.shThe vendored Go bootstrap supports macOS and Linux on supported CPU architectures (macos-arm64, macos-x64, linux-x64, linux-arm64). Windows is not supported by this bootstrap flow.
Then build:
npm run tss-buildThe native binary is built to ./tss/.tooling/bin/tss. Native vaults are stored under keystores/bnbtss/party-<idx>/chain-<chainId>/default/. User-facing party indices start at 1.
export TSS_PASSWORD_CHAIN_<chainId>=<chain-specific-vault-password>
export BNB_TSS_CHANNEL_ID=<shared-channel-id> # required for keygen/regroup
export BNB_TSS_CHANNEL_PASSWORD=<shared-channel-password> # required for keygen/regroupRequired envs by flow:
TSS_PASSWORD_CHAIN_<chainId>: required for native vault access on that chain. Must be set beforetss-init,tss-keygen,tss-verify, andtss-partystartup. If missing, startup validation fails withBNB TSS vault password is required (TSS_PASSWORD_CHAIN_<chainId>).BNB_TSS_CHANNEL_ID: required for manual native keygen/regroup/sign flows unless passed explicitly on the command line.BNB_TSS_CHANNEL_PASSWORD: required for manual native keygen/regroup/sign flows unless passed explicitly on the command line.SHARDUS_CRYPTO_HASH_KEY: optional override for the long-livedtss-partysigner. When unset, the code falls back to a built-in default. If you set it, all parties must use the same value so deterministic signing channel passwords match.
The long-lived tss-party signer derives a deterministic signing channelId per transaction from each transaction txId and txTimestamp, and a deterministic signing channelPassword from channelId + SHARDUS_CRYPTO_HASH_KEY.
Native helper walkthroughs and command examples live in tss-tools/guide.md.
params.json— TSS keygen/regroup parameters. Default:{"parties": 5, "threshold": 3}. Keygen/regroup store these values in the keystore. Signing uses the keystore values, notparams.json.chain-config.json— RPC endpoints, contract addresses, gas config per chain.observer-list.json— observer peer URLs as a JSON array. When configured, the app uses this list to choose observer peers and determine the observer count. Copy the shape fromobserver-list.json.exampleand replace the placeholder IPs with real public observer IPs. If you use DNS names, setTSS_SELF_OBSERVER_URLon each party so it can identify its own observer.- Observer setup flag (
chain-config.json):isRemote: false(default): ifobserver-list.jsonis empty, peers default tohttp://127.0.0.1:8101...isRemote: true:observer-list.jsonis required and startup fails when it is missing/empty.
- Optional local dev shortcut: set
observerSkipOldData: trueinchain-config.json, run withOBSERVER_SKIP_OLD_DATA=true, or pass--skip-old-datato the observer to seed both EVM and Liberdus monitor cursors instead of scanning historical data; useobserverSkipOldLiberdusData: trueorOBSERVER_SKIP_OLD_LIBERDUS_DATA=trueto apply the skip only to Liberdus transactions when a Liberdus monitor cursor has not been initialized yet.
All 5 parties must complete keygen before any signing can occur. Keygen produces per-party keystore files in keystores/. Refer to PARTY_SETUP.md for the full multi-operator keygen process.
npm run start-tss # starts 10 PM2 processes (5 observers + 5 TSS parties)# Compile all TypeScript (scripts, observer, tss-tools → dist/)
npm run compile
# Build the Liberdus fork of BNB TSS
npm run tss-build
# Compile + run a single party (for testing)
npm run tss-party
# Native TSS operator helpers
npm run tss-init -- --party 1 --chain-id 97
npm run tss-keygen -- --party 1 --chain-id 97
npm run tss-keygen-ceremony -- --nonce 1
npm run update-chain -- ./chain-config.json 97 '{"tssSenderAddress":"0x7fD5AF01358a7dad582b2476aA821b75CebaF297"}'
npm run tss-regroup-connectivity-check
npm run tss-verify -- --party 1
npm run tss-regroup -- --party 1 --chain-id 97 --is-old --new-threshold 3 --new-parties 5
npm run tss-sign-ethereum-tx -- --party 1 --chain-id 97 --tx-file ethereum-tx.json.example
npm run inject-liberdus-tx -- --chain-id 97 --tx-file ./liberdus-tx.json.example --dry-runFor manual Liberdus transaction signing/injection with npm run inject-liberdus-tx, see tss-tools/guide.md.
npm run update-chain -- <path-to-chain-config.json> <chainId> '<json-object>' deep-merges the supplied JSON object into matching entries in supportedChains, vaultChain, and secondaryChainConfig, and writes a .bak backup before updating the file. It requires bash and jq.
For remote multi-party keygen, npm run tss-keygen-ceremony -- --nonce <value> reads a local keygen-config.json, derives parties, threshold = floor(n/2), the current partyIndex, the other parties' peer-addrs, and deterministic keygen channel credentials from the shared config plus --nonce. It prompts for the chain-specific vault password, verifies the password can unlock the initialized vault, overwrites params.json with the derived {parties, threshold}, then runs tss-keygen with those env vars scoped to that child process only. Keygen stores those parameters in the native keystore. Use a fresh nonce for every retry, for example --nonce 1, then --nonce 2. The wrapper prints the derived UTC expiry before launching keygen. After the config is in place, tss-verify can omit --chain-id and will use chainId from that same file.
Expected local config file:
{"chainId":97,"partyIps":["198.51.100.11","198.51.100.12","198.51.100.13"]}Channel credential derivation:
channelIdis deterministic fromchainId + ordered partyIps + nonce.- The first 3 digits come from a SHA-256 digest modulo
1000. - The last 8 hex characters are the next
00:00:00 UTC, which keeps all operators on the same UTC day aligned without another shared argument. channelPasswordissha256(channelId + ceremony-material + ':channel-password'), whereceremony-materialis the JSON payload containingchainId, orderedpartyIps, andnonce.
Party index detection:
- The wrapper first matches the machine's local non-internal IPv4 addresses against
partyIps. - If that does not resolve uniquely, it falls back to public IPv4 lookup services and retries the match.
- If the result is still ambiguous or unmatched, the wrapper stops and prints the detection failure.
Avoid starting a ceremony right around 00:00 UTC; if some servers compute the channel on one UTC date and others on the next, they will derive different channel ids.
These scripts live under tss-tools/ and are the operator-facing helpers for the native tss flow.
| Path | Purpose |
|---|---|
tss-tools/build-tss.sh |
Builds the Liberdus forked bnb-chain/tss checkout to ./tss/.tooling/bin/tss plus ./tss/.tooling/bin/tss-derive-pubkey. |
tss-tools/setup-mise-go.sh |
Bootstraps a local Go toolchain under tss/.tooling/mise when system go is unavailable. Supports macos-arm64, macos-x64, linux-x64, and linux-arm64; Windows is not supported. |
tss-tools/init.ts |
Initializes one native TSS party home and vault for a given party index and chain id. |
tss-tools/keygen.ts |
Runs native TSS keygen for one party using the shared channel settings and params.json defaults unless overridden. By default it supplies deterministic local --p2p.peer_addrs for same-host committees. |
tss-tools/verify.ts |
Derives and prints the compressed pubkey, Ethereum pubkey, or Ethereum address from an existing native vault. |
tss-tools/sign-ethereum-tx.ts |
Signs an unsigned Ethereum transaction JSON through native TSS and prints the signed tx payload. |
tss-tools/regroup.ts |
Runs native TSS regroup for a carry-over old member (--is-old) or a fresh new member (--is-new-member). In deterministic local mode it auto-generates regroup topology for contiguous new committees (1..newParties). |
tss-tools/lib/bnbTss.ts |
Shared TypeScript runtime helper used by the tooling scripts for binary resolution, vault paths, committee topology, and signing helpers. |
tss-tools/lib/committeeTopology.ts |
Committee topology helper for deterministic local peer addresses and parsing tss describe topology output. |
tss-tools/lib/channelId.ts |
Deterministic signing channelId and channelPassword derivation helpers. |
scripts/inject-liberdus-tx.ts |
Signs a Liberdus tx payload through native BNB TSS, verifies the signature, and optionally injects it into the Liberdus network. |
tss-tools/tss_workflow_smoke.sh |
Local end-to-end native TSS smoke workflow: init, keygen, sign, regroup to 5, sign, regroup down to 3, and final sign. Run after npm run tss-build. |
tss-tools/test-sign-rounds.sh |
Multi-scenario signing test harness. Runs configurable rounds across varying party startup delays and reports PASS/FAIL. Logs to tss-tools/test-result.log and tss-tools/test-party{1..N}.log. |
tss-tools/derive-pubkey/main.go |
Small Go helper source staged into tss/.tooling and run inside the Liberdus forked bnb-chain/tss module for verify.ts and post-keygen address derivation. |
tss-tools/guide.md |
Step-by-step local operator guide for build, init, keygen, verify, sign, and regroup. |
npm run start-tss # start all 10 processes (5 observers + 5 TSS parties)
npm run stop-tss # stop all
npm run restart-tss # restart all
npm run restart-tss:observers # restart only observer-1..5
npm run restart-tss:tss-parties # restart only tss-party-1..5
npm run logs-tss # stream PM2 logs
npm run status-tss # show process statusObserver processes (observer-N) run with a 1 GB memory limit. TSS party processes (tss-party-N) run with a 2 GB memory limit. All processes use --expose-gc; GC is forced when heap exceeds 256 MB.
Log files are at logs/{process-name}-combined.log.
Observer admin endpoints and the operator-admin CLI for collecting logs and restarting observer/TSS PM2 processes are documented in OBSERVER_ADMIN.md.
Right now there are three TSS execution routes:
- BridgeVault: TSS sends tokens to EVM by calling
bridgeInon the destination EVM contract. This is for direct EVM -> EVM bridging. - Coin-to-Token: TSS sends tokens to EVM by calling
bridgeInon the destination EVM contract. This is for Liberdus -> EVM flow. - Token-to-Coin: TSS sends coins to the corresponding Liberdus account (same address as the sender on EVM) using the Liberdus chain account.
EVM -> Liberdus BridgeIn (Token-to-Coin / Liberdus Chain Account -> Corresponding Liberdus Account):
- Observer detects
BridgedOutevents on the EVM contract and saves them as PENDING in local SQLite - TSS parties poll the paired observer every 10s for unprocessed transactions
- Each party independently queues the pending transaction from its local observer
- Parties sign through the native BNB TSS flow
- Winning party broadcasts the signed tx to Liberdus and sets status to SUBMITTED with
receiptTimestamp - Winning party gossips Liberdus submission (
txId,receiptId,sourceChainId) to peer observers via/bridgein/liberdus/submittedso peers can mark the source tx as SUBMITTED early (txIdis sourcebridgeOuttx id,receiptIdis destinationbridgeIntx id) - Observer detects the delivery receipt from the Liberdus collector API and reconciles the final status (COMPLETED/FAILED/REVERTED); parties do not retry once
receiptTimestampis set
Liberdus -> EVM BridgeIn (Coin-to-Token):
- Observer polls the Liberdus collector API for bridge transfers and saves them as PENDING
- TSS parties pick up the pending transaction, sign, and submit to the target EVM chain
- Winning party gossips EVMChain submission (
txId,destinationChainId,receiptId) to peer observers via/bridgein/evm/submittedso peers can mark the tx as SUBMITTED early (txIdis sourcebridgeOuttx id,receiptIdis destinationbridgeIntx id)
| Path | Description |
|---|---|
scripts/tss-party.ts |
Main party orchestration (~3000 lines) |
observer/index.ts |
Observer entry point: HTTP server + monitoring schedulers |
observer/monitor/ethereum.ts |
EVM BridgedOut/BridgedIn block scanning |
observer/monitor/liberdus.ts |
Liberdus collector API polling |
shared/config.ts |
ChainConfig / ParamsConfig types and loaders |
shared/storage/transactiondb.ts |
SQLite transaction DB: types, statuses, queries |
tss-tools/lib/bnbTss.ts |
Native BNB TSS runtime helper |
chain-config.json |
Multi-chain RPC and contract configuration |
params.json |
TSS keygen/regroup parameters (parties, threshold) |
keystores/ |
Native TSS vault files (created during keygen) |
ecosystem.config.js |
PM2 process configuration for all 10 processes |
Keygen: All 5 parties participate simultaneously. Output: shared public key + individual key shares written to keystores/bnbtss/party-N/chain-CHAINID/default/.
Signing: Any threshold + 1 (≥ 4 of 5) parties suffice. The TSS binary reads the party/threshold values from the keystore, so signing does not use params.json. The Liberdus fork implements a configurable discovery window (--sign_discovery_timeout, default 5s) — once the first peer connects, signing proceeds with all parties that arrive within the window (minimum threshold).
Regroup: Transfers key shares to a new committee without regenerating the shared key. Requires at least threshold + 1 old participants.
# Compile TypeScript
npm run compile
# Native TSS helper unit tests
npm run test:tss-toolsApache-2.0