Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ test-ledger
localnet.json

.vscode
.idea
.idea
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;
}
};
247 changes: 247 additions & 0 deletions src/chains/bitcoin/observer.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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

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