-
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?
Changes from all commits
0584f87
42d3469
146002d
a7ec061
a639aab
1ad2121
0403556
066ddc1
77056c2
f4cc2ef
752755f
fa1f923
8551eb4
3b4c556
c4af649
6be2c47
ab3e75c
cfbd78f
a5c3e19
c4ff63b
ac3ceb2
e33c3b8
4c9d930
7f81df3
c25cdfa
c8c32f6
b61d5b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,4 +29,4 @@ test-ledger | |
| localnet.json | ||
|
|
||
| .vscode | ||
| .idea | ||
| .idea | ||
| 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; | ||
| } | ||
| }; |
| 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>(); | ||
kingpinXD marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const intervalId = setInterval(async () => { | ||
| try { | ||
| // If address is not yet known, try to obtain/create it with RPC wait | ||
kingpinXD marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not mark txids as processed before we can act on them
- 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(
🤖 Prompt for AI Agents |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Convert BTC amounts without triggering scientific notation
- 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 |
||
| 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); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.