diff --git a/.gitignore b/.gitignore index 2785a7a..dd050d0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ test-ledger localnet.json .vscode -.idea +.idea \ No newline at end of file diff --git a/src/chains/bitcoin/isBitcoinAvailable.ts b/src/chains/bitcoin/isBitcoinAvailable.ts new file mode 100644 index 0000000..933ebb9 --- /dev/null +++ b/src/chains/bitcoin/isBitcoinAvailable.ts @@ -0,0 +1,11 @@ +import { execSync } from "child_process"; + +export const isBitcoinAvailable = (): boolean => { + try { + execSync("bitcoind --version", { stdio: "ignore" }); + execSync("bitcoin-cli --version", { stdio: "ignore" }); + return true; + } catch { + return false; + } +}; diff --git a/src/chains/bitcoin/observer.ts b/src/chains/bitcoin/observer.ts new file mode 100644 index 0000000..f1f3c0a --- /dev/null +++ b/src/chains/bitcoin/observer.ts @@ -0,0 +1,247 @@ +import ansis from "ansis"; +import { execSync } from "child_process"; +import { ethers } from "ethers"; + +import { addBackgroundProcess } from "../../backgroundProcesses"; +import { NetworkID } from "../../constants"; +import { logger } from "../../logger"; +import { zetachainDeposit } from "../zetachain/deposit"; +import { zetachainDepositAndCall } from "../zetachain/depositAndCall"; +import { resolveBitcoinTssAddress } from "./setup"; + +type StartObserverOptions = { + chainID?: string; + foreignCoins?: any[]; + pollIntervalMs?: number; + provider?: any; + tssAddress?: string; + zetachainContracts?: any; +}; + +const tryDecodeMemoHex = (hex: string): string | undefined => { + try { + if (!hex || typeof hex !== "string") return undefined; + // Basic hex validation + if (!/^[0-9a-fA-F]+$/.test(hex)) return undefined; + const buf = Buffer.from(hex, "hex"); + // Attempt UTF-8 decode; fallback to hex string if non-printable + const text = buf.toString("utf8"); + // If decoded string contains many replacement chars, prefer hex + const replacementCount = (text.match(/\uFFFD/g) || []).length; + if (replacementCount > 0) return hex; + return text; + } catch (_error) { + return undefined; + } +}; + +const getOpReturnPushes = (tx: any): string[] => { + try { + // NOTE: Unlike the zetaclient implementation, we intentionally skip the extra + // unwrap of "outer" outputs. Localnet transactions already surface the OP_RETURN + // memo in the top-level vouts and we ignore the additional wrapping anyway, so + // walking the raw vout array keeps the local flow simple and still correct. + const vouts: any[] = Array.isArray(tx?.vout) ? tx.vout : []; + const pushes: string[] = []; + for (const vout of vouts) { + const spk = vout?.scriptPubKey || {}; + if (spk?.type === "nulldata" && typeof spk?.asm === "string") { + const parts = spk.asm.split(/\s+/).filter(Boolean); + for (let i = 1; i < parts.length; i++) { + pushes.push(parts[i]); + } + } + } + return pushes; + } catch (_error) { + return []; + } +}; + +const extractMemoFromTransaction = (tx: any): string | undefined => { + for (const maybeHex of getOpReturnPushes(tx)) { + const decoded = tryDecodeMemoHex(maybeHex); + if (decoded) return decoded; + } + return undefined; +}; + +// Return the first hex push from OP_RETURN as a hex string (no utf-8 decoding) +const extractMemoHexFromTransaction = (tx: any): string | undefined => { + for (const maybeHex of getOpReturnPushes(tx)) { + if (/^[0-9a-fA-F]+$/.test(maybeHex) && maybeHex.length % 2 === 0) { + return maybeHex.toLowerCase(); + } + } + return undefined; +}; + +export const startBitcoinObserver = ({ + tssAddress, + pollIntervalMs = 1000, + provider, + zetachainContracts, + foreignCoins, +}: StartObserverOptions = {}) => { + const log = logger.child({ chain: "bitcoin" }); + + let watchAddress = tssAddress; + + const seenTxIds = new Set(); + + const intervalId = setInterval(async () => { + try { + // If address is not yet known, try to obtain/create it with RPC wait + if (!watchAddress) { + try { + const addr = resolveBitcoinTssAddress(); + if (addr) { + watchAddress = addr; + log.info(`Bitcoin TSS address: ${watchAddress}`); + console.log(`Bitcoin TSS address: ${watchAddress}`); + } else { + log.info( + ansis.yellow( + "Unable to determine a TSS address; will retry... set BITCOIN_TSS_ADDRESS to override" + ) + ); + return; // try again on next tick + } + } catch (_error) { + return; // try again on next tick + } + } + + const mempoolRaw = execSync("bitcoin-cli -regtest getrawmempool", { + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + + if (!mempoolRaw) return; + const txids: string[] = JSON.parse(mempoolRaw); + if (!Array.isArray(txids)) return; + + for (const txid of txids) { + if (seenTxIds.has(txid)) continue; + seenTxIds.add(txid); + + try { + const txRaw = execSync( + `bitcoin-cli -regtest getrawtransaction ${txid} true`, + { + stdio: ["ignore", "pipe", "ignore"], + } + ) + .toString() + .trim(); + const tx = JSON.parse(txRaw); + const memo = extractMemoFromTransaction(tx); + const memoHex = extractMemoHexFromTransaction(tx); + const vouts: any[] = Array.isArray(tx?.vout) ? tx.vout : []; + for (const vout of vouts) { + const spk = vout?.scriptPubKey || {}; + const addr: string | undefined = + spk.address || + (Array.isArray(spk.addresses) ? spk.addresses[0] : undefined); + if (addr && addr === watchAddress) { + const amount = vout?.value; + const message = `Observed Bitcoin tx to TSS: txid=${txid} to=${addr} amount=${amount}`; + console.log(message); + log.info(message); + if (memo) { + const memoMsg = `Memo: ${memo}`; + console.log(memoMsg); + log.info(memoMsg); + } + + // If memo hex is present, interpret first 20 bytes as receiver on ZetaChain + if ( + memoHex && + /^[0-9a-fA-F]+$/.test(memoHex) && + memoHex.length % 2 === 0 + ) { + const bytesLen = memoHex.length / 2; + if (bytesLen >= 20) { + try { + const recvHex = `0x${memoHex.slice(0, 40)}`; + const receiver = ethers.getAddress(recvHex); + const payloadHex = memoHex.slice(40); + const payload = + payloadHex.length > 0 ? `0x${payloadHex}` : "0x"; + if (!provider || !zetachainContracts || !foreignCoins) { + log.info( + "Zeta context not ready (provider/contracts/foreignCoins missing); skipping", + { chain: "bitcoin" } + ); + break; + } + + const sender = ethers.ZeroAddress; + // Convert BTC value (in whole BTC) to 18 decimals for dev testing + const amountWei = ethers.parseUnits( + String(amount ?? 0), + 18 + ); + const asset = ethers.ZeroAddress; // treat as gas token on source chain + + if (bytesLen === 20) { + log.info( + `Triggering ZetaChain deposit to ${receiver} (no payload)`, + { chain: "bitcoin" } + ); + await zetachainDeposit({ + args: [sender, receiver, amountWei, asset], + chainID: NetworkID.Bitcoin, + foreignCoins, + zetachainContracts, + }); + } else { + log.info( + `Triggering ZetaChain depositAndCall to ${receiver} with payload length ${ + payloadHex.length / 2 + } bytes`, + { chain: "bitcoin" } + ); + await zetachainDepositAndCall({ + args: [sender, receiver, amountWei, asset, payload], + chainID: NetworkID.Bitcoin, + foreignCoins, + provider, + zetachainContracts, + }); + } + } catch (btcMemoErr) { + log.error( + `Failed to process memo for tx ${txid}: ${btcMemoErr}` + ); + } + } + } + break; // one match is enough + } + } + } catch (innerErr) { + // Ignore individual tx parsing errors + if (typeof log.debug === "function") { + log.debug("Failed to process bitcoin tx", { + chain: "bitcoin", + error: + innerErr instanceof Error ? innerErr.message : String(innerErr), + }); + } + } + } + } catch (err) { + // Swallow polling errors to keep observer running in dev + if (typeof log.debug === "function") { + log.debug("Bitcoin observer polling error", { + chain: "bitcoin", + error: err instanceof Error ? err.message : String(err), + }); + } + } + }, pollIntervalMs); + + addBackgroundProcess(intervalId); +}; diff --git a/src/chains/bitcoin/setup.ts b/src/chains/bitcoin/setup.ts new file mode 100644 index 0000000..fadf976 --- /dev/null +++ b/src/chains/bitcoin/setup.ts @@ -0,0 +1,273 @@ +import ansis from "ansis"; +import { execSync, spawn, spawnSync } from "child_process"; +import { ethers } from "ethers"; +import waitOn from "wait-on"; + +import { NetworkID } from "../../constants"; +import { logger } from "../../logger"; +import { registerContracts } from "../../utils"; +import { isBitcoinAvailable } from "./isBitcoinAvailable"; + +const logDebugError = ( + log: ReturnType, + message: string, + error: unknown +) => { + if (typeof log.debug === "function") { + log.debug(message, { + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +const getRunningBitcoinPidStrings = (): string[] => { + try { + const pidsOutput = execSync("pgrep -x bitcoind", { + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + + if (!pidsOutput) { + return []; + } + + return pidsOutput.split("\n").filter(Boolean); + } catch { + return []; + } +}; + +const stopBitcoinProcesses = (pidStrings: string[]) => { + if (pidStrings.length === 0) return; + + const log = logger.child({ chain: NetworkID.Bitcoin }); + + log.info( + `Found running bitcoind process(es): ${pidStrings.join(", ")}. Stopping...` + ); + + try { + execSync("bitcoin-cli -regtest stop", { stdio: "ignore" }); + } catch (error) { + logDebugError(log, "Failed to stop bitcoin via RPC", error); + } + + for (const pid of pidStrings) { + try { + execSync(`kill -9 ${pid}`); + } catch (error) { + logDebugError(log, `Failed to kill bitcoin process ${pid}`, error); + } + } +}; + +const toNumericPids = (pidStrings: string[]): number[] => + pidStrings.map((s) => parseInt(s, 10)).filter((n) => Number.isFinite(n)); + +const waitForBitcoinPort = async (log: ReturnType) => { + try { + await waitOn({ resources: ["tcp:127.0.0.1:18444"], timeout: 30_000 }); + } catch { + log.info( + ansis.yellow("Bitcoin regtest port not confirmed; proceeding anyway") + ); + } +}; + +const runBitcoinCliCommand = ( + args: string[], + description: string, + { expectOutput = false }: { expectOutput?: boolean } = {} +): string | undefined => { + const log = logger.child({ chain: "bitcoin" }); + + const result = spawnSync("bitcoin-cli", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + logDebugError(log, description, result.error); + return undefined; + } + + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + const error = new Error( + stderr || `bitcoin-cli exited with status ${result.status}` + ); + logDebugError(log, description, error); + return undefined; + } + + const output = result.stdout?.trim() || ""; + + if (expectOutput) { + return output ? output : undefined; + } + + return undefined; +}; + +export const resolveBitcoinTssAddress = (): string | undefined => { + const getFromDefaultWallet = () => + runBitcoinCliCommand( + ["-regtest", "-rpcwait", "getnewaddress", "tss"], + "Failed to fetch TSS address from default Bitcoin wallet", + { expectOutput: true } + ); + + const getFromTssWallet = (failureMessage: string) => + runBitcoinCliCommand( + ["-regtest", "-rpcwait", "-rpcwallet=tss", "getnewaddress", "tss"], + failureMessage, + { expectOutput: true } + ); + + const strategies: (() => string | undefined)[] = [ + getFromDefaultWallet, + () => + getFromTssWallet("Failed to fetch TSS address from named Bitcoin wallet"), + () => { + runBitcoinCliCommand( + ["-regtest", "-rpcwait", "loadwallet", "tss"], + "Failed to load Bitcoin TSS wallet" + ); + + return getFromTssWallet( + "Failed to fetch TSS address after loading wallet" + ); + }, + () => { + runBitcoinCliCommand( + ["-regtest", "-rpcwait", "createwallet", "tss"], + "Failed to create Bitcoin TSS wallet" + ); + + return getFromTssWallet( + "Failed to fetch TSS address after creating wallet" + ); + }, + ]; + + for (const strategy of strategies) { + const address = strategy(); + if (address) { + return address; + } + } + + return undefined; +}; + +export const startBitcoinNode = async (): Promise => { + const log = logger.child({ chain: "bitcoin" }); + + const existingPidStrings = getRunningBitcoinPidStrings(); + + if (existingPidStrings.length > 0) { + stopBitcoinProcesses(existingPidStrings); + } + + const args = ["-regtest", "-daemon", "-fallbackfee=0.0002"]; + const child = spawn("bitcoind", args, { detached: true, stdio: "ignore" }); + + try { + child.unref(); + } catch (error) { + logDebugError(log, "Failed to unref bitcoind child process", error); + } + + await waitForBitcoinPort(log); + + const runningPidStrings = getRunningBitcoinPidStrings(); + return toNumericPids(runningPidStrings); +}; + +export const bitcoinSetup = async ({ zetachainContracts, skip }: any) => { + const log = logger.child({ chain: NetworkID.Bitcoin }); + if (skip || !isBitcoinAvailable()) { + return; + } + + log.info("Setting up Bitcoin..."); + + try { + // Resolve or create a TSS receive address on regtest + let tssAddress: string | undefined; + + try { + tssAddress = resolveBitcoinTssAddress(); + } catch (error) { + logDebugError( + log, + "Unexpected error while resolving Bitcoin TSS address", + error + ); + } + + if (!tssAddress) { + log.info( + ansis.yellow( + "Unable to determine TSS address; registering placeholder. You can re-register later." + ) + ); + tssAddress = "tss"; // minimal placeholder string + } + + try { + execSync( + `bitcoin-cli -regtest -rpcwait generatetoaddress 101 ${tssAddress}`, + { + stdio: ["ignore", "pipe", "pipe"], + } + ); + } catch (fundErr: any) { + log.error( + `Failed to mine blocks for Bitcoin TSS wallet: ${ + fundErr?.message || String(fundErr) + }` + ); + throw fundErr; + } + + // Activate Bitcoin chain in CoreRegistry + const changeChainStatus = + await zetachainContracts.coreRegistry.changeChainStatus( + BigInt(NetworkID.Bitcoin), + ethers.ZeroAddress, + "0x", + true, + { + gasLimit: 1_000_000, + } + ); + + await changeChainStatus.wait(); + + await registerContracts( + zetachainContracts.coreRegistry, + NetworkID.Bitcoin, + { + gateway: ethers.hexlify(ethers.toUtf8Bytes(tssAddress)), + } + ); + + return { + addresses: [ + { + address: tssAddress, + chain: "bitcoin", + type: "gateway", + }, + ], + env: { + tssAddress, + }, + }; + } catch (error: any) { + log.error(`Error setting up Bitcoin: ${error.message || String(error)}`); + throw error; + } +}; diff --git a/src/chains/bitcoin/withdraw.ts b/src/chains/bitcoin/withdraw.ts new file mode 100644 index 0000000..76a1d01 --- /dev/null +++ b/src/chains/bitcoin/withdraw.ts @@ -0,0 +1,89 @@ +import { execFileSync } from "child_process"; +import { ethers } from "ethers"; + +import { NetworkID } from "../../constants"; +import { logger } from "../../logger"; + +type BitcoinWithdrawArgs = { + amount: bigint; + foreignCoin?: { decimals?: number }; + receiver: string; +}; + +const DEFAULT_FEE_RATE = "0.00001"; + +const runBitcoinCli = (args: string[]) => + execFileSync("bitcoin-cli", args, { + stdio: ["ignore", "pipe", "pipe"], + }) + .toString() + .trim(); + +export const bitcoinWithdraw = ({ + receiver, + amount, + foreignCoin, +}: BitcoinWithdrawArgs): string => { + const receiverBytes = ethers.getBytes(receiver); + const receiverAddress = Buffer.from(receiverBytes) + .toString("utf8") + .replace(/\0+$/g, "") + .trim(); + + if (!receiverAddress) { + throw new Error("Invalid Bitcoin receiver address"); + } + + const decimals = foreignCoin?.decimals; + const btcAmount = ethers.formatUnits(amount, decimals); + + try { + runBitcoinCli(["-regtest", "-rpcwallet=tss", "settxfee", DEFAULT_FEE_RATE]); + } catch (feeErr: any) { + const stderr = feeErr?.stderr?.toString?.() ?? ""; + if (stderr && !stderr.includes("settxfee")) { + logger.debug(`settxfee failed: ${stderr}`, { + chain: NetworkID.Bitcoin, + }); + } + } + + let txid: string; + try { + txid = runBitcoinCli([ + "-regtest", + "-rpcwallet=tss", + "sendtoaddress", + receiverAddress, + btcAmount, + ]); + } catch (sendErr: any) { + const stderr = sendErr?.stderr?.toString?.() ?? sendErr?.message ?? ""; + if (stderr.includes("Fallbackfee is disabled")) { + runBitcoinCli([ + "-regtest", + "-rpcwallet=tss", + "settxfee", + DEFAULT_FEE_RATE, + ]); + txid = runBitcoinCli([ + "-regtest", + "-rpcwallet=tss", + "sendtoaddress", + receiverAddress, + btcAmount, + ]); + } else { + throw sendErr; + } + } + + logger.info( + `Transferred ${btcAmount} BTC from TSS to ${receiverAddress} (txid: ${txid})`, + { + chain: NetworkID.Bitcoin, + } + ); + + return txid; +}; diff --git a/src/chains/zetachain/withdraw.ts b/src/chains/zetachain/withdraw.ts index bb42c09..58cf80f 100644 --- a/src/chains/zetachain/withdraw.ts +++ b/src/chains/zetachain/withdraw.ts @@ -6,6 +6,7 @@ import { NetworkID } from "../../constants"; import { deployOpts } from "../../deployOpts"; import { logger } from "../../logger"; import { isRegisteringGatewaysActive } from "../../utils/registryUtils"; +import { bitcoinWithdraw } from "../bitcoin/withdraw"; import { connectorWithdraw } from "../evm/connectorWithdraw"; import { evmCustodyWithdraw } from "../evm/custodyWithdraw"; import { evmTSSTransfer } from "../evm/tssTransfer"; @@ -47,10 +48,11 @@ export const zetachainWithdraw = async ({ }); return; } + const foreignCoin = foreignCoins.find( + (coin: any) => coin.zrc20_contract_address === zrc20 + ); - const asset = - foreignCoins.find((coin: any) => coin.zrc20_contract_address === zrc20) - ?.asset || (isZeta ? "ZETA" : null); + const asset = foreignCoin?.asset || (isZeta ? "ZETA" : null); try { (tss as NonceManager).reset(); @@ -101,6 +103,14 @@ export const zetachainWithdraw = async ({ // if the token is gas token if (coinType === 1n) { + if (chainID === NetworkID.Bitcoin) { + return bitcoinWithdraw({ + amount, + foreignCoin, + receiver, + }); + } + return await evmTSSTransfer({ args, foreignCoins, tss }); } diff --git a/src/commands/start.ts b/src/commands/start.ts index e1be922..c5c9837 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -10,8 +10,11 @@ import readline from "readline/promises"; import { getBorderCharacters, table } from "table"; import waitOn from "wait-on"; -import { initLocalnet } from "../"; +import { getZetaRuntimeContext, initLocalnet } from "../"; import { clearBackgroundProcesses } from "../backgroundProcesses"; +import { isBitcoinAvailable } from "../chains/bitcoin/isBitcoinAvailable"; +import { startBitcoinObserver } from "../chains/bitcoin/observer"; +import { startBitcoinNode } from "../chains/bitcoin/setup"; import { isSolanaAvailable } from "../chains/solana/isSolanaAvailable"; import { isSuiAvailable } from "../chains/sui/isSuiAvailable"; import * as ton from "../chains/ton"; @@ -23,7 +26,7 @@ import { initLogger, logger, LoggerLevel, loggerLevels } from "../logger"; const LOCALNET_JSON_FILE = "./localnet.json"; const PROCESS_FILE = path.join(LOCALNET_DIR, "process.json"); const ANVIL_CONFIG = path.join(LOCALNET_DIR, "anvil.json"); -const AVAILABLE_CHAINS = ["ton", "solana", "sui"] as const; +const AVAILABLE_CHAINS = ["ton", "solana", "sui", "bitcoin"] as const; const CHAIN_ID_TO_NAME: Record = Object.fromEntries( Object.entries(NetworkID).map(([name, id]) => [id, name]) ); @@ -94,6 +97,32 @@ const printRegistryTables = (registry: any, log: any) => { } }; +const getGatewayAddressForChain = ( + registry: any, + chainId: string +): string | undefined => { + try { + const chainData = registry?.[chainId]; + if (!chainData) return undefined; + + const contracts: any[] = Array.isArray(chainData.contracts) + ? chainData.contracts + : []; + + const gateway = contracts.find((contract) => { + const type = String(contract?.contractType ?? "").toLowerCase(); + return type === "gateway"; + }); + + const address = gateway?.address; + if (!address) return undefined; + + return String(address).trim() || undefined; + } catch { + return undefined; + } +}; + const killProcessOnPort = async (port: number, forceKill: boolean) => { try { const output = execSync(`lsof -ti tcp:${port}`).toString().trim(); @@ -255,6 +284,46 @@ const startLocalnet = async (options: { log.info("Skipping Solana..."); } + // Bitcoin + if (enabledChains.includes("bitcoin") && isBitcoinAvailable()) { + log.info("Starting Bitcoin..."); + + try { + const bitcoinPids = await startBitcoinNode(); + for (const pid of bitcoinPids) { + processes.push({ command: "bitcoind", pid }); + } + } catch (error) { + log.error( + `Failed to start Bitcoin node: ${ + error instanceof Error ? error.message : String(error) + }` + ); + if (options.exitOnError) { + throw error; + } + } + + try { + execSync("bitcoin-cli -regtest -rpcwait getblockchaininfo", { + stdio: "ignore", + }); + } catch (error) { + log.debug("Failed to query bitcoin blockchain info", { + chain: "bitcoin", + error: error instanceof Error ? error.message : String(error), + }); + } + + // Defer starting the Bitcoin observer until Zeta context is ready later + } else if (enabledChains.includes("bitcoin") && !isBitcoinAvailable()) { + throw new Error( + "bitcoind and bitcoin-cli are not available. Please, install them and try again: https://bitcoin.org/en/full-node" + ); + } else { + log.info("Skipping Bitcoin..."); + } + let suiProcess: ChildProcess; if (enabledChains.includes("sui") && isSuiAvailable()) { log.info("Starting Sui..."); @@ -295,6 +364,29 @@ const startLocalnet = async (options: { // Pretty-print registry using tables printRegistryTables(registry, log); + + // Start Bitcoin observer now that Zeta context is initialized + if (options.chains.includes("bitcoin") && isBitcoinAvailable()) { + const ctx = getZetaRuntimeContext(); + const bitcoinTssAddress = getGatewayAddressForChain( + registry, + NetworkID.Bitcoin + ); + if (ctx) { + startBitcoinObserver({ + foreignCoins: ctx.foreignCoins, + provider: ctx.provider, + tssAddress: bitcoinTssAddress, + zetachainContracts: ctx.zetachainContracts, + }); + } else { + log.info( + ansis.yellow( + "Zeta context unavailable; skipping Bitcoin observer start" + ) + ); + } + } } catch (error: unknown) { log.error(`Error initializing localnet: ${error}`); await gracefulShutdown(); diff --git a/src/constants.ts b/src/constants.ts index ea4b3ec..a31db90 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,6 +12,7 @@ export const anvilTestMnemonic = export const NetworkID = { BNB: "98", + Bitcoin: "18332", Ethereum: "11155112", Solana: "902", Sui: "104", diff --git a/src/index.ts b/src/index.ts index 2ab18a4..f638ca9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { ethers, HDNodeWallet, Mnemonic, NonceManager } from "ethers"; +import { bitcoinSetup } from "./chains/bitcoin/setup"; import { evmCall } from "./chains/evm/call"; import { evmDeposit } from "./chains/evm/deposit"; import { evmDepositAndCall } from "./chains/evm/depositAndCall"; @@ -24,6 +25,12 @@ const foreignCoins: any[] = []; return this.toString(); }; +let zetaRuntimeContext: + | { foreignCoins: any[]; provider: any; zetachainContracts: any } + | undefined; + +export const getZetaRuntimeContext = () => zetaRuntimeContext; + export const initLocalnet = async ({ port, exitOnError, @@ -72,31 +79,39 @@ export const initLocalnet = async ({ // Run non-EVM chains in parallel (they don't share wallets) log.debug("Setting up non-EVM chains"); - const [solanaContracts, suiContracts, tonContracts] = await Promise.all([ - solanaSetup({ - deployer, - foreignCoins, - provider, - skip: !chains.includes("solana"), - zetachainContracts, - }), - suiSetup({ - deployer, - foreignCoins, - provider, - skip: !chains.includes("sui"), - zetachainContracts, - }), - ton.setup({ - chainID: NetworkID.TON, - deployer, - foreignCoins, - provider, - skip: !chains.includes("ton"), - tss, - zetachainContracts, - }), - ]); + const [solanaContracts, suiContracts, tonContracts, bitcoinContracts] = + await Promise.all([ + solanaSetup({ + deployer, + foreignCoins, + provider, + skip: !chains.includes("solana"), + zetachainContracts, + }), + suiSetup({ + deployer, + foreignCoins, + provider, + skip: !chains.includes("sui"), + zetachainContracts, + }), + ton.setup({ + chainID: NetworkID.TON, + deployer, + foreignCoins, + provider, + skip: !chains.includes("ton"), + tss, + zetachainContracts, + }), + bitcoinSetup({ + deployer, + foreignCoins, + provider, + skip: !chains.includes("bitcoin"), + zetachainContracts, + }), + ]); log.debug("Non-EVM chains setup complete"); // Run EVM chains sequentially to avoid nonce conflicts @@ -125,6 +140,7 @@ export const initLocalnet = async ({ log.debug("BNB contracts setup complete"); const contracts = { + bitcoinContracts, bnbContracts, deployer, ethereumContracts, @@ -148,6 +164,7 @@ export const initLocalnet = async ({ await createToken(contracts, "SUI.SUI", true, NetworkID.Sui, 9); await createToken(contracts, "USDC.SUI", false, NetworkID.Sui, 9); await createToken(contracts, "TON.TON", true, NetworkID.TON, 9); + await createToken(contracts, "BTC.BTC", true, NetworkID.Bitcoin, 8); log.debug("Token creation complete"); @@ -277,6 +294,13 @@ export const initLocalnet = async ({ log.debug("Event handlers setup complete"); + // Expose context for external observers (e.g., Bitcoin) + zetaRuntimeContext = { + foreignCoins, + provider, + zetachainContracts, + }; + return registry; } catch (error) { logger.error("Error in initLocalnet", { diff --git a/src/logger.ts b/src/logger.ts index e1bdcac..308b205 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -23,6 +23,7 @@ export const chains: Record = { [NetworkID.ZetaChain]: { color: ansis.green, name: "ZetaChain" }, [NetworkID.Solana]: { color: ansis.magenta, name: "Solana" }, [NetworkID.BNB]: { color: ansis.yellow, name: "BNB" }, + [NetworkID.Bitcoin]: { color: ansis.yellowBright, name: "Bitcoin" }, }; // Create a custom format for chain-based logging diff --git a/src/tokens/createToken.ts b/src/tokens/createToken.ts index 08138fc..b913e57 100644 --- a/src/tokens/createToken.ts +++ b/src/tokens/createToken.ts @@ -40,8 +40,15 @@ export const createToken = async ( chainID === NetworkID.Solana && !contracts.solanaContracts; const suiNotSupported = chainID === NetworkID.Sui && !contracts.suiContracts; const tonNotSupported = chainID === NetworkID.TON && !contracts.tonContracts; - - if (solanaNotSupported || suiNotSupported || tonNotSupported) { + const bitcoinNotSupported = + chainID === NetworkID.Bitcoin && !contracts.bitcoinContracts; + + if ( + solanaNotSupported || + suiNotSupported || + tonNotSupported || + bitcoinNotSupported + ) { return; }