diff --git a/bin/commands/bitcoin.js b/bin/commands/bitcoin.js index 65fa251d..512269a4 100644 --- a/bin/commands/bitcoin.js +++ b/bin/commands/bitcoin.js @@ -6,6 +6,8 @@ import { BitcoinHelpers } from "../../index.js" import sha256 from "bcrypto/lib/sha256-browser.js" +import web3Utils from "web3-utils" +const { toBN } = web3Utils const { digest } = sha256 export const bitcoinCommandHelp = [ @@ -50,84 +52,35 @@ export function parseBitcoinCommand(web3, args) { if (extra.length === 0) { return async tbtc => { - const toBN = web3.utils.toBN - const rawOutputScript = BitcoinHelpers.Address.toRawScript( - outputAddress - ) - const rawPreviousOutpoint = Buffer.concat([ - toBN(previousTransactionID).toArrayLike(Buffer, "le", 32), - toBN(previousOutputIndex).toArrayLike(Buffer, "le", 4) - ]) - const { value: previousOutputValueBtc, address: previousOutputAddress } = await BitcoinHelpers.Transaction.getSimpleOutput( previousTransactionID, - parseInt(previousOutputIndex, 16) + parseInt(previousOutputIndex) ) - const previousOutputValue = + const previousOutputValue = Math.round( previousOutputValueBtc * - BitcoinHelpers.satoshisPerBtc.toNumber() - const rawPreviousOutputScript = BitcoinHelpers.Address.toRawScript( - previousOutputAddress - ) - const transactionFee = await BitcoinHelpers.Transaction.estimateFee( - tbtc.depositFactory.constants() - ) - - const outputValue = toBN(previousOutputValue).sub( - transactionFee.divn(4) + BitcoinHelpers.satoshisPerBtc.toNumber() ) - /** @type {Buffer} */ - const outputValueBytes = outputValue.toArrayLike(Buffer, "le", 8) - - // Construct per BIP-143; see https://en.bitcoin.it/wiki/BIP_0143 - // for more. - const sighashPreimage = Buffer.concat( - [ - // version - `01000000`, - // hashPrevouts - digest(digest(rawPreviousOutpoint)), - // hashSequence(00000000) - digest(digest(Buffer.from("00000000", "hex"))), - // outpoint - rawPreviousOutpoint, - // P2wPKH script: - Buffer.concat([ - // length, dup, hash160, pkh_length - Buffer.from("1976a914", "hex"), - // pkh, without prefix length info - rawPreviousOutputScript.slice(2), - // equal, checksig - Buffer.from("88ac", "hex") - ]), - // 8-byte little-endian input value (= previous output value) - toBN(previousOutputValue).toArrayLike(Buffer, "le", 8), - // input nSequence - "00000000", - // hash of the single output - digest( - digest( - Buffer.concat([ - // value bytes - outputValueBytes, - // length of output script - Buffer.of(rawOutputScript.byteLength), - // output script - rawOutputScript - ]) - ) - ), - // nLockTime - "00000000", - // SIG_ALL - "01000000" - ].map(_ => Buffer.from(_, "hex")) - ) - - return digest(digest(sighashPreimage)).toString("hex") + const transactionFee = ( + await BitcoinHelpers.Transaction.estimateFee( + tbtc.depositFactory.constants() + ) + ).muln(5) + + return computeSighash( + { + transactionID: previousTransactionID, + index: previousOutputIndex + }, + previousOutputValue, + previousOutputAddress, + { + value: previousOutputValue - transactionFee.toNumber(), + address: outputAddress + } + ).toString("hex") } } } @@ -144,56 +97,31 @@ export function parseBitcoinCommand(web3, args) { if (extra.length === 0) { return async tbtc => { - const toBN = web3.utils.toBN - const rawOutputScript = BitcoinHelpers.Address.toRawScript( - outputAddress - ) - const rawPreviousOutpoint = Buffer.concat([ - toBN(previousTransactionID).toArrayLike(Buffer, "le", 32), - toBN(previousOutputIndex).toArrayLike(Buffer, "le", 4) - ]) - const { value: previousOutputValueBtc } = await BitcoinHelpers.Transaction.getSimpleOutput( previousTransactionID, - parseInt(previousOutputIndex, 16) + parseInt(previousOutputIndex) ) - const previousOutputValue = + const previousOutputValue = Math.round( previousOutputValueBtc * BitcoinHelpers.satoshisPerBtc.toNumber() - const transactionFee = await BitcoinHelpers.Transaction.estimateFee( - tbtc.depositFactory.constants() - ) - - const outputValue = toBN(previousOutputValue).sub( - transactionFee.divn(4) - ) - - const rawTransaction = BitcoinHelpers.Transaction.constructOneInputOneOutputWitnessTransaction( - rawPreviousOutpoint.toString("hex"), - // We set sequence to `0` to be able to replace by fee. It reflects - // bitcoin-spv: - // https://github.com/summa-tx/bitcoin-spv/blob/2a9d594d9b14080bdbff2a899c16ffbf40d62eef/solidity/contracts/CheckBitcoinSigs.sol#L154 - 0, - outputValue.toNumber(), - rawOutputScript.toString("hex") ) - - const signatureR = digestSignature.slice(0, 64) - const signatureS = digestSignature.slice(64) - const publicKeyPoint = BitcoinHelpers.Address.splitPublicKey( - publicKeyString - ) - - const signedTransaction = BitcoinHelpers.Transaction.addWitnessSignature( - rawTransaction, - 0, - signatureR, - signatureS, - BitcoinHelpers.publicKeyPointToPublicKeyString( - publicKeyPoint.x.toString("hex"), - publicKeyPoint.y.toString("hex") + const transactionFee = ( + await BitcoinHelpers.Transaction.estimateFee( + tbtc.depositFactory.constants() ) + ).muln(5) + + const outputValue = toBN(previousOutputValue).sub(transactionFee) + + const signedTransaction = constructSignedTransaction( + { + transactionID: previousTransactionID, + index: previousOutputIndex + }, + digestSignature, + publicKeyString, + { value: outputValue.toNumber(), address: outputAddress } ) const transaction = await BitcoinHelpers.Transaction.broadcast( @@ -210,3 +138,110 @@ export function parseBitcoinCommand(web3, args) { // If we're here, no command matched. return null } + +export function computeSighash( + /** @type {{ transactionID: string, index: string }} */ previousOutpoint, + /** @type {number} */ previousOutputValue, + /** @type {string} */ previousOutputAddress, + /** @type {{value: number, address: string}[]} */ ...outputs +) { + const rawPreviousOutpoint = Buffer.concat([ + toBN(previousOutpoint.transactionID).toArrayLike(Buffer, "le", 32), + toBN(previousOutpoint.index).toArrayLike(Buffer, "le", 4) + ]) + const rawPreviousOutputScript = BitcoinHelpers.Address.toRawScript( + previousOutputAddress + ) + const outputByteValues = outputs.map(({ value, address }) => ({ + valueBytes: toBN(value).toArrayLike(Buffer, "le", 8), + rawOutputScript: BitcoinHelpers.Address.toRawScript(address) + })) + + // Construct per BIP-143; see https://en.bitcoin.it/wiki/BIP_0143 + // for more. + const preimage = Buffer.concat( + [ + // version + `01000000`, + // hashPrevouts + digest(digest(rawPreviousOutpoint)), + // hashSequence(00000000) + digest(digest(Buffer.from("00000000", "hex"))), + // outpoint + rawPreviousOutpoint, + // P2wPKH script: + Buffer.concat([ + // length, dup, hash160, pkh_length + Buffer.from("1976a914", "hex"), + // pkh, without prefix length info + rawPreviousOutputScript.slice(2), + // equal, checksig + Buffer.from("88ac", "hex") + ]), + // 8-byte little-endian input value (= previous output value) + toBN(previousOutputValue).toArrayLike(Buffer, "le", 8), + // input nSequence + "00000000", + // hash of the outputs + digest( + digest( + Buffer.concat( + outputByteValues.flatMap(({ valueBytes, rawOutputScript }) => [ + // value bytes + valueBytes, + // length of output script + Buffer.of(rawOutputScript.byteLength), + // output script + rawOutputScript + ]) + ) + ) + ), + // nLockTime + "00000000", + // SIG_ALL + "01000000" + ].map(_ => Buffer.from(_, "hex")) + ) + + return /** @type {Buffer} */ (digest(digest(preimage))) +} + +export function constructSignedTransaction( + /** @type {{ transactionID: string, index: string }} */ previousOutpoint, + /** @type {string} */ sighashSignature, + /** @type {string} */ publicKeyString, + /** @type {{value: number, address: string}[]} */ ...outputs +) { + const rawPreviousOutpoint = Buffer.concat([ + toBN(previousOutpoint.transactionID).toArrayLike(Buffer, "le", 32), + toBN(previousOutpoint.index).toArrayLike(Buffer, "le", 4) + ]) + + const rawTransaction = BitcoinHelpers.Transaction.constructOneInputWitnessTransaction( + rawPreviousOutpoint.toString("hex"), + // We set sequence to `0` to be able to replace by fee. It reflects + // bitcoin-spv: + // https://github.com/summa-tx/bitcoin-spv/blob/2a9d594d9b14080bdbff2a899c16ffbf40d62eef/solidity/contracts/CheckBitcoinSigs.sol#L154 + 0, + ...outputs.map(({ value, address }) => ({ + value, + script: BitcoinHelpers.Address.toScript(address) + })) + ) + + const signatureR = sighashSignature.slice(0, 64) + const signatureS = sighashSignature.slice(64) + const publicKeyPoint = BitcoinHelpers.Address.splitPublicKey(publicKeyString) + + return BitcoinHelpers.Transaction.addWitnessSignature( + rawTransaction, + 0, + signatureR, + signatureS, + BitcoinHelpers.publicKeyPointToPublicKeyString( + publicKeyPoint.x.toString("hex"), + publicKeyPoint.y.toString("hex") + ) + ) +} diff --git a/bin/copied-stake-status.js b/bin/copied-stake-status.js new file mode 100755 index 00000000..77238014 --- /dev/null +++ b/bin/copied-stake-status.js @@ -0,0 +1,216 @@ +#!/usr/bin/env NODE_BACKEND=js node --experimental-modules --experimental-json-modules +// //// +// bin/copied-stake-status.js +// +// Iterates through known eligible addresses for stake copying and reports +// their status in CSV format. The resulting CSV has 7 fields: +// - operator is the operator address for which stake was copied. +// - owner is the owner address of the operator. +// - oldAmount is the amount staked on the old staking contract at the time +// the script was run. 0 if the stake was undelegated and recovered. +// - amountCopied is the amount copied to the new staking contract; this +// should be 0 if stake copying was not used, and equal to oldAmount +// otherwise. +// - availableBalance is the current liquid KEEP token balance for the owner +// account. +// - paidBack is true if the copied stake was paid back, false otherwise. +// - oldUndelegatedAt is 0 if the old staking contract's stake was never +// undelegated, and a block timestamp if the stake was undelegated. +// //// +import Subproviders from "@0x/subproviders" +import Web3 from "web3" +import ProviderEngine from "web3-provider-engine" +import WebsocketSubprovider from "web3-provider-engine/subproviders/websocket.js" +/** @typedef { import('../src/EthereumHelpers.js').TruffleArtifact } TruffleArtifact */ + +import OldTokenStakingJSON from "@keep-network/keep-core/artifacts/OldTokenStaking.json" +import KeepTokenJSON from "@keep-network/keep-core/artifacts/KeepToken.json" +import StakingPortBackerJSON from "@keep-network/keep-core/artifacts/StakingPortBacker.json" + +import { + findAndConsumeArgsExistence, + findAndConsumeArgsValues +} from "./helpers.js" +import { EthereumHelpers } from "../index.js" +import { contractsFromWeb3, lookupOwnerAndGrantType } from "./owner-lookup.js" + +let args = process.argv.slice(2) +if (process.argv[0].includes("refunds.js")) { + args = process.argv.slice(1) // invoked directly, no node +} + +// No debugging unless explicitly enabled. +const { + found: { debug }, + remaining: flagArgs +} = findAndConsumeArgsExistence(args, "--debug") +if (!debug) { + console.debug = () => {} +} + +const { + found: { mnemonic, /* account,*/ rpc } +} = findAndConsumeArgsValues(flagArgs, "--mnemonic", "--account", "--rpc") +const engine = new ProviderEngine({ pollingInterval: 1000 }) + +engine.addProvider( + // For address 0x420ae5d973e58bc39822d9457bf8a02f127ed473. + new Subproviders.PrivateKeyWalletSubprovider( + mnemonic || + "b6252e08d7a11ab15a4181774fdd58689b9892fe9fb07ab4f026df9791966990" + ) +) +engine.addProvider( + new WebsocketSubprovider({ + rpcUrl: + rpc || + "wss://eth-mainnet.ws.alchemyapi.io/v2/I621JkcPPi7YegYM1hMK89847QX_k9u1", + debug, + origin: undefined + }) +) + +const web3 = new Web3(engine) +engine.start() + +const allowedCopyOperators = [ + "0x835b94982fea704ee3e3a49527aa565571fe790b", + "0xa31ac31bcd31302f2c7938718e3e3c678dcdc8e6", + "0x518d9df0704693a51adf2b205e35f9f7cbd87d99", + "0x85e198b8d4d6d785cd75201425bc2ec9815e2c91", + "0x284c6571cf35b5879a247922e216ef495441ada7", + "0x9c49073f0cca880c11db730fefeb54559ef8b378", + "0x25e458d05253a8e69332a2cc79e6ee06fb8e7743", + "0xf26d8407970ca01b44437d95ce750061b23a4df4", + "0x49674ceb89cd175263106670a894c373cc286a28", + "0x0d0271d1b2906cc472a8e75148937967be788f09", + "0x93a30cc97cebc14576eac6e14e1a5343a5c6022a", + "0xf6f4e1e6127f369d74ecce6523c59c75d24ce45c", + "0x9c2e1dbf17032134145c7ad6d15d604b660034d8", + "0x8c63b12babeff8758c5aa18629ba2d19ed6a0a58", + "0x76ca4b2300a2abe46dcd736f71a207ffbcb3c5e8", + "0xf25796d81d6caaca3616ce17ecc94966821d4f1d", + "0x07c9a8f8264221906b7b8958951ce4753d39628b", + "0x03ab65648d9f75da043581efdc8332aede07d70f", + "0x81e1b56db174a935fe81e4b9839d6d92528090f4", + "0x00cef852246b08b9215772c3f409d28408bb21bd", + "0xc2243c8550d03cc112110b940ed8a4b6c42ecc3c", + "0xe5c8dcd32cabdf97c48853ee14e63487fe15a907", + "0xa4166c3e14cbdd6d4494945a99616f1c73ad9699", + "0xaea619d02dcf7299fb24db2f60a08bfc8fb2dbcf", + "0xca70fea021359778daec479b97d0cd2efe1ad099", + "0x3712c6fed51ceca83ca953f6ff3458f2339436b4", + "0x438decafa74cd174ebf71c6b4bd715d001f6fab7", + "0xb822ec4fabf37b881397bd3425152adbfe516174", + "0x590204f050a12e61ed5f58188ddeb86c49ee270d", + "0xdcd4199e22d09248ca2583cbdd2759b2acd22381", + "0x6757b362bfa1dde1ece9693ec0a6527909e318b7", + "0xfc97a906c715587b56c2c65a07ce731ba80339de", + "0xa543441313f7fa7f9215b84854e6dc8386b93383", + "0x36c56a69c2aea23879b59db0e99d57ef2ff77f06", + "0x8ba4359ee951944847abf81cda84697c40fab617", + "0xfbd33b871c6e6e11f9d6a62dfc780ce4bea1ce17", + "0x7bda94202049858060e4dffa42ecb00c58d12452", + "0xc010b84528b0809295fcd21cb37415e8c532343a", + "0x1c51adbf71525002f42abc6e859413a3fc163c4c", + "0x1e5801db6779b44a90104ae856602424d8853807", + "0x7e6332d18719a5463d3867a1a892359509589a3d", + "0x3e5d36bf359698bc53bdaf8bc97de56263fa0a70", + "0xe81c50802bf9ddf190ce493a6a76cf3d36dd8793", + "0xdd704c0bc9a5815ff8c7714eaa96b52914c920d1", + "0xe48495557a31b04693142e33b7a05073ea03b767" +] + +run(async () => { + // Force the poor Web3 provider to initialize before we hit it with the lookup + // hammer. + await web3.eth.getBlockNumber() + + const spbContract = await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (StakingPortBackerJSON), + web3, + "1" + ) + const otsContract = await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (OldTokenStakingJSON), + web3, + "1" + ) + const tokenContract = await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (KeepTokenJSON), + web3, + "1" + ) + + const contractsForOwnerLookup = await contractsFromWeb3(web3) + // Run owner lookup on the original contract. + contractsForOwnerLookup.TokenStaking = otsContract + + return ( + "operator,owner,grantType,oldAmount,amountCopied,availableBalance,copied,paidBack,oldUndelegatedAt" + + (await allowedCopyOperators.reduce(async (soFar, operatorAddress) => { + // Don't smash the poor provider with parallel hits for all operators; + // instead, serialize the process. + const already = await soFar + const stakingInfo = await otsContract.methods + .getDelegationInfo(operatorAddress) + .call() + const copyInfo = await spbContract.methods + .copiedStakes(operatorAddress) + .call() + const ownerBalance = await tokenContract.methods + .balanceOf(copyInfo.owner) + .call() + + const copied = copyInfo.timestamp != 0 + + const { owner, grantType } = await lookupOwnerAndGrantType( + web3, + contractsForOwnerLookup, + operatorAddress + ) + + return ( + already + + "\n" + + operatorAddress + + "," + + owner + + "," + + grantType + + "," + + stakingInfo.amount + + "," + + copyInfo.amount + + "," + + ownerBalance + + "," + + copied + + "," + + copyInfo.paidBack + + "," + + stakingInfo.undelegatedAt + ) + }, Promise.resolve(""))) + ) +}) + +/** + * @param {function():Promise} action Command action that will yield a + * promise to the desired CLI output or error out by failing the promise. + * A null or undefined output means no output should be emitted, but the + * command should exit successfully. + */ +function run(action) { + action() + .catch(error => { + console.error("Got error", error) + process.exit(2) + }) + .then((/** @type {string} */ result) => { + if (result) { + console.log(result) + } + process.exit(0) + }) +} diff --git a/bin/liquidations.js b/bin/liquidations.js new file mode 100755 index 00000000..3c85f08f --- /dev/null +++ b/bin/liquidations.js @@ -0,0 +1,278 @@ +#!/usr/bin/env NODE_BACKEND=js node --experimental-modules --experimental-json-modules +// //// +// bin/liquidations.js [start-timestamp] [fields] +// fields is comma-separated list of fields for the CSV, which can include: +// - operator +// - owner +// - beneficiary +// - keep +// Order is taken into account, and a header row is emitted with the field +// name. +// //// +import https from "https" +import moment from "moment" +import BitcoinHelpers, { BitcoinNetwork } from "../src/BitcoinHelpers.js" +import AvailableBitcoinConfigs from "./config.json" +import { findAndConsumeArgsExistence } from "./helpers.js" + +let args = process.argv.slice(2) +if (process.argv[0].includes("liquidations.js")) { + args = process.argv.slice(1) // invoked directly, no node +} + +// No debugging unless explicitly enabled. +const { + found: { debug }, + remaining: remainingArgs +} = findAndConsumeArgsExistence(args, "--debug") +if (!debug) { + console.debug = () => {} +} + +const startDate = remainingArgs[0].match(/^[0-9]+$/) + ? moment.unix(parseInt(remainingArgs[0])) + : moment(remainingArgs[0]) +if (!startDate.isValid()) { + console.error( + `Start time ${remainingArgs[0]} is either invalid or not recent enough.` + ) + process.exit(1) +} + +const validFields = ["operator", "owner", "beneficiary", "keep"] +const fields = (remainingArgs[1] || "operator,owner,beneficiary,keep") + .toLowerCase() + .split(",") + .filter(_ => validFields.includes(_)) + +run(async () => { + const liquidations = await queryLiquidations(startDate) + + const liquidationRows = liquidations.reduce( + ( + rows, + { + deposit: { + bondedECDSAKeep: { keepAddress, members } + } + } + ) => { + members.forEach(({ address, owner, beneficiary }) => { + const asFields = /** @type {{ [field: string]: string }} */ ({ + operator: address, + owner, + beneficiary, + keep: keepAddress + }) + rows.push(fields.map(_ => asFields[_])) + }) + return rows + }, + /** @type {string[][]} */ ([]) + ) + + BitcoinHelpers.electrumConfig = AvailableBitcoinConfigs["1"].electrum + + const misfunds = await queryMisfunds(startDate) + const misfundRows = await BitcoinHelpers.withElectrumClient(async () => + misfunds.reduce( + async ( + rowsPromise, + { bondedECDSAKeep: { keepAddress, publicKey, members } } + ) => { + const rows = await rowsPromise + const keepBtcAddress = BitcoinHelpers.Address.publicKeyToP2WPKHAddress( + publicKey.replace(/^0x/, ""), + BitcoinNetwork.MAINNET + ) + const balance = await BitcoinHelpers.Transaction.getBalance( + keepBtcAddress + ) + + if (balance > 0) { + members.forEach(({ address, owner, beneficiary }) => { + const asFields = /** @type {{ [field: string]: string }} */ ({ + operator: address, + owner, + beneficiary, + keep: keepAddress + }) + rows.push(fields.map(_ => asFields[_])) + }) + } + + return rows + }, + Promise.resolve(/** @type {string[][]} */ ([])) + ) + ) + + return [fields] + .concat(liquidationRows) + .concat(misfundRows) + .map(_ => _.join(",")) + .join("\n") +}) + +/** + * @param {function():Promise} action Command action that will yield a + * promise to the desired CLI output or error out by failing the promise. + * A null or undefined output means no output should be emitted, but the + * command should exit successfully. + */ +function run(action) { + // Redirect all console logs to debug. + const originalConsoleLog = console.log + console.log = (...args) => console.debug(...args) + + action() + .catch(error => { + console.error("Got error", error) + process.exit(2) + }) + .then((/** @type {string} */ result) => { + console.log = originalConsoleLog + if (result) { + console.log(result) + } + process.exit(0) + }) +} + +/** + * @typedef {{ + * liquidationInitiated: number, + * deposit: { + * id: string, + * owner: string, + * bondedECDSAKeep: { + * keepAddress: string, + * members: [{ + * address: string, + * owner: string, + * beneficiary: string + * }] + * } + * } + * }} DepositLiquidationInfo + */ + +/** + * @param {moment.Moment} startDate The start time as a Moment object. + * @return {Promise<[DepositLiquidationInfo]>} The returned query results. + */ +async function queryLiquidations(startDate) { + return ( + await queryGraph(`{ + depositLiquidations( + first: 100, + where: { liquidationInitiated_gt: ${startDate.unix()}, isLiquidated: true } + ) { + liquidationInitiated + deposit { + id + owner + bondedECDSAKeep { + keepAddress + members { + address + owner + beneficiary + } + } + } + } + }`) + ).data.depositLiquidations +} + +/** + * @typedef {{ + * id: string, + * owner: string, + * bondedECDSAKeep: { + * keepAddress: string, + * publicKey: string, + * members: [{ + * address: string, + * owner: string, + * beneficiary: string + * }] + * } + * }} DepositMisfundInfo + */ + +/** + * @param {moment.Moment} startDate The start time as a Moment object. + * @return {Promise<[DepositMisfundInfo]>} The returned query results. + */ +async function queryMisfunds(startDate) { + return ( + await queryGraph(`{ + deposits( + first: 100, + where: { failureReason: FUNDING_TIMEOUT, createdAt_gt: ${startDate.unix()} } + ) { + id + owner + bondedECDSAKeep { + keepAddress + publicKey + members { + address + owner + beneficiary + } + } + } + }`) + ).data.deposits +} + +/** + * @param {string} graphql GraphQL query for the Keep subgraph. + * @return {Promise} Returned data as a parsed JSON object, or a failed + * promise if the request or the JSON conversion fails. + */ +function queryGraph(graphql) { + return new Promise((resolve, reject) => { + let responseContent = "" + const request = https.request( + "https://api.thegraph.com/subgraphs/name/miracle2k/all-the-keeps", + { method: "POST" }, + response => { + response.setEncoding("utf8") + response.on("data", chunk => (responseContent += chunk)) + response.on("end", () => { + if ( + response.statusCode && + (response.statusCode < 200 || response.statusCode >= 300) + ) { + reject( + new Error( + `Unexpected status: ${response.statusCode} ${response.statusMessage}` + ) + ) + } else { + try { + resolve(JSON.parse(responseContent)) + } catch (error) { + reject( + new Error( + `Error parsing response: ${error}; response was: ${responseContent}` + ) + ) + } + } + }) + } + ) + + request.write( + JSON.stringify({ + query: graphql + }) + ) + request.end() + }) +} diff --git a/bin/owner-lookup.js b/bin/owner-lookup.js new file mode 100755 index 00000000..395b1524 --- /dev/null +++ b/bin/owner-lookup.js @@ -0,0 +1,308 @@ +#!/usr/bin/env NODE_BACKEND=js node --experimental-modules --experimental-json-modules +import Subproviders from "@0x/subproviders" +import Web3 from "web3" +import ProviderEngine from "web3-provider-engine" +import WebsocketSubprovider from "web3-provider-engine/subproviders/websocket.js" +import EthereumHelpers from "../src/EthereumHelpers.js" + +/** @typedef { import('../src/EthereumHelpers.js').TruffleArtifact } TruffleArtifact */ +/** @typedef { import('../src/EthereumHelpers.js').Contract } Contract */ +/** @typedef {{ [contractName: string]: Contract}} Contracts */ + +import { + findAndConsumeArgsExistence, + findAndConsumeArgsValues +} from "./helpers.js" + +import KeepTokenJSON from "@keep-network/keep-core/artifacts/KeepToken.json" +import TokenGrantJSON from "@keep-network/keep-core/artifacts/TokenGrant.json" +import TokenStakingJSON from "@keep-network/keep-core/artifacts/TokenStaking.json" +import ManagedGrantJSON from "@keep-network/keep-core/artifacts/ManagedGrant.json" +import StakingPortBackerJSON from "@keep-network/keep-core/artifacts/StakingPortBacker.json" +import TokenStakingEscrowJSON from "@keep-network/keep-core/artifacts/TokenStakingEscrow.json" + +const ManagedGrantABI = ManagedGrantJSON.abi + +const utils = Web3.utils + +let standalone = false +const args = process.argv.slice(2) +if (process.argv.some(_ => _.includes("owner-lookup.js"))) { + standalone = true +} + +// No debugging unless explicitly enabled. +const { + found: { debug }, + remaining: flagArgs +} = findAndConsumeArgsExistence(args, "--debug") +if (!debug) { + console.debug = () => {} +} + +const { + found: { mnemonic, account, rpc }, + remaining: commandArgs +} = findAndConsumeArgsValues(flagArgs, "--mnemonic", "--account", "--rpc") +const engine = new ProviderEngine({ pollingInterval: 1000 }) + +engine.addProvider( + // For address 0x420ae5d973e58bc39822d9457bf8a02f127ed473. + new Subproviders.PrivateKeyWalletSubprovider( + mnemonic || + "b6252e08d7a11ab15a4181774fdd58689b9892fe9fb07ab4f026df9791966990" + ) +) +engine.addProvider( + new WebsocketSubprovider({ + rpcUrl: + rpc || "wss://mainnet.infura.io/ws/v3/414a548bc7434bbfb7a135b694b15aa4", + debug, + origin: undefined + }) +) + +const web3 = new Web3(engine) +engine.start() + +if (standalone) { + // owner-lookup.js + + if (!commandArgs.every(utils.isAddress)) { + console.error("All arguments must be valid Ethereum addresses.") + process.exit(1) + } + + doTheThing() + .then(result => { + console.log(result) + + process.exit(0) + }) + .catch(error => { + console.error("ERROR ", error) + + process.exit(1) + }) +} + +async function doTheThing() { + web3.eth.defaultAccount = account || (await web3.eth.getAccounts())[0] + + const operators = args + return Promise.all( + operators.map( + operator => + new Promise(async resolve => { + resolve( + [ + operator, + await lookupOwner(web3, await contractsFromWeb3(web3), operator) + ].join("\t") + ) + }) + ) + ).then(_ => _.join("\n")) +} + +/** + * @param {Web3} web3 + * @return {Promise} + */ +export async function contractsFromWeb3(/** @type {Web3} */ web3) { + const chainId = String(await web3.eth.getChainId()) + + return { + KeepToken: await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (KeepTokenJSON), + web3, + chainId + ), + TokenGrant: await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TokenGrantJSON), + web3, + chainId + ), + TokenStaking: await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TokenStakingJSON), + web3, + chainId + ), + StakingPortBacker: await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (StakingPortBackerJSON), + web3, + chainId + ), + TokenStakingEscrow: await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TokenStakingEscrowJSON), + web3, + chainId + ) + } +} + +export function lookupOwner( + /** @type {Web3} */ web3, + /** @type {{ [contractName: string]: Contract}} */ contracts, + /** @type {string} */ operator +) { + const { TokenStaking } = contracts + return TokenStaking.methods + .ownerOf(operator) + .call() + .then((/** @type {string} */ owner) => { + try { + return resolveOwner(web3, contracts, owner, operator) + } catch (e) { + return `Unknown (${e})` + } + }) +} + +export function lookupOwnerAndGrantType( + /** @type {Web3} */ web3, + /** @type {{ [contractName: string]: Contract}} */ contracts, + /** @type {string} */ operator +) { + const { TokenStaking } = contracts + return TokenStaking.methods + .ownerOf(operator) + .call() + .then((/** @type {string} */ owner) => { + try { + return resolveOwnerAndGrantType(web3, contracts, owner, operator) + } catch (e) { + return `Unknown (${e})` + } + }) +} + +/** + * @param {Web3} web3 + * @param {Contracts} contracts + * @param {string} owner + * @param {string} operator + * @return {Promise} + */ +async function resolveOwner(web3, contracts, owner, operator) { + return (await resolveOwnerAndGrantType(web3, contracts, owner, operator)) + .owner +} + +/** + * @enum {string} + */ +export const GrantType = { + None: "no grant", + SimpleGrant: "direct grant", + ManagedGrant: "managed grant" +} + +/** + * @param {Web3} web3 + * @param {Contracts} contracts + * @param {string} owner + * @param {string} operator + * @return {Promise<{owner: string, grantType: GrantType}>} + */ +async function resolveOwnerAndGrantType(web3, contracts, owner, operator) { + const { + KeepToken, + StakingPortBacker, + TokenStaking, + TokenStakingEscrow, + TokenGrant + } = contracts + + const firstStorageSlot = await web3.eth.getStorageAt(owner, 0) + + if (firstStorageSlot === "0x") { + return { owner, grantType: GrantType.None } // owner is already a user-owned account + } else if (owner == StakingPortBacker.options.address) { + const { owner } = await StakingPortBacker.methods + .copiedStakes(operator) + .call() + return resolveOwnerAndGrantType(web3, contracts, owner, operator) + } else if (owner == TokenStakingEscrow.options.address) { + const { + returnValues: { grantId } + } = await EthereumHelpers.getExistingEvent( + TokenStakingEscrow, + "DepositRedelegated", + { newOperator: operator } + ) + const { grantee } = await TokenGrant.methods.getGrant(grantId).call() + const { + grantee: finalGrantee, + grantType + } = await resolveGranteeAndGrantType(web3, grantee) + + return { owner: finalGrantee, grantType } + } else { + // If it's not a known singleton contract, try to see if it's a + // TokenGrantStake; if not, assume it's an owner-controlled contract. + try { + let grantId = null + + // TokenGrantStakes have the token address and token staking address as + // their first two storage slots. They should be the only owner with this + // characteristic. In this case, the grant id is in the 4th slot. + // + // This is unfortunately the only clear strategy for identifying + // TokenGrantStakes on both the v1.0.1 TokenStaking contract and + // the v1.3.0 upgraded one. The old contract did not have any + // events that indexed the operator contract, making it impossible + // to efficiently check if a token grant stake was at play without + // already knowing the owner. + if ( + firstStorageSlot == + web3.utils.padLeft(KeepToken.options.address, 64).toLowerCase() && + (await web3.eth.getStorageAt(owner, 1)) == + web3.utils.padLeft(TokenStaking.options.address, 64).toLowerCase() + ) { + const fourthStorageSlot = await web3.eth.getStorageAt(owner, 3) + // We're making the assumption the grant id doesn't need a BN, + // which should be a safe assumption for the foreseeable future. + grantId = web3.utils.hexToNumber(fourthStorageSlot) + } + + const { grantee } = await TokenGrant.methods.getGrant(grantId).call() + const { + grantee: finalGrantee, + grantType + } = await resolveGranteeAndGrantType(web3, grantee) + + return { owner: finalGrantee, grantType } + } catch (_) { + // If we threw, assume this isn't a TokenGrantStake and the + // owner is just an unknown contract---e.g. Gnosis Safe. + return { owner, grantType: GrantType.None } + } + } +} + +async function resolveGranteeAndGrantType( + /** @type {Web3} */ web3, + /** @type {string} */ grantee +) { + if ((await web3.eth.getStorageAt(grantee, 0)) === "0x") { + return { grantee, grantType: GrantType.SimpleGrant } // grantee is already a user-owned account + } else { + try { + const grant = EthereumHelpers.buildContract( + web3, + // @ts-ignore Oh but this is an AbiItem[] + ManagedGrantABI, + grantee + ) + + return { + grantee: await grant.methods.grantee().call(), + grantType: GrantType.ManagedGrant + } + } catch (_) { + // If we threw, assume this isn't a ManagedGrant and the + // grantee is just an unknown contract---e.g. Gnosis Safe. + return { grantee, grantType: GrantType.SimpleGrant } + } + } +} diff --git a/bin/refunds.js b/bin/refunds.js new file mode 100755 index 00000000..daa102e6 --- /dev/null +++ b/bin/refunds.js @@ -0,0 +1,1067 @@ +#!/usr/bin/env NODE_BACKEND=js node --experimental-modules --experimental-json-modules +// //// +// bin/refunds.js [-c ] [-s ] +// [-o ] +// [-r ] +// [--dry-run|-y] +// +// +// A CSV file that should have a column whose title is `keep`. It may have +// other columns, which will be ignored. +// +// -f +// The bitcoin transaction fee to use, in satoshis, as a constant. By +// default, uses a multiplier on the minimum redemption fee allowed by the +// tBTC system. +// +// -c +// The path to the keep-ecdsa client executable. If omitted, `keep-ecdsa` is +// assumed (and must be on the PATH). +// +// -s +// The directory that contains operator key shares for keeps. The directory +// should have one directory under it per keep, named after the keep +// address, and each directory should have 3 key share files in it, one per +// operator. The key share files should be the files outputted by +// `keep-ecdsa signing decrypt-key-share`. Defaults to `./key-shares`. +// +// -o +// The directory that contains Bitcoin receive addresses for operators. +// These are the addresses designated for liquidation BTC retrieval for +// each operator. The directory should contain a set of JSON files named +// `beneficiary-
.json`, where `
` is the operator address. +// The JSON files should be in the common Ethereum signature format, and +// should present a Bitcoin xpub, ypub, zpub, or address, signed by the +// operator, staker, or beneficiary addresses for that operator's +// delegation. Defaults to `./beneficiaries`. +// +// -m +// The directory that contains Bitcoin receive addresses for misfunds. +// These are the addresses designated by accounts that funded incorrectly +// to retrieve their Bitcoin. The directory should contain a set of JSON +// files named `misfund-
.json`, where `
` is the tBTC +// deposit address. The JSON files should be in the common Ethereum +// signature format, and should present a Bitcoin address, signed by the +// owner of the specified deposit. Defaults to `./misfunds`. +// +// --dry-run|-y +// When passed, runs the script in dry-run mode, where Bitcoin transactions +// are not broadcast to the chain. +// +// Iterates through the specified keep ids, looks up their public keys in +// order to compute their Bitcoin addresses, and verifies that they are still +// holding Bitcoin. If they are, creates a temp directory and starts checking +// each operator for key material availability. If key material is available +// for all three operators and the underlying deposit was liquidated, checks +// for BTC receive address availability for each operator. If BTC receive +// addresses are available, creates, signs, and broadcasts a Bitcoin +// transaction splitting the BTC held by the keep into thirds, each third +// going to its respective operator's designated BTC receive address. For BTC +// non-liquidations, checks for BTC refund address availability. If a refund +// address is available, creates, signs, and broadcasts (unless in dry-run +// mode) a Bitcoin transaction sending the BTC held by the keep to the refund +// address. +// +// Operator BTC receive addresses must be in JSON format signed by the +// operator, staker, or beneficiary. BTC refund addresses must be signed by +// the +// //// +import PapaParse from "papaparse" +import { promises } from "fs" +const { readdir, stat, readFile } = promises + +import Subproviders from "@0x/subproviders" +import Web3 from "web3" +import ProviderEngine from "web3-provider-engine" +import WebsocketSubprovider from "web3-provider-engine/subproviders/websocket.js" +/** @typedef { import('../src/EthereumHelpers.js').TruffleArtifact } TruffleArtifact */ + +// @ts-ignore This lib is built all sorts of poorly for imports. +import xpubLib from "@swan-bitcoin/xpub-lib" +// @ts-ignore This lib is built all sorts of poorly for typing. +const { getExtPubKeyMetadata, addressFromExtPubKey } = xpubLib + +import TokenStakingJSON from "@keep-network/keep-core/artifacts/TokenStaking.json" +import BondedECDSAKeepJSON from "@keep-network/keep-ecdsa/artifacts/BondedECDSAKeep.json" +import TBTCSystemJSON from "@keep-network/tbtc/artifacts/TBTCSystem.json" +import TBTCConstantsJSON from "@keep-network/tbtc/artifacts/TBTCConstants.json" +import TBTCDepositTokenJSON from "@keep-network/tbtc/artifacts/TBTCDepositToken.json" + +import { + findAndConsumeArgsExistence, + findAndConsumeArgsValues +} from "./helpers.js" +import { spawn } from "child_process" +import { EthereumHelpers } from "../index.js" +import BitcoinHelpers from "../src/BitcoinHelpers.js" +import AvailableBitcoinConfigs from "./config.json" +import { contractsFromWeb3, lookupOwner } from "./owner-lookup.js" +import { + computeSighash, + constructSignedTransaction +} from "./commands/bitcoin.js" +import web3Utils from "web3-utils" +const { toBN } = web3Utils + +let args = process.argv.slice(2) +if (process.argv[0].includes("refunds.js")) { + args = process.argv.slice(1) // invoked directly, no node +} + +// No debugging unless explicitly enabled. +const { + found: { debug }, + remaining: flagArgs +} = findAndConsumeArgsExistence(args, "--debug") +if (!debug) { + console.debug = () => {} +} +const { + found: { + mnemonic, + /* account,*/ rpc, + f: transactionFee, + c: keepEcdsaClientPath, + s: keyShareDirectory, + o: beneficiaryDirectory, + m: misfundDirectory + }, + remaining: booleanArgs +} = findAndConsumeArgsValues( + flagArgs, + "--mnemonic", + "--account", + "--rpc", + "-f", + "-c", + "-s", + "-o", + "-m" +) + +const { + found: { y: dryRunShort, dryRun: dryRunLong }, + remaining: commandArgs +} = findAndConsumeArgsExistence(booleanArgs, "-y", "--dry-run") + +const dryRun = dryRunLong || dryRunShort + +const engine = new ProviderEngine({ pollingInterval: 1000 }) + +engine.addProvider( + // For address 0x420ae5d973e58bc39822d9457bf8a02f127ed473. + new Subproviders.PrivateKeyWalletSubprovider( + mnemonic || + "b6252e08d7a11ab15a4181774fdd58689b9892fe9fb07ab4f026df9791966990" + ) +) +engine.addProvider( + new WebsocketSubprovider({ + rpcUrl: + rpc || "wss://mainnet.infura.io/ws/v3/414a548bc7434bbfb7a135b694b15aa4", + debug, + origin: undefined + }) +) + +if (commandArgs.length !== 1) { + console.error(`Only one CSV file is supported, got '${commandArgs}'.`) + process.exit(1) +} + +const [infoCsv] = commandArgs + +const web3 = new Web3(engine) +engine.start() + +/** + * @param {string} keepAddress, + * @param {string} digest + * @return {Promise<{ signature: string, publicKey: string }>} + */ +function signDigest(keepAddress, digest) { + const keepDirectory = (keyShareDirectory || "key-shares") + "/" + keepAddress + return new Promise((resolve, reject) => { + let output = "" + let allOutput = "" + let outputFinished = false + const process = spawn(keepEcdsaClientPath || "keep-ecdsa", [ + "signing", + "sign-digest", + digest, + keepDirectory + ]) + process.stdout.setEncoding("utf8") + process.stdout.on("data", chunk => { + output += chunk + allOutput += chunk + }) + process.stderr.on("data", chunk => { + allOutput += chunk + }) + process.stdout.on("end", () => (outputFinished = true)) + + process + .on("exit", (code, signal) => { + if (code === 0) { + const processOutput = () => { + const [publicKey, signature] = output.split("\t") + + if (publicKey && signature) { + resolve({ + signature: signature.trim(), + publicKey: publicKey.trim() + }) + } else { + reject(new Error(`Unexpected output:\n${allOutput}`)) + } + } + if (outputFinished) { + processOutput() + } else { + process.stdout.on("close", processOutput) + } + } else { + reject( + new Error( + `Process exited abnormally with signal ${signal} and code ${code}\n` + + allOutput + ) + ) + } + }) + .on("error", error => { + reject(allOutput || error) + }) + }) +} + +/** + * @param {string} keepAddress + */ +async function validateKeyShares(keepAddress) { + const keepDirectory = (keyShareDirectory || "key-shares") + "/" + keepAddress + + if ((await stat(keepDirectory)).isDirectory()) { + const directoryContents = await readdir(keepDirectory) + + // Below, use semicolons instead of commas since CSV is our output type. + if (directoryContents.length > 3) { + return `too many key shares: ${directoryContents.join(";")}.` + } else if (directoryContents.length < 3) { + const keepContract = keepAt(keepAddress) + /** @type {string[]} */ + const operators = await keepContract.methods.getMembers().call() + const directoryOperators = directoryContents + .map(_ => + _.replace(/share-/, "") + .replace(/.dat$/, "") + .toLowerCase() + ) + .sort() + const seenOperators = new Set(directoryOperators) + const unseenOperators = operators.filter( + _ => !seenOperators.has(_.toLowerCase()) + ) + + return `not enough key shares---missing: ${unseenOperators.join("; ")}.` + } else if ((await signDigest(keepAddress, "deadbeef")) === null) { + return "unknown key share signing error" + } else { + // All is well! + return null + } + } else { + return "no key share directory" + } +} +/** @type {import("../src/EthereumHelpers.js").Contract} */ +let baseKeepContract +/** @type {import("../src/EthereumHelpers.js").Contract} */ +let tokenStakingContract +/** @type {Promise} */ +let tbtcSystemContract +/** @type {Promise} */ +let tbtcConstantsContract +/** @type {Promise} */ +let tbtcDepositTokenContract +/** @type {number} */ +let startingBlock + +function keepAt(/** @type {string} */ keepAddress) { + baseKeepContract = + baseKeepContract || + EthereumHelpers.buildContract( + web3, + /** @type {TruffleArtifact} */ (BondedECDSAKeepJSON).abi + ) + + const requestedKeep = /** @type {typeof baseKeepContract} */ (baseKeepContract.clone()) + requestedKeep.options.address = keepAddress + return requestedKeep +} + +async function tokenStaking() { + tokenStakingContract = + tokenStakingContract || + (await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TokenStakingJSON), + web3, + "1" + )) + + return tokenStakingContract +} + +function tbtcSystem() { + tbtcSystemContract = + tbtcSystemContract || + EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TBTCSystemJSON), + web3, + "1" + ) + + return tbtcSystemContract +} + +function tbtcConstants() { + tbtcConstantsContract = + tbtcConstantsContract || + EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TBTCConstantsJSON), + web3, + "1" + ) + + return tbtcConstantsContract +} + +function tbtcDepositToken() { + tbtcDepositTokenContract = + tbtcDepositTokenContract || + EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TBTCDepositTokenJSON), + web3, + "1" + ) + + return tbtcDepositTokenContract +} + +async function referenceBlock() { + startingBlock = startingBlock || (await web3.eth.getBlockNumber()) + return startingBlock +} + +/** + * @type {{ [operatorAddress: string]: { beneficiary?: string?, owner?: string? }}} + */ +const delegationInfoCache = {} + +async function beneficiaryOf(/** @type {string} */ operatorAddress) { + if ( + delegationInfoCache[operatorAddress] && + delegationInfoCache[operatorAddress].beneficiary + ) { + return delegationInfoCache[operatorAddress].beneficiary + } + + const beneficiary = (await tokenStaking()).methods + .beneficiaryOf(operatorAddress) + .call() + delegationInfoCache[operatorAddress] = + delegationInfoCache[operatorAddress] || {} + delegationInfoCache[operatorAddress].beneficiary = beneficiary + + return beneficiary +} + +async function deepOwnerOf(/** @type {string} */ operatorAddress) { + if ( + delegationInfoCache[operatorAddress] && + delegationInfoCache[operatorAddress].owner + ) { + return delegationInfoCache[operatorAddress].owner + } + + const owner = lookupOwner( + web3, + await contractsFromWeb3(web3), + operatorAddress + ) + delegationInfoCache[operatorAddress] = + delegationInfoCache[operatorAddress] || {} + delegationInfoCache[operatorAddress].owner = owner + + return owner +} + +/** + * @param {string} keepAddress + */ +async function keepStatusCompleted(keepAddress) { + const block = await referenceBlock() + const keepContract = keepAt(keepAddress) + + if (await keepContract.methods.isClosed().call({}, block)) { + return "closed" + } else if (await keepContract.methods.isTerminated().call({}, block)) { + return "terminated" + } else { + return null + } +} + +/** + * @param {string} keepAddress + */ +async function keepHoldsBtc(keepAddress) { + const keepContract = keepAt(keepAddress) + const pubkey = await keepContract.methods.getPublicKey().call() + + const bitcoinAddress = BitcoinHelpers.Address.publicKeyToP2WPKHAddress( + pubkey.replace(/0x/, ""), + BitcoinHelpers.Network.MAINNET + ) + + const btcBalance = await BitcoinHelpers.Transaction.getBalance(bitcoinAddress) + + return { bitcoinAddress, btcBalance } +} + +/** @type {{[operatorAddress: string]: { beneficiary: string, latestIndex: number}} */ +const operatorBeneficiaries = {} + +async function generateAddress( + /** @type {string} */ beneficiary, + /** @type {number} */ latestIndex +) { + if (!beneficiary.match(/^.pub/)) { + return { latestAddress: beneficiary, latestIndex } // standard address, always returns itself + } + + const metadata = getExtPubKeyMetadata(beneficiary) + let latestAddress = "" + let addressIndex = latestIndex + do { + const derivedAddressInfo = addressFromExtPubKey({ + extPubKey: beneficiary, + keyIndex: addressIndex, + purpose: metadata.type, + network: metadata.network + }) + latestAddress = derivedAddressInfo.address + // TODO Store address index? + addressIndex++ + } while (await BitcoinHelpers.Transaction.find(latestAddress, 0)) + + return { latestAddress, latestIndex: addressIndex } +} + +/** + * @param {string} operatorAddress + */ +async function readBeneficiary(operatorAddress) { + let beneficiaryInfo = operatorBeneficiaries[operatorAddress] + if (!beneficiaryInfo) { + const beneficiaryFile = + (beneficiaryDirectory || "beneficiaries") + + "/beneficiary-" + + operatorAddress.toLowerCase() + + ".json" + + // If it doesn't exist, return empty. + try { + if (!(await stat(beneficiaryFile)).isFile()) { + return null + } + } catch (e) { + return null + } + + /** @type {{msg: string, sig: string, address: string}} */ + const jsonContents = JSON.parse( + await readFile(beneficiaryFile, { encoding: "utf-8" }) + ) + + if (!jsonContents.msg || !jsonContents.sig || !jsonContents.address) { + throw new Error( + `Invalid format for ${operatorAddress}: message, signature, or signing address missing.` + ) + } + + // Force a 0x prefix so `recover` works correctly. Some tools omit it from + // the sig field. + if (!jsonContents.sig.startsWith("0x")) { + jsonContents.sig = "0x" + jsonContents.sig + } + + const recoveredAddress = web3.eth.accounts.recover( + jsonContents.msg, + jsonContents.sig + ) + if (recoveredAddress.toLowerCase() !== jsonContents.address.toLowerCase()) { + throw new Error( + `Recovered address does not match signing address for ${operatorAddress}.` + ) + } + + if ( + recoveredAddress.toLowerCase() !== + (await beneficiaryOf(operatorAddress)).toLowerCase() && + recoveredAddress.toLowerCase() !== + (await deepOwnerOf(operatorAddress)).toLowerCase() && + recoveredAddress.toLowerCase() !== operatorAddress + ) { + throw new Error( + `Beneficiary address for ${operatorAddress} was not signed by operator owner or beneficiary.` + ) + } + + const pubs = [...jsonContents.msg.matchAll(/[xyz]pub[a-zA-Z0-9]+/g)].map( + _ => _[0] + ) + if (pubs.length > 1 && pubs.slice(1).some(_ => _ !== pubs[0])) { + throw new Error( + `Beneficiary message for ${operatorAddress} includes too many *pubs: ${pubs}` + ) + } else if (pubs.length !== 0) { + beneficiaryInfo = { + beneficiary: pubs[0], + latestIndex: 0 + } + } else { + const addresses = [ + ...jsonContents.msg.matchAll( + /(?:1|3)[A-Za-z0-9]{25,34}|bc1[0-9a-z]{11,71}/g + ) + ].map(_ => _[0]) + if (addresses.length > 1) { + throw new Error( + `Beneficiary message for ${operatorAddress} includes too many addresses: ${addresses}` + ) + } else if (addresses.length !== 0) { + beneficiaryInfo = { + beneficiary: addresses[0], + latestIndex: 0 + } + } + } + + if (!beneficiaryInfo) { + throw new Error( + `Could not find a valid BTC address or *pub in signed message for ${operatorAddress}: ` + + `${jsonContents.msg}` + ) + } + } + + const { latestAddress, latestIndex } = await generateAddress( + beneficiaryInfo.beneficiary, + beneficiaryInfo.latestIndex + ) + + beneficiaryInfo.latestIndex = latestIndex + operatorBeneficiaries[operatorAddress] = beneficiaryInfo + + return latestAddress +} + +async function readRefundAddress(/** @type {string} */ depositAddress) { + const addressFile = + (misfundDirectory || "misfunds") + + "/misfund-" + + depositAddress.toLowerCase() + + ".json" + + // If it doesn't exist, return empty. + try { + if (!(await stat(addressFile)).isFile()) { + return null + } + } catch (e) { + return null + } + + /** @type {{msg: string, sig: string, address: string}} */ + const jsonContents = JSON.parse( + await readFile(addressFile, { encoding: "utf-8" }) + ) + + if (!jsonContents.msg || !jsonContents.sig || !jsonContents.address) { + throw new Error( + `Invalid format for signed address: message, signature, or signing address missing.` + ) + } + + const recoveredAddress = web3.eth.accounts.recover( + jsonContents.msg, + jsonContents.sig + ) + if (recoveredAddress.toLowerCase() !== jsonContents.address.toLowerCase()) { + throw new Error(`Recovered address does not match signing address.`) + } + + const depositOwner = await (await tbtcDepositToken()).methods + .ownerOf(depositAddress) + .call() + + if (recoveredAddress.toLowerCase() !== depositOwner.toLowerCase()) { + throw new Error(`Refund address was not signed by deposit owner.`) + } + + const addresses = [ + ...jsonContents.msg.matchAll( + /(?:1|3)[A-Za-z0-9]{25,34}|bc1[0-9a-z]{11,71}/g + ) + ].map(_ => _[0]) + if (addresses.length > 1) { + throw new Error(`Refund message includes too many addresses: ${addresses}`) + } else if (addresses.length === 1) { + return addresses[0] + } + + throw new Error( + `Could not find a valid BTC address in signed message: ` + + `${jsonContents.msg}` + ) +} + +async function beneficiariesAvailableAndSigned( + /** @type {string} */ keepAddress +) { + const keepContract = keepAt(keepAddress) + + const operators = /** @type {[string,string,string]} */ ([ + ...(await keepContract.methods.getMembers().call()) + ]) + .map(_ => _.toLowerCase()) + // Sort operators so beneficiaries are in a stable order for analysis. + .sort() + try { + const beneficiaries = await Promise.all(operators.map(readBeneficiary)) + const unavailableBeneficiaries = beneficiaries + .map((beneficiary, i) => { + if (beneficiary === null) { + return operators[i] + } else { + return null + } + }) + .filter(_ => _ !== null) + + if (unavailableBeneficiaries.length > 0) { + return { + error: `not all beneficiaries are available (missing ${unavailableBeneficiaries.join( + "; " + )})` + } + } + + return { + beneficiary1: beneficiaries[0], + beneficiary2: beneficiaries[1], + beneficiary3: beneficiaries[2] + } + } catch (e) { + return { error: `beneficiary lookup failed: ${e}` } + } +} + +async function buildAndBroadcastLiquidationSplit(/** @type {any} */ keepData) { + const { + /** @type {string} */ keep: keepAddress, + /** @type {string} */ bitcoinAddress, + /** @type {number} */ btcBalance, + /** @type {string} */ beneficiary1, + /** @type {string} */ beneficiary2, + /** @type {string} */ beneficiary3, + /** @type {string} */ fundingTransactionID, + /** @type {number} */ fundingTransactionPosition + } = keepData + + const fee = + parseInt(transactionFee || "0") || + toBN(await (await tbtcConstants()).methods.getMinimumRedemptionFee().call()) + .muln(15) + .toNumber() + + // Math this out in BN-land to minimize the likelihood of precision issues. + const refundAmount = toBN(btcBalance).subn(fee) + const perBeneficiaryAmount = refundAmount.divn(3).toNumber() + const sighashToSign = computeSighash( + { + transactionID: fundingTransactionID, + index: fundingTransactionPosition + }, + btcBalance, + bitcoinAddress, + { value: perBeneficiaryAmount, address: beneficiary1 }, + { value: perBeneficiaryAmount, address: beneficiary2 }, + { value: perBeneficiaryAmount, address: beneficiary3 } + ) + + try { + const { signature, publicKey } = await signDigest( + keepAddress, + sighashToSign.toString("hex") + ) + const signedTransaction = constructSignedTransaction( + { + transactionID: fundingTransactionID, + index: fundingTransactionPosition + }, + signature, + publicKey, + { value: perBeneficiaryAmount, address: beneficiary1 }, + { value: perBeneficiaryAmount, address: beneficiary2 }, + { value: perBeneficiaryAmount, address: beneficiary3 } + ) + + const { transactionID } = dryRun + ? { transactionID: "dry run: transaction not broadcast" } + : await BitcoinHelpers.Transaction.broadcast(signedTransaction) + + return { + perBeneficiaryAmount, + signature, + publicKey, + transactionID, + signedTransaction + } + } catch (e) { + return { refundAmount, error: `Error signing: ${e}` } + } +} + +async function misfundRecipientAvailableAndSigned( + /** @type {string} */ keepAddress +) { + // Find associated deposit. + const { + returnValues: { _depositContractAddress: depositAddress } + } = await EthereumHelpers.getExistingEvent(await tbtcSystem(), "Created", { + _keepAddress: keepAddress + }) + + // find owner + // check for beneficiary info + /** @type {[string,string,string]} */ + try { + const refundAddress = await readRefundAddress(depositAddress) + + if (!refundAddress) { + return { + error: `refund address missing` + } + } + + return { + recipientAddress: refundAddress + } + } catch (e) { + return { error: `refund address lookup failed: ${e}` } + } +} + +async function findFundingInfo(/** @type {string} */ bitcoinAddress) { + const unspent = await BitcoinHelpers.Transaction.findAllUnspent( + bitcoinAddress + ) + + if (unspent.length > 1) { + return { error: "Multiple unspent outputs, manual intervention required." } + } else if (unspent.length == 0) { + return { error: "No unspent outputs." } + } + + const { transactionID, outputPosition } = unspent[0] + + return { + fundingTransactionID: transactionID, + fundingTransactionPosition: outputPosition + } +} + +async function buildAndBroadcastRefund(/** @type {any} */ keepData) { + const { + /** @type {string} */ keep: keepAddress, + /** @type {string} */ bitcoinAddress, + /** @type {number} */ btcBalance, + /** @type {string} */ recipientAddress, + /** @type {string} */ fundingTransactionID, + /** @type {number} */ fundingTransactionPosition + } = keepData + + const fee = + parseInt(transactionFee || "0") || + toBN(await (await tbtcConstants()).methods.getMinimumRedemptionFee().call()) + .muln(18) + .muln(5) + .toNumber() + + const refundAmount = btcBalance - fee + const sighashToSign = computeSighash( + { + transactionID: fundingTransactionID, + index: fundingTransactionPosition + }, + btcBalance, + bitcoinAddress, + { value: refundAmount, address: recipientAddress } + ) + + try { + const { signature, publicKey } = await signDigest( + keepAddress, + sighashToSign.toString("hex") + ) + const signedTransaction = constructSignedTransaction( + { + transactionID: fundingTransactionID, + index: fundingTransactionPosition + }, + signature, + publicKey, + { + value: refundAmount, + address: recipientAddress + } + ) + + const { transactionID } = dryRun + ? { transactionID: "dry run: transaction not broadcast" } + : await BitcoinHelpers.Transaction.broadcast(signedTransaction) + + return { + refundAmount, + signature, + publicKey, + transactionID, + signedTransaction + } + } catch (e) { + return { refundAmount, error: `Error signing: ${e}` } + } +} + +async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { + const keeps = keepRows + .filter(_ => _.keep) // strip any weird undefined lines + .reduce((keepSet, { keep }) => keepSet.add(keep), new Set()) + const results = Array.from(keeps).reduce(async (previousRows, keep) => { + // We need to wait for previous work to settle so that beneficiary address + // generation runs serially rather than in parallel. + const rows = await previousRows + + // Contract for processors is they take the row data and return updated row + // data; if the updated row data includes an `error` key, subsequent + // processors don't run. + const genericStatusProcessors = [ + async (/** @type {any} */ row) => { + const status = await keepStatusCompleted(row.keep) + + if (status) { + return { ...row, status } + } else { + return { ...row, error: "no status" } + } + }, + async (/** @type {any} */ row) => { + const balanceData = await keepHoldsBtc(row.keep) + + if (balanceData && balanceData.btcBalance > 0) { + return { ...row, ...balanceData } + } else { + return { ...row, error: "no BTC" } + } + }, + async (/** @type {any} */ row) => { + const validationError = await validateKeyShares(row.keep) + + if (validationError) { + return { + ...row, + error: validationError + } + } else { + return row + } + } + ] + const liquidationProcessors = [ + async (/** @type {any} */ row) => { + const beneficiaries = await beneficiariesAvailableAndSigned(row.keep) + + if (beneficiaries) { + return { ...row, ...beneficiaries } + } else { + return { + ...row, + error: "no beneficiary" + } + } + }, + async (/** @type {any} */ row) => { + const fundingInfo = await findFundingInfo(row.bitcoinAddress) + + if (fundingInfo) { + return { ...row, ...fundingInfo } + } else { + return { + ...row, + error: "failed to find funding info for keep" + } + } + }, + async (/** @type {any} */ row) => { + const transactionData = await buildAndBroadcastLiquidationSplit(row) + + if (transactionData) { + return { ...row, ...transactionData } + } else { + return { + ...row, + error: + "failed to build and broadcast liquidation split BTC transaction" + } + } + } + ] + const misfundProcessors = [ + // - Check for BTC refund address availability for the keep's deposit. + // - If available, verify that the BTC refund address is correctly signed by + // the owner of the keep's deposit. + // (= await web3.eth.personal.ecRecover(address.msg, address.sig) == address.account && + // deposit.includes(message.account)) + // - If yes, build, sign, and broadcast refund transaction. + async (/** @type {any} */ row) => { + const misfunderInfo = await misfundRecipientAvailableAndSigned(row.keep) + + if (misfunderInfo) { + return { ...row, ...misfunderInfo } + } else { + return { + ...row, + error: "no misfunder" + } + } + }, + async (/** @type {any} */ row) => { + const fundingInfo = await findFundingInfo(row.bitcoinAddress) + + if (fundingInfo) { + return { ...row, ...fundingInfo } + } else { + return { + ...row, + error: "failed to find funding info for keep" + } + } + }, + async (/** @type {any} */ row) => { + const transactionData = await buildAndBroadcastRefund(row) + + if (transactionData) { + return { ...row, ...transactionData } + } else { + return { + ...row, + error: "failed to build and broadcast refund BTC transaction" + } + } + } + ] + + const processThrough = async ( + /** @type {any} */ inputData, + /** @type {(function(any):Promise)[]} */ processors + ) => { + return await processors.reduce(async (rowPromise, process) => { + const row = await rowPromise + try { + if (!row.error) { + return await process(row) + } else { + return row + } + } catch (e) { + return Promise.resolve({ + ...row, + error: `Error processing transaction: ${e}` + }) + } + }, Promise.resolve(inputData)) + } + + const basicInfo = await processThrough({ keep }, genericStatusProcessors) + + if (basicInfo.status == "terminated") { + return rows.concat([ + await processThrough(basicInfo, liquidationProcessors) + ]) + } else if (basicInfo.status == "closed") { + return rows.concat([await processThrough(basicInfo, misfundProcessors)]) + } else { + return rows.concat([basicInfo]) + } + }, []) + + return results +} + +run(() => { + return new Promise(async (resolve, reject) => { + try { + PapaParse.parse(await readFile(infoCsv, "utf8"), { + header: true, + transformHeader: header => { + const unspaced = header.trim().replace(/ /g, "") + return unspaced[0].toLowerCase() + unspaced.slice(1) + }, + complete: ({ data }) => { + // @ts-ignore No really, this is a valid config. + BitcoinHelpers.electrumConfig = AvailableBitcoinConfigs["1"].electrum + BitcoinHelpers.withElectrumClient(() => + processKeeps(data).then(keepRows => { + const allKeys = Array.from(new Set(keepRows.flatMap(Object.keys))) + + const arrayRows = keepRows.map(row => + allKeys.map(key => row[key]) + ) + resolve( + [allKeys] + .concat(arrayRows) + .map(_ => _.join(",")) + .join("\n") + ) + }) + ) + } + }) + } catch (err) { + reject(err) + } + }) +}) + +/** + * @param {function():Promise} action Command action that will yield a + * promise to the desired CLI output or error out by failing the promise. + * A null or undefined output means no output should be emitted, but the + * command should exit successfully. + */ +function run(action) { + action() + .catch(error => { + console.error("Got error", error) + process.exit(2) + }) + .then((/** @type {string} */ result) => { + if (result) { + console.log(result) + } + process.exit(0) + }) +} diff --git a/jsconfig.json b/jsconfig.json index 914c99a1..0bb119ac 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -8,6 +8,10 @@ "esModuleInterop": true, "noImplicitAny": true, "strictNullChecks": true, - "checkJs": true + "checkJs": true, + "lib": [ + "es2019", + "es2020.string" + ] } } diff --git a/package-lock.json b/package-lock.json index fcd476b7..144bdfaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -386,6 +386,24 @@ "semver": "^5.5.1" } }, + "@babel/polyfill": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz", + "integrity": "sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g==", + "dev": true, + "requires": { + "core-js": "^2.6.5", + "regenerator-runtime": "^0.13.4" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, "@babel/runtime": { "version": "7.11.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz", @@ -1516,6 +1534,16 @@ } } }, + "@swan-bitcoin/xpub-lib": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@swan-bitcoin/xpub-lib/-/xpub-lib-0.1.0.tgz", + "integrity": "sha512-4fF7hVTqNmxhu97tnR82kHjEuIzjhjIPTRFMzgCzH/YpduMuw3R7SLKZDQ6DhpEqbyaxS0NI5r7JbZjfXGjA0Q==", + "dev": true, + "requires": { + "bitcoinjs-lib": "^5.2.0", + "unchained-bitcoin": "0.0.15" + } + }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", @@ -3174,6 +3202,12 @@ "loady": "~0.0.5" } }, + "bech32": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", + "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", + "dev": true + }, "bfilter": { "version": "git+https://github.com/keep-network/bfilter.git#c6695f05eb94026dc5dee8274d8b978d334d344f", "from": "git+https://github.com/keep-network/bfilter.git#c6695f05eb94026dc5dee8274d8b978d334d344f", @@ -3219,6 +3253,12 @@ "file-uri-to-path": "1.0.0" } }, + "bip174": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.0.1.tgz", + "integrity": "sha512-i3X26uKJOkDTAalYAp0Er+qGMDhrbbh2o93/xiPyAN2s25KrClSpe3VXo/7mNJoqA5qfko8rLS2l3RWZgYmjKQ==", + "dev": true + }, "bip32": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/bip32/-/bip32-2.0.5.tgz", @@ -3266,6 +3306,58 @@ "safe-buffer": "^5.0.1" } }, + "bitcoin-address-validation": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/bitcoin-address-validation/-/bitcoin-address-validation-0.2.9.tgz", + "integrity": "sha512-47/XSK0yCA5Ivbt0YK5wCXm82TJWQRfkEiVRQScug5DNvmLCLeUekY6gwtH4dr7Ms2m13Nktq6/dhvsjdut0xg==", + "dev": true, + "requires": { + "base-x": "^3.0.6", + "bech32": "^1.1.3", + "hash.js": "^1.1.7" + }, + "dependencies": { + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + } + } + }, + "bitcoin-ops": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/bitcoin-ops/-/bitcoin-ops-1.4.1.tgz", + "integrity": "sha512-pef6gxZFztEhaE9RY9HmWVmiIHqCb2OyS4HPKkpc6CIiiOa3Qmuoylxc5P2EkU3w+5eTSifI9SEZC88idAIGow==", + "dev": true + }, + "bitcoinjs-lib": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-5.2.0.tgz", + "integrity": "sha512-5DcLxGUDejgNBYcieMIUfjORtUeNWl828VWLHJGVKZCb4zIS1oOySTUr0LGmcqJBQgTBz3bGbRQla4FgrdQEIQ==", + "dev": true, + "requires": { + "bech32": "^1.1.2", + "bip174": "^2.0.1", + "bip32": "^2.0.4", + "bip66": "^1.1.0", + "bitcoin-ops": "^1.4.0", + "bs58check": "^2.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.3", + "merkle-lib": "^2.0.10", + "pushdata-bitcoin": "^1.0.1", + "randombytes": "^2.0.1", + "tiny-secp256k1": "^1.1.1", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.0.4", + "wif": "^2.0.1" + } + }, "bl": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", @@ -21678,6 +21770,12 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, + "merkle-lib": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/merkle-lib/-/merkle-lib-2.0.10.tgz", + "integrity": "sha1-grjbrnXieneFOItz+ddyXQ9vMyY=", + "dev": true + }, "merkle-patricia-tree": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/merkle-patricia-tree/-/merkle-patricia-tree-2.3.2.tgz", @@ -21986,6 +22084,12 @@ "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-4.13.0.tgz", "integrity": "sha512-DD0vOdofJdoaRNtnWcrXe6RQbpHkPPmtqGq14uRX0F8ZKJ5nv89CVTYl/BZdppDxBDaV0hl75htg3abpEWlPZA==" }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "dev": true + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -22508,6 +22612,12 @@ } } }, + "papaparse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.2.0.tgz", + "integrity": "sha512-ylq1wgUSnagU+MKQtNeVqrPhZuMYBvOSL00DHycFTCxownF95gpLAk1HiHdUW77N8yxRq1qHXLdlIPyBSG9NSA==", + "dev": true + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -22898,6 +23008,15 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, + "pushdata-bitcoin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pushdata-bitcoin/-/pushdata-bitcoin-1.0.1.tgz", + "integrity": "sha1-FZMdPNlnreUiBvUjqnMxrvfUOvc=", + "dev": true, + "requires": { + "bitcoin-ops": "^1.3.0" + } + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", @@ -24623,6 +24742,69 @@ "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", "dev": true }, + "unchained-bitcoin": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/unchained-bitcoin/-/unchained-bitcoin-0.0.15.tgz", + "integrity": "sha512-IubzpcTT4spAV5uZ7bcpT50qNBKbda8epu2zilEvEBerUQNKXEidUIdccSNVYwabu4d04VkXK+3vyMX02Ilw+g==", + "dev": true, + "requires": { + "@babel/polyfill": "^7.7.0", + "bignumber.js": "^8.1.1", + "bip32": "^2.0.5", + "bitcoin-address-validation": "^0.2.9", + "bitcoinjs-lib": "^4.0.5", + "bs58check": "^2.1.2", + "bufio": "^1.0.6", + "core-js": "^2.6.10" + }, + "dependencies": { + "bignumber.js": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-8.1.1.tgz", + "integrity": "sha512-QD46ppGintwPGuL1KqmwhR0O+N2cZUg8JG/VzwI2e28sM9TqHjQB10lI4QAaMHVbLzwVLLAwEglpKPViWX+5NQ==", + "dev": true + }, + "bitcoinjs-lib": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-4.0.5.tgz", + "integrity": "sha512-gYs7K2hiY4Xb96J8AIF+Rx+hqbwjVlp5Zt6L6AnHOdzfe/2tODdmDxsEytnaxVCdhOUg0JnsGpl+KowBpGLxtA==", + "dev": true, + "requires": { + "bech32": "^1.1.2", + "bip32": "^1.0.4", + "bip66": "^1.1.0", + "bitcoin-ops": "^1.4.0", + "bs58check": "^2.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.3", + "merkle-lib": "^2.0.10", + "pushdata-bitcoin": "^1.0.1", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.1", + "tiny-secp256k1": "^1.0.0", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.0.4", + "wif": "^2.0.1" + }, + "dependencies": { + "bip32": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-1.0.4.tgz", + "integrity": "sha512-8T21eLWylZETolyqCPgia+MNp+kY37zFr7PTFDTPObHeNi9JlfG4qGIh8WzerIJidtwoK+NsWq2I5i66YfHoIw==", + "dev": true, + "requires": { + "bs58check": "^2.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "tiny-secp256k1": "^1.0.0", + "typeforce": "^1.11.5", + "wif": "^2.0.6" + } + } + } + } + } + }, "underscore": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz", @@ -24883,6 +25065,15 @@ "integrity": "sha1-2Ca4n3SQcy+rwMDtaT7Uddyynr8=", "dev": true }, + "varuint-bitcoin": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz", + "integrity": "sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.1" + } + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index bdc65e2c..3a8e6fdc 100644 --- a/package.json +++ b/package.json @@ -49,11 +49,13 @@ "chai": "^4.2.0", "eslint": "^6.8.0", "eslint-config-keep": "git+https://github.com/keep-network/eslint-config-keep.git#0.3.0", - "fs": "0.0.1-security", "mocha": "^6.2.0", + "moment": "2.29.1", "prettier": "^1.19.1", "typescript": "3.4.3", "web3": "^1.2.11", - "web3-provider-engine": "^15.0.7" + "web3-provider-engine": "^15.0.7", + "papaparse": "5.2.0", + "@swan-bitcoin/xpub-lib": "0.1.0" } } diff --git a/src/BitcoinHelpers.js b/src/BitcoinHelpers.js index d58696a1..f3f2492c 100644 --- a/src/BitcoinHelpers.js +++ b/src/BitcoinHelpers.js @@ -92,6 +92,10 @@ const BitcoinHelpers = { /** @type {ElectrumConfig?} */ electrumConfig: null, + /** @type {Promise?} */ + electrumClient: null, + electrumClientUsages: 0, + /** * Updates the config to use for Electrum client connections. Electrum is * the core mechanism used to interact with the Bitcoin blockchain. @@ -290,19 +294,36 @@ const BitcoinHelpers = { throw new Error("Electrum client not configured.") } - const electrumClient = new ElectrumClient(BitcoinHelpers.electrumConfig) + if (BitcoinHelpers.electrumClient === null) { + BitcoinHelpers.electrumClient = new Promise(async (resolve, reject) => { + const client = new ElectrumClient( + // @ts-ignore Electrum config can't be null due to check at top of + // function. + BitcoinHelpers.electrumConfig + ) - await electrumClient.connect() + try { + await backoffRetrier(10)(() => client.connect()) + resolve(client) + } catch (error) { + reject(error) + } + }) + } - const result = block(electrumClient) - result.then( - () => { - electrumClient.close() - }, - () => { - electrumClient.close() + const electrumClient = await BitcoinHelpers.electrumClient + BitcoinHelpers.electrumClientUsages++ + const cleanup = async () => { + BitcoinHelpers.electrumClientUsages-- + if (BitcoinHelpers.electrumClientUsages == 0) { + await electrumClient.close() + BitcoinHelpers.electrumClient = null } - ) + } + + const result = block(electrumClient) + // Ensure we clean up the connection once all is said and done. + result.then(cleanup, cleanup) return result }, @@ -661,6 +682,56 @@ const BitcoinHelpers = { return transaction.toRaw().toString("hex") }, + /** + * Constructs a Bitcoin SegWit transaction with one input and many outputs. + * Difference between all outputs values summed and previous output value + * will be taken as a transaction fee. + * + * @param {string} previousOutpoint Previous transaction's output to be + * used as an input. Provided in hexadecimal format, consists of + * 32-byte transaction ID and 4-byte output index number. + * @param {number} inputSequence Input's sequence number. As per + * BIP-125 the value is used to indicate that transaction should + * be able to be replaced in the future. If input sequence is set + * to `0xffffffff` the transaction won't be replaceable. + * @param {number} outputValue Value for the output. + * @param {{value: number, script: string}[]} outputDetails Outputs for the + * transaction as a combination of values and output scripts as unprefixed + * hexadecimal strings. + * + * @return {string} Raw bitcoin transaction in hexadecimal format. + */ + constructOneInputWitnessTransaction( + previousOutpoint, + inputSequence, + ...outputDetails + ) { + // Input + const prevOutpoint = Outpoint.fromRaw( + Buffer.from(previousOutpoint, "hex") + ) + + const input = Input.fromOptions({ + prevout: prevOutpoint, + sequence: inputSequence + }) + + // Outputs + const outputs = outputDetails.map(({ value, script }) => + Output.fromOptions({ + value: value, + script: Buffer.from(script, "hex") + }) + ) + + // Transaction + const transaction = TX.fromOptions({ + inputs: [input], + outputs: outputs + }) + + return transaction.toRaw().toString("hex") + }, /** * Finds all transactions containing unspent outputs received * by the `bitcoinAddress`. diff --git a/src/EthereumHelpers.js b/src/EthereumHelpers.js index c33b8b57..3714951a 100644 --- a/src/EthereumHelpers.js +++ b/src/EthereumHelpers.js @@ -152,14 +152,21 @@ function getEvent(sourceContract, eventName, filter, fromBlock) { * @param {any} [filter] An additional filter to apply to the event being * searched for. * @param {number} [fromBlock] Starting block for events search. + * @param {number} [toBlock] Ending block for events search. * * @return {Promise} A promise that will be fulfilled by the list of * event objects once they are found. */ -async function getExistingEvents(sourceContract, eventName, filter, fromBlock) { +async function getExistingEvents( + sourceContract, + eventName, + filter, + fromBlock, + toBlock +) { const events = await sourceContract.getPastEvents(eventName, { fromBlock: fromBlock || sourceContract.deployedAtBlock || 0, - toBlock: "latest", + toBlock: toBlock || "latest", filter }) @@ -177,13 +184,26 @@ async function getExistingEvents(sourceContract, eventName, filter, fromBlock) { * @param {any} [filter] An additional filter to apply to the event being * searched for. * @param {number} [fromBlock] Starting block for events search. + * @param {number} [toBlock] Ending block for events search. * * @return {Promise} A promise that will be fulfilled by the event object * once it is found. */ -async function getExistingEvent(sourceContract, eventName, filter, fromBlock) { +async function getExistingEvent( + sourceContract, + eventName, + filter, + fromBlock, + toBlock +) { return ( - await getExistingEvents(sourceContract, eventName, filter, fromBlock) + await getExistingEvents( + sourceContract, + eventName, + filter, + fromBlock, + toBlock + ) ).slice(-1)[0] } diff --git a/src/lib/ElectrumClient.js b/src/lib/ElectrumClient.js index 325420e5..95a0c1c0 100644 --- a/src/lib/ElectrumClient.js +++ b/src/lib/ElectrumClient.js @@ -77,7 +77,7 @@ export default class Client { * Establish connection with the server. */ async connect() { - console.log("Connecting to electrum server...") + console.debug("Connecting to electrum server...") await this.electrumClient.connect("tbtc", "1.4.2").catch(err => { throw new Error(`failed to connect: [${err}]`) @@ -88,7 +88,7 @@ export default class Client { * Disconnect from the server. */ async close() { - console.log("Closing connection to electrum server...") + console.debug("Closing connection to electrum server...") this.electrumClient.close() } @@ -249,7 +249,7 @@ export default class Client { const receivedScriptHash = msg[0] const status = msg[1] - console.log( + console.debug( `Received notification for script hash: [${receivedScriptHash}] with status: [${status}]` ) @@ -327,7 +327,7 @@ export default class Client { for (const msg of messages) { const height = msg.height - console.log( + console.debug( `Received notification of a new block at height: [${height}]` ) @@ -343,7 +343,7 @@ export default class Client { this.electrumClient.subscribe.on(eventName, listener) - console.log(`Registered listener for ${eventName} event`) + console.debug(`Registered listener for ${eventName} event`) } catch (err) { throw new Error(`failed listening for notification: ${err}`) }