Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
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
11 changes: 11 additions & 0 deletions src/chains/bitcoin/isBitcoinAvailable.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
312 changes: 312 additions & 0 deletions src/chains/bitcoin/observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
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";

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 extractMemoFromTransaction = (tx: any): string | undefined => {
try {
const vouts: any[] = Array.isArray(tx?.vout) ? tx.vout : [];
for (const vout of vouts) {
const spk = vout?.scriptPubKey || {};
if (spk?.type === "nulldata" && typeof spk?.asm === "string") {
// Expected format: "OP_RETURN <hex>" (possibly multiple pushes)
const parts = spk.asm.split(/\s+/).filter(Boolean);
// Find the first hex-looking push after OP_RETURN
for (let i = 1; i < parts.length; i++) {
const maybeHex = parts[i];
const decoded = tryDecodeMemoHex(maybeHex);
if (decoded) return decoded;
}
}
}
} catch (_error) {
return undefined;
}
return undefined;
};

// Return the first hex push from OP_RETURN as a hex string (no utf-8 decoding)
const extractMemoHexFromTransaction = (tx: any): string | undefined => {
try {
const vouts: any[] = Array.isArray(tx?.vout) ? tx.vout : [];
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++) {
const maybeHex = parts[i];
if (/^[0-9a-fA-F]+$/.test(maybeHex) && maybeHex.length % 2 === 0) {
return maybeHex.toLowerCase();
}
}
}
}
} catch (_error) {
return undefined;
}
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<string>();

const intervalId = setInterval(async () => {
try {
// If address is not yet known, try to obtain/create it with RPC wait
if (!watchAddress) {
try {
let addr: string | undefined;
// Try default wallet (if any)
try {
addr = execSync("bitcoin-cli -regtest -rpcwait getnewaddress tss", {
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim();
} catch (_error) {
// ignore: wallet might not be available yet
}
// Try named wallet directly
if (!addr) {
try {
addr = execSync(
"bitcoin-cli -regtest -rpcwait -rpcwallet=tss getnewaddress tss",
{ stdio: ["ignore", "pipe", "ignore"] }
)
.toString()
.trim();
} catch (_error) {
// ignore: wallet might not be available yet
}
}
// Try loading wallet then request address
if (!addr) {
try {
execSync("bitcoin-cli -regtest -rpcwait loadwallet tss", {
stdio: ["ignore", "pipe", "ignore"],
});
} catch (_error) {
// ignore: wallet load may fail if it already exists
}
try {
addr = execSync(
"bitcoin-cli -regtest -rpcwait -rpcwallet=tss getnewaddress tss",
{ stdio: ["ignore", "pipe", "ignore"] }
)
.toString()
.trim();
} catch (_error) {
// ignore: wallet might not be available yet
}
}
// Try creating wallet then request address
if (!addr) {
try {
execSync("bitcoin-cli -regtest -rpcwait createwallet tss", {
stdio: ["ignore", "pipe", "ignore"],
});
} catch (_error) {
// ignore: wallet may already exist
}
try {
addr = execSync(
"bitcoin-cli -regtest -rpcwait -rpcwallet=tss getnewaddress tss",
{ stdio: ["ignore", "pipe", "ignore"] }
)
.toString()
.trim();
} catch (_error) {
// ignore: wallet might not be available yet
}
}
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;
}

Comment on lines +126 to +179
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not mark txids as processed before we can act on them

seenTxIds.add(txid) runs before we check readiness or complete the deposit. If the Zeta context is still warming up (Line 238) or any later step throws, we never retry that mempool entry, effectively dropping the withdrawal. Move the seenTxIds.add call to after a successful deposit/depositAndCall (or reset it on failure) so retries remain possible.

-      for (const txid of txids) {
-        if (seenTxIds.has(txid)) continue;
-        seenTxIds.add(txid);
+      for (const txid of txids) {
+        if (seenTxIds.has(txid)) continue;-              if (addr && addr === watchAddress) {
+              if (addr && addr === watchAddress) {-                    if (!provider || !zetachainContracts || !foreignCoins) {
+                    if (!provider || !zetachainContracts || !foreignCoins) {
                       log.info(
                         "Zeta context not ready (provider/contracts/foreignCoins missing); skipping",
                         { chain: "bitcoin" }
                       );
-                      break;
+                      break;
                     }
…
-                    if (bytesLen === 20) {
+                    let processed = false;
+                    if (bytesLen === 20) {-                      await zetachainDeposit({
+                      await zetachainDeposit({
                         args: [sender, receiver, amountWei, asset],
…
-                    } else {
+                      processed = true;
+                    } else {-                      await zetachainDepositAndCall({
+                      await zetachainDepositAndCall({
                         args: [sender, receiver, amountWei, asset, payload],
…
-                    }
+                      processed = true;
+                    }
+                    if (processed) {
+                      seenTxIds.add(txid);
+                    }
                     }
                   } catch (btcMemoErr) {
                     log.error(

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/chains/bitcoin/observer.ts around lines 191 to 244, the call
seenTxIds.add(txid) is done before we confirm the Zeta context is ready or the
deposit/depositAndCall completes, which causes mempool entries to be dropped if
processing later fails; move the seenTxIds.add(txid) so it only runs after a
successful deposit/depositAndCall (or equivalent success path), and remove any
prior optimistic addition; additionally, wrap the processing in try/catch and on
any failure ensure seenTxIds does not contain txid (either don’t add it at start
or explicitly delete it in the catch/failure branches), and also do not mark
txid as seen when you early-skip due to missing provider/contracts/foreignCoins
so the entry remains retryable.

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) {
Comment on lines +180 to +188
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Convert BTC amounts without triggering scientific notation

String(amount ?? 0) turns tiny BTC values (e.g., 1 sat ≈ 1e-8) into scientific notation, which ethers.parseUnits rejects, causing the observer to crash on legitimate withdrawals. Format the float with a fixed scale before parsing, or derive the value from satoshis explicitly. Example fix:

-                    const amountWei = ethers.parseUnits(
-                      String(amount ?? 0),
-                      18
-                    );
+                    const amountNum =
+                      typeof amount === "number" ? amount : Number(amount ?? 0);
+                    const amountWei = ethers.parseUnits(
+                      amountNum.toFixed(8),
+                      18
+                    );
🤖 Prompt for AI Agents
In src/chains/bitcoin/observer.ts around lines 245 to 253, the code currently
does ethers.parseUnits(String(amount ?? 0), 18) which fails for very small BTC
values because String() can produce scientific notation; change the conversion
to avoid scientific notation by either converting BTC to satoshis and building
the wei-like value from integers (e.g., multiply satoshis by 10^(18-8) using
BigInt or string arithmetic) or format the BTC float with a fixed 8-decimal
scale (e.g., toFixed(8)) before calling parseUnits so ethers.parseUnits never
receives scientific notation.

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);
};
Loading