UNPKG

wrapped-wit-contracts

Version:
312 lines (268 loc) 14 kB
require('dotenv').config(); const fs = require("fs") const { ethers } = require('ethers'); const { utils, Witnet } = require("@witnet/sdk") const { WrappedWIT } = require(".."); const ETH_NETWORK = process.env.WRAPPED_WIT_UNWRAPPER_ETH_NETWORK const ETH_SKIP_BLOCKS = process.env.WRAPPED_WIT_UNWRAPPER_ETH_SKIP_BLOCKS || 1 const ETH_WSS_PROVIDER = process.env.WRAPPED_WIT_UNWRAPPER_ETH_WSS_PROVIDER const ETH_WSS_RECONNECT_INTERVAL = process.env.WRAPPED_WIT_UNWRAPPER_ETH_WSS_RECONNECT_MSECS || 5000; const STORAGE_PATH = process.env.WRAPPED_WIT_UNWRAPPER_STORAGE_PATH || ".unwrapper" const WIT_MASTER_KEY = process.env.WRAPPED_WIT_UNWRAPPER_WIT_MASTER_KEY const WIT_MIN_BALANCE = process.env.WRAPPED_WIT_UNWRAPPER_WIT_MIN_BALANCE_WITS || 1000.0 const WIT_MIN_UTXOS = process.env.WRAPPED_WIT_UNWRAPPER_WIT_MIN_UTXOS || 16 const WIT_RPC_PROVIDER = process.env.WRAPPED_WIT_UNWRAPPER_WIT_RPC_PROVIDER || "https://rpc-01.witnet.io" const WIT_SIGNER_PKH=process.env.WRAPPED_WIT_UNWRAPPER_WIT_SIGNER_PKH const WIT_UTXOS_STRATEGY = process.env.WRAPPED_WIT_UNWRAPPER_WIT_UTXOS_STRATEGY || "slim-fit" const WIT_VTT_CONFIRMATIONS = process.env.WRAPPED_WIT_UNWRAPPER_WIT_VTT_CONFIRMATIONS || 3 const WIT_VTT_PRIORITY = process.env.WRAPPED_WIT_UNWRAPPER_WIT_VTT_PRIORITY || "opulent" if (!WrappedWIT.isNetworkSupported(ETH_NETWORK)) { console.error(`Fatal: ${ETH_NETWORK.toUpperCase()} is not supported!`) process.exit(1) } else if (!WrappedWIT.isNetworkCanonical(ETH_NETWORK)) { console.error(`Fatal: ${ETH_NETWORK.toUpperCase()} is not canonical!`) process.exit(1) } if (!fs.existsSync(STORAGE_PATH)) { fs.writeFileSync(STORAGE_PATH, ""); } async function main() { const wallet = await Witnet.Wallet.fromXprv( WIT_MASTER_KEY, { limit: 1, strategy: WIT_UTXOS_STRATEGY, provider: await Witnet.JsonRpcProvider.fromURL(WIT_RPC_PROVIDER), }) await wallet.getAccount(WIT_SIGNER_PKH || wallet.coinbase.pkh) const signer = wallet.getSigner(WIT_SIGNER_PKH || wallet.coinbase.pkh) console.info(`Wit/RPC provider: ${WIT_RPC_PROVIDER}`) console.info(`Witnet network: WITNET:${wallet.provider.network.toUpperCase()} (${wallet.provider.networkId.toString(16)})`) console.info(`Witnet hot wallet: ${signer.pkh}`) const VTTs = Witnet.ValueTransfers.from(signer) const minBalance = Witnet.Coins.fromWits(WIT_MIN_BALANCE) let balance = Witnet.Coins.fromPedros(0n) balance = await checkWitnetBalance() console.info(`Initial balance: ${balance.toString(2)} (${signer.cacheInfo.size} UTXOs)`) if (balance.pedros < minBalance.pedros) { console.error(`❌ Fatal: hot wallet must be funded with at least ${minBalance.toString(2)}.`) process.exit(0) } let fromBlock try { fromBlock = BigInt(fs.readFileSync(STORAGE_PATH)) } catch (err) { console.error(err) } if (!fromBlock || fromBlock < ETH_SKIP_BLOCKS) { fromBlock = BigInt(ETH_SKIP_BLOCKS) } let provider; let wrappedWIT; let inbound = [] let vttEthMempool = {} let vttBlockNumbers = {} async function connect() { console.info(`Connecting to WebSocket ...`); console.info(`> Eth/WSS provider: ${ETH_WSS_PROVIDER}`) provider = new ethers.WebSocketProvider(ETH_WSS_PROVIDER); const network = await provider.getNetwork() if (Number(network.chainId) !== WrappedWIT.getNetworkChainId(ETH_NETWORK)) { console.error(`> Fatal: connected to wrong EVM chain id (${network.chainId}).`) process.exit(0) } console.info(`> Ethereum network: ${ETH_NETWORK.toUpperCase()} (${network.chainId})`) if (WrappedWIT.isNetworkMainnet() && signer.provider.network !== "mainnet") { console.error(`> Fatal: EVM mainnets must be bridged to Witnet Mainnet network.`) process.exit(0) } wrappedWIT = await WrappedWIT.fetchContractFromEthersProvider(provider) console.info(`> Ethereum contract: ${await wrappedWIT.getAddress()}`) const witUnwrapper = await wrappedWIT.witUnwrapper() if (witUnwrapper !== signer.pkh) { console.error(`> Fatal: contract's hot wallet mismatch: ${witUnwrapper}`) process.exit(0) } if (await provider.getBlockNumber() > fromBlock) { const unwraps = await wrappedWIT.queryFilter("Unwrapped", fromBlock) if (unwraps.length > 0) { console.info(`> Catching up previous unwraps ...`) await Promise.all(unwraps.map(log => onUnwrapped(...log.args, log))) } } wrappedWIT.on("NewUnwrapper", onNewUnwrapper) wrappedWIT.on("Unwrapped", onUnwrapped); provider.websocket.on("close", (code) => { console.error(`⚠️ WebSocket closed with code ${code}. Reconnecting in ${ETH_WSS_RECONNECT_INTERVAL / 1000} seconds...`); cleanup(); setTimeout(connect, ETH_WSS_RECONNECT_INTERVAL); }); provider.on("block", async (blockNumber) => { console.info(`> EVM block number: ${blockNumber}`) if (Number(blockNumber) % 10 === 0) { balance = await checkWitnetBalance() if (balance.nanowits < minBalance.nanowits) { console.error(`> Witnet balance below ${minBalance.toString(2)}`) } else { console.info (`> Witnet balance: ${balance.toString(2)}`) } } flushInbound() }) provider.on("error", err => { console.error("⚠️ WebSocket error:", err.message); cleanup(); setTimeout(connect, ETH_WSS_RECONNECT_INTERVAL); }); } // Clean listeners and connections before reconnecting function cleanup() { wrappedWIT?.removeAllListeners(); provider?.websocket.close() } function onNewUnwrapper(newUnwrapper, event) { if (newUnwrapper !== signer.pkh) { console.info(`❌ Fatal: contract's hot wallet changed from ${signer.pkh} to ${newUnwrapper} on block ${event.blockNumber}.`) process.exit(0) } } async function onUnwrapped(from, to, value, nonce, event) { let blockNumber = event?.log?.blockNumber || event?.blockNumber // Rely on Witnet's metadata storage to verify the unwrap transaction has not yet been attended, // neither by `signer.pkh` nor any other hot wallet in the past. const witUnwrapTx = await WrappedWIT.findUnwrapTransactionFromWitnetProvider( wallet.provider, ETH_NETWORK, blockNumber, nonce, from, to, value, ); if (witUnwrapTx) { console.info(`> Unwrapped { block: ${blockNumber}, nonce: ${nonce}, from: ${from}, into: ${to}, value: ${ethers.formatUnits(value, 9)} WIT }`) } else { const digest = WrappedWIT.getNetworkUnwrapTransactionDigest(ETH_NETWORK, blockNumber, nonce, from, to, value) inbound.push({ blockNumber, nonce, from, to, value, event, digest, metadata: Witnet.PublicKeyHash.fromHexString(digest).toBech32(wallet.provider.network) }) } } async function flushInbound() { const pending = [] const pendingValue = 0n for (let index = 0; index < inbound.length; index ++) { const unwrap = inbound[index] if (signer.cacheInfo.expendable > unwrap?.value) { const { blockNumber, nonce, from, to, digest, metadata, value } = unwrap // Double-check that the unwrap transaction has not yet been attended: const witUnwrapTx = await WrappedWIT.findUnwrapTransactionFromWitnetProvider( wallet.provider, ETH_NETWORK, blockNumber, nonce, from, to, value, ); if (witUnwrapTx) { console.info(`> Unwrapped { block: ${blockNumber}, nonce: ${nonce}, from: ${from}, into: ${to}, value: ${ethers.formatUnits(value, 9)} WIT }`) } else { let vtt; try { vtt = await VTTs.sendTransaction({ recipients: [ [ to, Witnet.Coins.fromPedros(value) ], [ metadata, Witnet.Coins.fromPedros(1n) ] ], fees: WIT_VTT_PRIORITY }) } catch (err) { console.error(err) pending.push(unwrap) pendingValue += unwrap.value continue } console.info(`> Unwrapping { block: ${blockNumber}, nonce: ${nonce}, from: ${from} } ...`) console.info(` => Recipient: ${to}`) console.info(` => Metadata: ${metadata} [${digest.slice(2)}]`) console.info(` => Value: ${ethers.formatUnits(value, 9)} WIT`) console.info(` => Fee: ${vtt.fees.toString(2)}`) console.info(` => VTT hash: ${vtt.hash}`) // push new vtt data into unwrapper's mempool if (!vttEthMempool[blockNumber]) vttEthMempool[blockNumber] = [] vttEthMempool[blockNumber][vtt.hash] = { nonce, from, to, value } vttBlockNumbers[vtt.hash] = blockNumber VTTs.confirmTransaction(vtt.hash, { confirmations: WIT_VTT_CONFIRMATIONS, onStatusChange: traceUnwrapping, onCheckpoint: traceUnwrapping }) } } else { pending.push(unwrap) pendingValue += unwrap.value } } if (pendingValue > balance.nanowits) { console.error(`> Insufficient funds (available: ${balance.toString(3)}, required: ${Witnet.Coins.fromPedros(pendingValue).toString(3)})`) } else if (pending.length > 0) { console.info (`> Awaiting UTXOs to be available for ${pending.length} pending transfers.`) } inbound = pending } function traceUnwrapping(receipt, error) { if (error) { console.info(`> Failed { block: ${blockNumber}, nonce: ${data.nomce}, from: ${data.from} }`) console.error(error) } let blockNumber = vttBlockNumbers[receipt.hash] let data = vttEthMempool[blockNumber][receipt.hash] if (receipt.status !== "confirmed" && receipt.status !== "finalized") { if (receipt.status !== "relayed") { const status = receipt.confirmations ? `T - ${WIT_VTT_CONFIRMATIONS - receipt.confirmations}` : receipt.status console.info(`> Unwrapping { block: ${blockNumber}, nonce: ${data.nonce}, from: ${data.from} } ... ${status}`) } } else if (receipt.status !== "relayed") { console.info(`> Unwrapped { block: ${blockNumber}, nonce: ${data.nonce}, from: ${data.from}, into: ${data.to}, value: ${ethers.formatUnits(data.value, 9)} WIT }`) delete vttBlockNumbers[receipt.hash] delete vttEthMempool[blockNumber][receipt.hash] if (vttEthMempool[blockNumber].length === 0) { saveFromBlock(blockNumber) } } } function saveFromBlock(blockNumber) { // update last fully processed block number into local storage if (fromBlock < BigInt(blockNumber)) { fromBlock = blockNumber try { console.info(`> EVM checkpoint at: ${blockNumber}`) fs.writeFileSync(STORAGE_PATH, fromBlock.toString(), { flag: "w+" }) } catch (err) { console.error(`❌ Fatal: cannot write into local storage: ${err}`) process.exit(0) } } } async function checkWitnetBalance() { let newBalance = Witnet.Coins.fromPedros((await signer.getBalance()).unlocked) let now = Math.floor(Date.now() / 1000) let increased = newBalance.nanowits > balance?.nanowtis || 0n let utxos = (await signer.getUtxos(increased)).filter(utxo => utxo.timelock <= now) if (increased && utxos.length < WIT_MIN_UTXOS) { const splits = Math.min(WIT_MIN_UTXOS * 2, 50) let fees = 10000n const recipients = [] const value = Witnet.Coins.fromPedros((newBalance.pedros - fees) / BigInt(splits)) fees += (newBalance.pedros - fees) % BigInt(splits) recipients.push(...Array(splits).fill([ signer.pkh, value ])) const receipt = await VTTs.sendTransaction({ recipients, fees: Witnet.Coins.fromPedros(fees) }) console.info(JSON.stringify(receipt.tx, utils.txJsonReplacer, 4)) await VTTs.confirmTransaction(receipt.hash, { onStatusChange: (receipt) => { console.info(`> Splitting UTXOs => ${receipt.hash} [${receipt.status}]`)}, }) newBalance = Witnet.Coins.fromPedros((await signer.getBalance()).unlocked) } return newBalance } connect() } main();