-
Notifications
You must be signed in to change notification settings - Fork 4
feat: support for Bitcoin (memo only) #247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughAdds Bitcoin regtest support: CLI availability check, node lifecycle and setup, mempool observer decoding OP_RETURN memos to trigger ZetaChain deposits/calls, Bitcoin withdrawal helper with fee fallback, and integration into startup, runtime context, logging, and token creation guards. Changes
Sequence Diagram(s)sequenceDiagram
participant Setup as bitcoinSetup()
participant Bitcoind as bitcoind RPC
participant Registry as CoreRegistry
participant TSS as Wallet/TSS
Setup->>Bitcoind: Ensure bitcoind running (spawn or detect)
Setup->>Bitcoind: Resolve or create TSS wallet/address
alt address resolved
Setup->>TSS: Use resolved tssAddress
else fallback
Setup->>Setup: Use placeholder tss address
end
Setup->>Bitcoind: Mine 101 blocks to tssAddress
Setup->>Registry: Activate Bitcoin chain & register gateway
Setup-->>Setup: Return addresses & env (tssAddress)
sequenceDiagram
participant Observer as startBitcoinObserver()
participant CLI as bitcoin-cli / RPC
participant Mempool as Bitcoin mempool
participant Zeta as ZetaChain
loop every pollInterval
Observer->>CLI: resolve watchAddress (wallet/address)
Observer->>Mempool: getrawmempool
Mempool-->>Observer: txids
par per txid
Observer->>CLI: getrawtransaction (verbose)
CLI-->>Observer: tx data
Observer->>Observer: scan vout for watchAddress and OP_RETURN memo
alt output to watchAddress found
Observer->>Observer: extractMemoHex / tryDecodeMemoHex
alt memo has payload
Observer->>Zeta: zetachainDepositAndCall(payload, receiver, amount, asset)
else
Observer->>Zeta: zetachainDeposit(receiver, amount, asset)
end
end
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
Pre-merge checks and finishing touches✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro Disabled knowledge base sources:
📒 Files selected for processing (3)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (2)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
Warning Review ran into problems🔥 ProblemsErrors were encountered while retrieving linked issues. Errors (1)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🧹 Nitpick comments (1)
src/chains/bitcoin/withdraw.ts (1)
33-75: Validate the receiver before broadcasting.At the moment we rely on
sendtoaddressto fail if the destination string is malformed, which produces a generic wallet error after constructing the transaction context. Pre-validating tightens the flow and returns a deterministic message before we touch wallet state.+ const validationResult = JSON.parse( + runBitcoinCli([ + "-regtest", + "-rpcwallet=tss", + "validateaddress", + receiverAddress, + ]) + ); + + if (!validationResult.isvalid) { + throw new Error(`Invalid Bitcoin receiver address: ${receiverAddress}`); + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (10)
src/chains/bitcoin/isBitcoinAvailable.ts(1 hunks)src/chains/bitcoin/observer.ts(1 hunks)src/chains/bitcoin/setup.ts(1 hunks)src/chains/bitcoin/withdraw.ts(1 hunks)src/chains/zetachain/withdraw.ts(3 hunks)src/commands/start.ts(5 hunks)src/constants.ts(1 hunks)src/index.ts(6 hunks)src/logger.ts(1 hunks)src/tokens/createToken.ts(1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-06-03T16:51:16.398Z
Learnt from: hernan-clich
Repo: zeta-chain/localnet PR: 190
File: src/index.ts:336-349
Timestamp: 2025-06-03T16:51:16.398Z
Learning: In src/index.ts, the BNB chain's DepositedAndCalled event handler intentionally has exitOnError hardcoded to false, which is existing behavior from the main branch that should be preserved.
Applied to files:
src/chains/zetachain/withdraw.tssrc/tokens/createToken.ts
📚 Learning: 2025-05-30T17:27:49.816Z
Learnt from: hernan-clich
Repo: zeta-chain/localnet PR: 186
File: src/chains/solana/constants.ts:6-11
Timestamp: 2025-05-30T17:27:49.816Z
Learning: The hardcoded TSS private key in src/chains/solana/constants.ts is intended only for local network development and testing, not production use.
Applied to files:
src/commands/start.ts
🧬 Code graph analysis (8)
src/logger.ts (1)
src/constants.ts (1)
NetworkID(13-21)
src/chains/bitcoin/withdraw.ts (2)
src/logger.ts (1)
logger(48-48)src/constants.ts (1)
NetworkID(13-21)
src/chains/zetachain/withdraw.ts (2)
src/constants.ts (1)
NetworkID(13-21)src/chains/bitcoin/withdraw.ts (1)
bitcoinWithdraw(22-89)
src/chains/bitcoin/observer.ts (5)
src/logger.ts (1)
logger(48-48)src/chains/zetachain/deposit.ts (1)
zetachainDeposit(7-56)src/constants.ts (1)
NetworkID(13-21)src/chains/zetachain/depositAndCall.ts (1)
zetachainDepositAndCall(6-78)src/backgroundProcesses.ts (1)
addBackgroundProcess(8-10)
src/chains/bitcoin/setup.ts (4)
src/logger.ts (1)
logger(48-48)src/constants.ts (1)
NetworkID(13-21)src/chains/bitcoin/isBitcoinAvailable.ts (1)
isBitcoinAvailable(3-11)src/utils/registryUtils.ts (1)
registerContracts(152-166)
src/tokens/createToken.ts (1)
src/constants.ts (1)
NetworkID(13-21)
src/index.ts (3)
src/constants.ts (1)
NetworkID(13-21)src/chains/bitcoin/setup.ts (1)
bitcoinSetup(176-261)src/tokens/createToken.ts (1)
createToken(32-248)
src/commands/start.ts (5)
src/chains/bitcoin/isBitcoinAvailable.ts (1)
isBitcoinAvailable(3-11)src/chains/bitcoin/setup.ts (1)
startBitcoinNode(152-174)src/index.ts (1)
getZetaRuntimeContext(32-32)src/constants.ts (1)
NetworkID(13-21)src/chains/bitcoin/observer.ts (1)
startBitcoinObserver(81-312)
🔇 Additional comments (6)
src/chains/bitcoin/isBitcoinAvailable.ts (1)
4-9: Availability probe looks sound.The synchronous version checks with suppressed output align with our other chain guards and keep startup noise-free.
src/tokens/createToken.ts (1)
43-50: Bitcoin guard keeps token creation consistent.The additional early exit mirrors the other non-EVM safeguards and prevents partial BTC token setup when contracts are unavailable.
src/logger.ts (1)
20-27: Logging palette stays coherent.Adding the Bitcoin mapping with
yellowBrightkeeps the chain table exhaustive without altering existing formatting.src/constants.ts (1)
14-20: Network ID registration is tidy.Including the Bitcoin identifier here keeps the enum authoritative for downstream lookups.
src/index.ts (2)
82-114: Parallelizing bitcoinSetup fits the existing pattern.Running the Bitcoin setup alongside the other non-EVM initializers maintains parity and keeps the skip logic centralized.
297-302: Runtime context exposure is useful.Publishing the runtime handles once initialization completes gives the observer layer a clean, synchronous entry point.
| 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; | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| zetachainContracts.coreRegistry, | ||
| NetworkID.Bitcoin, | ||
| { | ||
| gateway: ethers.hexlify(ethers.toUtf8Bytes(tssAddress)), | ||
| } | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Invalid address when registering the Bitcoin gateway
registerContract encodes the gateway argument as an EVM address. Passing ethers.hexlify(ethers.toUtf8Bytes(tssAddress)) produces a variable-length payload (e.g., 42+ bytes for bech32), so ethers throws invalid address and bitcoinSetup aborts before Bitcoin is activated. Decode the bech32 address to its 20-byte witness program (or otherwise map it to a proper AddressLike) before calling registerContracts. For example:
+import { bech32 } from "bech32";
…
- await registerContracts(
- zetachainContracts.coreRegistry,
- NetworkID.Bitcoin,
- {
- gateway: ethers.hexlify(ethers.toUtf8Bytes(tssAddress)),
- }
- );
+ const decoded = bech32.decode(tssAddress);
+ const [, ...programWords] = decoded.words;
+ const witnessProgram = Uint8Array.from(bech32.fromWords(programWords));
+ if (witnessProgram.length !== 20) {
+ throw new Error("Unexpected Bitcoin witness program length");
+ }
+ await registerContracts(
+ zetachainContracts.coreRegistry,
+ NetworkID.Bitcoin,
+ {
+ gateway: ethers.hexlify(witnessProgram),
+ }
+ );Committable suggestion skipped: line range outside the PR's diff.
| 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 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ensure cleanup runs before aborting Bitcoin startup.
Both the rethrow inside the Bitcoin start block and the explicit throw new Error on missing binaries skip gracefulShutdown. By this point anvil (and potentially Solana/Sui) are already running, so we leak processes when we abort, requiring manual cleanup.
Please make sure we await gracefulShutdown() before propagating these errors so existing child processes are terminated deterministically.
} catch (error) {
log.error(
`Failed to start Bitcoin node: ${
error instanceof Error ? error.message : String(error)
}`
);
if (options.exitOnError) {
- throw error;
+ await gracefulShutdown();
+ throw error;
}
}
...
} 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"
- );
+ const message =
+ "bitcoind and bitcoin-cli are not available. Please, install them and try again: https://bitcoin.org/en/full-node";
+ log.error(message, { chain: "bitcoin" });
+ await gracefulShutdown();
+ throw new Error(message);
} else {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 { | |
| 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) { | |
| await gracefulShutdown(); | |
| 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()) { | |
| const message = | |
| "bitcoind and bitcoin-cli are not available. Please, install them and try again: https://bitcoin.org/en/full-node"; | |
| log.error(message, { chain: "bitcoin" }); | |
| await gracefulShutdown(); | |
| throw new Error(message); | |
| } else { |
🤖 Prompt for AI Agents
In src/commands/start.ts around lines 292 to 323, ensure gracefulShutdown is
awaited before propagating Bitcoin startup errors: in the catch block where you
rethrow when options.exitOnError is true, call and await gracefulShutdown()
(handling any errors from shutdown if needed) before throwing the original
error; likewise, before the explicit throw when bitcoind/bitcoin-cli are
missing, call and await gracefulShutdown() so already-started child processes
are terminated, then throw the Error. Ensure the surrounding function is async
or that you can await gracefulShutdown() in this context.
|
@hernan-clich please, review as code owner. |
Deploy a Universal Contract to Call
Replace the contract in the Hello example to handle bytes message:
Deposit and Call with Memo
Withdraw ZRC-20 BTC to Bitcoin
Summary by CodeRabbit
New Features
Other