From c666b58322ed7ff3a4330084b0e7c9e0abb48cad Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 19 Nov 2020 18:15:10 -0500 Subject: [PATCH 01/52] Ask TypeScript to accept ES2019 features like Array.flat() --- jsconfig.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/jsconfig.json b/jsconfig.json index 914c99a1..73840637 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -8,6 +8,9 @@ "esModuleInterop": true, "noImplicitAny": true, "strictNullChecks": true, - "checkJs": true + "checkJs": true, + "lib": [ + "es2019" + ] } } From 934842eecc4512b57b41f18340f92b6fbceec7d7 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 19 Nov 2020 18:15:28 -0500 Subject: [PATCH 02/52] Add moment for date handling This will be used in certain utilities, but is only included as a dev dependency. --- package-lock.json | 6 ++++++ package.json | 1 + 2 files changed, 7 insertions(+) diff --git a/package-lock.json b/package-lock.json index fcd476b7..70b15286 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21986,6 +21986,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", diff --git a/package.json b/package.json index bdc65e2c..a1d51d9d 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "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", From f2762a92a02064d8b77d834583d2001f94a0def6 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 19 Nov 2020 18:16:51 -0500 Subject: [PATCH 03/52] Add bin/liquidations.js liquidation lookup helper This script helps look up and output liquidation information starting at a certain date as a CSV. --- bin/liquidations.js | 176 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100755 bin/liquidations.js diff --git a/bin/liquidations.js b/bin/liquidations.js new file mode 100755 index 00000000..9c7c5708 --- /dev/null +++ b/bin/liquidations.js @@ -0,0 +1,176 @@ +#!/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" + +let args = process.argv.slice(2) +if (process.argv[0].includes("liquidations.js")) { + args = process.argv.slice(1) // invoked directly, no node +} + +const startDate = isNaN(parseInt(args[0])) + ? moment(args[0]) + : moment.unix(parseInt(args[0])) +if (!startDate.isValid()) { + console.error(`Start time ${args[0]} is either invalid or not recent enough.`) + process.exit(1) +} + +const validFields = ["operator", "owner", "beneficiary", "keep"] +const fields = (args[1] || "operator,owner,beneficiary,keep") + .toLowerCase() + .split(",") + .filter(_ => validFields.includes(_)) + +run(async () => { + const liquidations = await queryLiquidations(startDate) + + const rows = 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 + }, + [fields] + ) + + return rows.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) { + action() + .catch(error => { + console.error("Got error", error) + process.exit(2) + }) + .then((/** @type {string} */ result) => { + 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 +} + +/** + * @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/keep-network", + { 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() + }) +} From 0eea02c790b7e3c0dafad00cfcbee67cc4c77568 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 19 Nov 2020 18:17:42 -0500 Subject: [PATCH 04/52] Add owner lookup utility This looks up the true owners for one or more operator address delegations, indirecting through grants if needed. --- bin/owner-lookup.js | 214 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100755 bin/owner-lookup.js diff --git a/bin/owner-lookup.js b/bin/owner-lookup.js new file mode 100755 index 00000000..53f1649e --- /dev/null +++ b/bin/owner-lookup.js @@ -0,0 +1,214 @@ +#!/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 */ + +import { + findAndConsumeArgsExistence, + findAndConsumeArgsValues +} from "./helpers.js" + +const utils = Web3.utils + +let args = process.argv.slice(2) +if (process.argv[0].includes("owner-lookup.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 }, + 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() + +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 TokenGrantABI = TokenGrantJSON.abi +const ManagedGrantABI = ManagedGrantJSON.abi + +// 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 chainId = String(await web3.eth.getChainId()) + + const TokenGrant = await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TokenGrantJSON), + web3, + chainId + ) + const TokenStaking = await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TokenStakingJSON), + web3, + chainId + ) + const StakingPortBacker = await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (StakingPortBackerJSON), + web3, + chainId + ) + const TokenStakingEscrow = await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TokenStakingEscrowJSON), + web3, + chainId + ) + + const operators = args + return Promise.all( + operators.map( + operator => + new Promise(async resolve => { + resolve([operator, await lookupOwner(operator)].join("\t")) + }) + ) + ).then(_ => _.join("\n")) + + function lookupOwner(/** @type {string} */ operator) { + return TokenStaking.methods + .ownerOf(operator) + .call() + .then((/** @type {string} */ owner) => { + try { + return resolveOwner(owner, operator) + } catch (e) { + return `Unknown (${e})` + } + }) + } + + /** + * @param {string} owner + * @param {string} operator + * @return {Promise} + */ + async function resolveOwner( + /** @type {string} */ owner, + /** @type {string} */ operator + ) { + if ((await web3.eth.getStorageAt(owner, 0)) === "0x") { + return owner // owner is already a user-owned account + } else if (owner == StakingPortBacker.options.address) { + const { owner } = await StakingPortBacker.methods + .copiedStakes(operator) + .call() + return resolveOwner(owner, operator) + } else if (owner == TokenStakingEscrow.options.address) { + const grantId = await TokenStakingEscrow.methods + .depositGrantId(operator) + .call() + const { grantee } = await TokenGrant.methods.getGrant(grantId).call() + return resolveGrantee(grantee) + } 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 { + const { + transactionHash + } = await EthereumHelpers.getExistingEvent( + TokenStaking, + "StakeDelegated", + { operator } + ) + const { logs } = await web3.eth.getTransactionReceipt(transactionHash) + const TokenGrantStakedABI = TokenGrantABI.filter( + _ => _.type == "event" && _.name == "TokenGrantStaked" + )[0] + let grantId = null + // eslint-disable-next-line guard-for-in + for (const i in logs) { + const { data, topics } = logs[i] + // @ts-ignore Oh but there is a signature property on events foo'. + if (topics[0] == TokenGrantStakedABI.signature) { + const decoded = web3.eth.abi.decodeLog( + TokenGrantStakedABI.inputs, + data, + topics.slice(1) + ) + grantId = decoded.grantId + break + } + } + + const { grantee } = await TokenGrant.methods.getGrant(grantId).call() + return resolveGrantee(grantee) + } catch (_) { + // If we threw, assume this isn't a TokenGrantStake and the + // owner is just an unknown contract---e.g. Gnosis Safe. + return owner + } + } + } + + async function resolveGrantee(/** @type {string} */ grantee) { + if ((await web3.eth.getStorageAt(grantee, 0)) === "0x") { + return grantee // 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 await grant.methods.grantee().call() + } catch (_) { + // If we threw, assume this isn't a ManagedGrant and the + // grantee is just an unknown contract---e.g. Gnosis Safe. + return grantee + } + } + } +} From 573eddf15302e0305a71707f1c8ea2a24203f02f Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 19 Nov 2020 23:03:00 -0500 Subject: [PATCH 05/52] Add bin/refunds.js utility draft This utility will allow straightforward ingesting of a CSV that lists keeps that may need recovery, key shares, and operator BTC receive addresses or funder refund addresses, and coordinate BTC transaction construction for BTC held in keeps that have provided their key shares. --- bin/refunds.js | 161 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 bin/refunds.js diff --git a/bin/refunds.js b/bin/refunds.js new file mode 100644 index 00000000..8bb854ff --- /dev/null +++ b/bin/refunds.js @@ -0,0 +1,161 @@ +#!/usr/bin/env NODE_BACKEND=js node --experimental-modules --experimental-json-modules +// //// +// bin/refunds.js [-c ] [-s ] +// [-o ] +// [-r ] +// +// +// A CSV file that should have a column whose title is `keep`. It may have +// other columns, which will be ignored. +// +// -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. +// +// -r +// The directory that contains Bitcoin refund 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 `deposit-
.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. +// +// 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 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 +// +// All on-chain state checks on Ethereum require at least 100 confirmations; +// all on-chain state checks on Bitcoin require at least 6 confirmations. +// //// +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 */ + +import { + findAndConsumeArgsExistence, + findAndConsumeArgsValues +} from "./helpers.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 } + /* 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() + +run(async () => { + // TODO + // - Read CSV. + // - Check keeps for keyshare availability (= directory exists + has 3 files), + // filter accordingly. + // - Check keep for terminated vs closed; make sure state has been settled for + // past 100 blocks. + // - Check keep to see if it still holds BTC; if it does, make sure it has for + // past 6 blocks. + // - If terminated, assume liquidation. + // - If closed, assume misfund. + // + // TODO liquidation + // - Check for BTC beneficiary availability for each operator in the keep + // (= file exists + is JSON). + // - If available, verify that each beneficiary address is correctly signed by + // the operator, staker, or beneficiary address of its delegation. + // (= await web3.eth.personal.ecRecover(address.msg, address.sig) == address.account && + // [operator, staker, beneficiary].includes(message.account)) + // - If yes, build, sign, and broadcast splitter transaction. + // + // TODO misfund + // - 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. + + return "boop" +}) + +/** + * @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) + }) +} From 5c257cccb03e081bd7eea715c978df04153a023c Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Sun, 6 Dec 2020 16:06:26 -0500 Subject: [PATCH 06/52] Add Papa Parse for CSV and xpub-lib for *pub address handling --- package-lock.json | 185 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 +- 2 files changed, 188 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 70b15286..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", @@ -22514,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", @@ -22904,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", @@ -24629,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", @@ -24889,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 a1d51d9d..72caefca 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "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" } } From 56d6dd1e8b166d8c85ee6c251a67f706154bd145 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Sun, 6 Dec 2020 13:03:40 -0500 Subject: [PATCH 07/52] Made owner-lookup able to be imported This is useful for the refunds script, which needs to look up owners for certain processes. --- bin/owner-lookup.js | 298 ++++++++++++++++++++++++-------------------- 1 file changed, 166 insertions(+), 132 deletions(-) diff --git a/bin/owner-lookup.js b/bin/owner-lookup.js index 53f1649e..d736296d 100755 --- a/bin/owner-lookup.js +++ b/bin/owner-lookup.js @@ -6,17 +6,30 @@ 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 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 TokenGrantABI = TokenGrantJSON.abi +const ManagedGrantABI = ManagedGrantJSON.abi + const utils = Web3.utils +let standalone = false let args = process.argv.slice(2) if (process.argv[0].includes("owner-lookup.js")) { args = process.argv.slice(1) // invoked directly, no node + standalone = true } // No debugging unless explicitly enabled. @@ -53,162 +66,183 @@ engine.addProvider( const web3 = new Web3(engine) engine.start() -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 TokenGrantABI = TokenGrantJSON.abi -const ManagedGrantABI = ManagedGrantJSON.abi - -// owner-lookup.js + -if (!commandArgs.every(utils.isAddress)) { - console.error("All arguments must be valid Ethereum addresses.") - process.exit(1) -} +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) + doTheThing() + .then(result => { + console.log(result) - process.exit(0) - }) - .catch(error => { - console.error("ERROR ", error) + process.exit(0) + }) + .catch(error => { + console.error("ERROR ", error) - process.exit(1) - }) + process.exit(1) + }) +} async function doTheThing() { web3.eth.defaultAccount = account || (await web3.eth.getAccounts())[0] - const chainId = String(await web3.eth.getChainId()) - - const TokenGrant = await EthereumHelpers.getDeployedContract( - /** @type {TruffleArtifact} */ (TokenGrantJSON), - web3, - chainId - ) - const TokenStaking = await EthereumHelpers.getDeployedContract( - /** @type {TruffleArtifact} */ (TokenStakingJSON), - web3, - chainId - ) - const StakingPortBacker = await EthereumHelpers.getDeployedContract( - /** @type {TruffleArtifact} */ (StakingPortBackerJSON), - web3, - chainId - ) - const TokenStakingEscrow = await EthereumHelpers.getDeployedContract( - /** @type {TruffleArtifact} */ (TokenStakingEscrowJSON), - web3, - chainId - ) const operators = args return Promise.all( operators.map( operator => new Promise(async resolve => { - resolve([operator, await lookupOwner(operator)].join("\t")) + resolve( + [ + operator, + await lookupOwner(web3, await contractsFromWeb3(web3), operator) + ].join("\t") + ) }) ) ).then(_ => _.join("\n")) +} - function lookupOwner(/** @type {string} */ operator) { - return TokenStaking.methods - .ownerOf(operator) - .call() - .then((/** @type {string} */ owner) => { - try { - return resolveOwner(owner, operator) - } catch (e) { - return `Unknown (${e})` - } - }) +/** + * @param {Web3} web3 + * @return {Promise} + */ +export async function contractsFromWeb3(/** @type {Web3} */ web3) { + const chainId = String(await web3.eth.getChainId()) + + return { + 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 + ) } +} - /** - * @param {string} owner - * @param {string} operator - * @return {Promise} - */ - async function resolveOwner( - /** @type {string} */ owner, - /** @type {string} */ operator - ) { - if ((await web3.eth.getStorageAt(owner, 0)) === "0x") { - return owner // owner is already a user-owned account - } else if (owner == StakingPortBacker.options.address) { - const { owner } = await StakingPortBacker.methods - .copiedStakes(operator) - .call() - return resolveOwner(owner, operator) - } else if (owner == TokenStakingEscrow.options.address) { - const grantId = await TokenStakingEscrow.methods - .depositGrantId(operator) - .call() - const { grantee } = await TokenGrant.methods.getGrant(grantId).call() - return resolveGrantee(grantee) - } 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. +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 { - const { - transactionHash - } = await EthereumHelpers.getExistingEvent( - TokenStaking, - "StakeDelegated", - { operator } - ) - const { logs } = await web3.eth.getTransactionReceipt(transactionHash) - const TokenGrantStakedABI = TokenGrantABI.filter( - _ => _.type == "event" && _.name == "TokenGrantStaked" - )[0] - let grantId = null - // eslint-disable-next-line guard-for-in - for (const i in logs) { - const { data, topics } = logs[i] - // @ts-ignore Oh but there is a signature property on events foo'. - if (topics[0] == TokenGrantStakedABI.signature) { - const decoded = web3.eth.abi.decodeLog( - TokenGrantStakedABI.inputs, - data, - topics.slice(1) - ) - grantId = decoded.grantId - break - } - } + return resolveOwner(web3, contracts, owner, operator) + } catch (e) { + return `Unknown (${e})` + } + }) +} - const { grantee } = await TokenGrant.methods.getGrant(grantId).call() - return resolveGrantee(grantee) - } catch (_) { - // If we threw, assume this isn't a TokenGrantStake and the - // owner is just an unknown contract---e.g. Gnosis Safe. - return owner +/** + * @param {Web3} web3 + * @param {Contracts} contracts + * @param {string} owner + * @param {string} operator + * @return {Promise} + */ +async function resolveOwner(web3, contracts, owner, operator) { + const { + StakingPortBacker, + TokenStaking, + TokenStakingEscrow, + TokenGrant + } = contracts + + if ((await web3.eth.getStorageAt(owner, 0)) === "0x") { + return owner // owner is already a user-owned account + } else if (owner == StakingPortBacker.options.address) { + const { owner } = await StakingPortBacker.methods + .copiedStakes(operator) + .call() + return resolveOwner(web3, contracts, owner, operator) + } else if (owner == TokenStakingEscrow.options.address) { + const grantId = await TokenStakingEscrow.methods + .depositGrantId(operator) + .call() + const { grantee } = await TokenGrant.methods.getGrant(grantId).call() + return resolveGrantee(grantee) + } 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 { + const { + transactionHash + } = await EthereumHelpers.getExistingEvent( + TokenStaking, + "StakeDelegated", + { operator } + ) + const { logs } = await web3.eth.getTransactionReceipt(transactionHash) + const TokenGrantStakedABI = TokenGrantABI.filter( + _ => _.type == "event" && _.name == "TokenGrantStaked" + )[0] + let grantId = null + // eslint-disable-next-line guard-for-in + for (const i in logs) { + const { data, topics } = logs[i] + // @ts-ignore Oh but there is a signature property on events foo'. + if (topics[0] == TokenGrantStakedABI.signature) { + const decoded = web3.eth.abi.decodeLog( + TokenGrantStakedABI.inputs, + data, + topics.slice(1) + ) + grantId = decoded.grantId + break + } } + + const { grantee } = await TokenGrant.methods.getGrant(grantId).call() + return resolveGrantee(web3, grantee) + } catch (_) { + // If we threw, assume this isn't a TokenGrantStake and the + // owner is just an unknown contract---e.g. Gnosis Safe. + return owner } } +} - async function resolveGrantee(/** @type {string} */ grantee) { - if ((await web3.eth.getStorageAt(grantee, 0)) === "0x") { - return grantee // 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 await grant.methods.grantee().call() - } catch (_) { - // If we threw, assume this isn't a ManagedGrant and the - // grantee is just an unknown contract---e.g. Gnosis Safe. - return grantee - } +async function resolveGrantee( + /** @type {Web3} */ web3, + /** @type {string} */ grantee +) { + if ((await web3.eth.getStorageAt(grantee, 0)) === "0x") { + return grantee // 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 await grant.methods.grantee().call() + } catch (_) { + // If we threw, assume this isn't a ManagedGrant and the + // grantee is just an unknown contract---e.g. Gnosis Safe. + return grantee } } } From d26f4bd91bccaeeace8fb1d62af8a77063baa66e Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Sun, 6 Dec 2020 15:57:22 -0500 Subject: [PATCH 08/52] Fill in initial parts of refunds script The script now pulls keeps and checks for their operators' associated key shares, the keeps' associated status on-chain, the BTC still held in the keep, and looks up their BTC beneficiary address information on disk for liquidations. Still missing: - Constructing the split transaction for liquidation refunds. - All misfund handling. --- bin/refunds.js | 522 +++++++++++++++++++++++++++++++++++++++++++++---- jsconfig.json | 3 +- 2 files changed, 487 insertions(+), 38 deletions(-) mode change 100644 => 100755 bin/refunds.js diff --git a/bin/refunds.js b/bin/refunds.js old mode 100644 new mode 100755 index 8bb854ff..4ea29bb0 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -27,7 +27,7 @@ // 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. +// delegation. Defaults to `./beneficiaries`. // // -r // The directory that contains Bitcoin refund addresses for misfunds. These @@ -36,7 +36,7 @@ // named `deposit-
.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. +// specified deposit. Defaults to `./refunds`. // // Iterates through the specified keep ids, looks up their public keys in // order to compute their Bitcoin addresses, and verifies that they are still @@ -58,17 +58,29 @@ // All on-chain state checks on Ethereum require at least 100 confirmations; // all on-chain state checks on Bitcoin require at least 6 confirmations. // //// +import PapaParse from "papaparse" +import { promises /* createReadStream, existsSync, writeFileSync }*/ } from "fs" +const { readdir, stat, readFile } = promises + import Subproviders from "@0x/subproviders" -// import Web3 from "web3" +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 */ +import BondedECDSAKeepJSON from "@keep-network/keep-ecdsa/artifacts/BondedECDSAKeep.json" +import TokenStakingJSON from "@keep-network/keep-core/artifacts/TokenStaking.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" let args = process.argv.slice(2) if (process.argv[0].includes("refunds.js")) { @@ -84,9 +96,25 @@ if (!debug) { console.debug = () => {} } const { - found: { mnemonic, /* account,*/ rpc } - /* remaining: commandArgs*/ -} = findAndConsumeArgsValues(flagArgs, "--mnemonic", "--account", "--rpc") + found: { + mnemonic, + /* account,*/ rpc, + c: keepEcdsaClientPath, + s: keyShareDirectory, + o: beneficiaryDirectory + // r: refundDirectory + }, + remaining: commandArgs +} = findAndConsumeArgsValues( + flagArgs, + "--mnemonic", + "--account", + "--rpc", + "-c", + "-s", + "-o", + "-r" +) const engine = new ProviderEngine({ pollingInterval: 1000 }) engine.addProvider( @@ -105,39 +133,459 @@ engine.addProvider( }) ) -// const web3 = new Web3(engine) +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() -run(async () => { - // TODO - // - Read CSV. - // - Check keeps for keyshare availability (= directory exists + has 3 files), - // filter accordingly. - // - Check keep for terminated vs closed; make sure state has been settled for - // past 100 blocks. - // - Check keep to see if it still holds BTC; if it does, make sure it has for - // past 6 blocks. - // - If terminated, assume liquidation. - // - If closed, assume misfund. - // - // TODO liquidation - // - Check for BTC beneficiary availability for each operator in the keep - // (= file exists + is JSON). - // - If available, verify that each beneficiary address is correctly signed by - // the operator, staker, or beneficiary address of its delegation. - // (= await web3.eth.personal.ecRecover(address.msg, address.sig) == address.account && - // [operator, staker, beneficiary].includes(message.account)) - // - If yes, build, sign, and broadcast splitter transaction. - // - // TODO misfund - // - 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. - - return "boop" +/** + * @param {string} keyShareDirectory + * @param {string} digest + * @return {Promise} + */ +function signDigest(keyShareDirectory, digest) { + return new Promise((resolve, reject) => { + let output = "" + let errorOutput = "" + const process = spawn(keepEcdsaClientPath || "keep-ecdsa", [ + "signing", + "sign-digest", + digest, + keyShareDirectory + ]) + process.stdout.setEncoding("utf8") + process.stdout.on("data", chunk => (output += chunk)) + process.stderr.on("data", chunk => (errorOutput += chunk)) + + process + .on("exit", (code, signal) => { + if (code === 0) { + resolve(output) + } else { + reject( + new Error( + `Process exited abnormally with signal ${signal} and code ${code}` + + errorOutput + ) + ) + } + }) + .on("error", error => { + reject(errorOutput || error) + }) + }) +} + +/** + * @param {string} keepAddress + */ +async function keySharesReady(keepAddress) { + const keepDirectory = (keyShareDirectory || "key-shares") + "/" + keepAddress + try { + return ( + (await stat(keepDirectory)).isDirectory() && + (await readdir(keepDirectory)).length == 3 && + (await signDigest(keepDirectory, "deadbeef")) !== null + ) + } catch (_) { + return false + } +} + +/** @type {import("../src/EthereumHelpers.js").Contract} */ +let baseKeepContract +/** @type {import("../src/EthereumHelpers.js").Contract} */ +let tokenStakingContract +/** @type {number} */ +let startingBlock + +function keepAt(/** @type {string} */ keepAddress) { + baseKeepContract = + baseKeepContract || + EthereumHelpers.buildContract( + web3, + /** @type {TruffleArtifact} */ (BondedECDSAKeepJSON).abi + ) + + const requestedKeep = baseKeepContract.clone() + requestedKeep.options.address = keepAddress + return requestedKeep +} + +function tokenStaking() { + tokenStakingContract = + tokenStakingContract || + EthereumHelpers.buildContract( + web3, + /** @type {TruffleArtifact} */ (TokenStakingJSON).abi + ) + + return tokenStakingContract +} + +async function referenceBlock() { + startingBlock = startingBlock || (await web3.eth.getBlockNumber()) + return startingBlock +} + +/** + * @type {{ [operatorAddress: string]: { beneficiary?: string?, owner?: string? }}} + */ +const delegationInfoCache = {} + +function beneficiaryOf(/** @type {string} */ operatorAddress) { + if ( + delegationInfoCache[operatorAddress] && + delegationInfoCache[operatorAddress].beneficiary + ) { + return delegationInfoCache[operatorAddress].beneficiary + } + + const beneficiary = 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) + console.log(bitcoinAddress, btcBalance) + + return { bitcoinAddress, btcBalance } +} + +/** @type {{[operatorAddress: string]: string}} */ +const operatorBeneficiaries = {} + +/** + * @param {string} operatorAddress + */ +async function readBeneficiary(operatorAddress) { + if (operatorBeneficiaries[operatorAddress]) { + return operatorBeneficiaries[operatorAddress] + } + + const beneficiaryFile = + (beneficiaryDirectory || "beneficiaries") + + "/" + + operatorAddress.toLowerCase() + + ".json" + + // If it doesn't exist, return empty. + try { + if (!(await stat(beneficiaryFile)).isFile()) { + return null + } + } catch (e) { + return null + } + + /** @type {{message: string, signature: string, address: string}} */ + const jsonContents = JSON.parse( + await readFile(beneficiaryFile, { encoding: "utf-8" }) + ) + + if ( + !jsonContents.message || + !jsonContents.signature || + !jsonContents.address + ) { + throw new Error( + `Invalid format for ${operatorAddress}: message, signature, or signing address missing.` + ) + } + + const recoveredAddress = await web3.eth.personal.ecRecover( + jsonContents.message, + jsonContents.signature + ) + if (recoveredAddress !== jsonContents.address) { + 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() + ) { + throw new Error( + `Beneficiary address for ${operatorAddress} was not signed by operator owner or beneficiary.` + ) + } + + const addresses = [ + ...jsonContents.message.matchAll(/((?:1|3|bc1)[A-Za-z0-9]{26,33})/) + ].map(_ => _[1]) + if (addresses.length > 1) { + throw new Error( + `Beneficiary message for ${operatorAddress} includes too many addresses: ${addresses}` + ) + } else if (addresses.length === 1) { + operatorBeneficiaries[operatorAddress] = addresses[0] + return addresses[0] + } + + const pubs = [...jsonContents.message.matchAll(/([xyz]pub[a-zA-Z0-9]*)/)].map( + _ => _[1] + ) + if (pubs.length > 1) { + throw new Error( + `Beneficiary message for ${operatorAddress} includes too many addresses: ${addresses}` + ) + } else if (pubs.length === 1) { + operatorBeneficiaries[operatorAddress] = pubs[0] + return pubs[0] + } + + throw new Error( + `Could not find a valid BTC address or *pub in signed message for ${operatorAddress}: ` + + `${jsonContents.message}` + ) +} + +async function beneficiariesAvailableAndSigned( + /** @type {string} */ keepAddress +) { + const keepContract = keepAt(keepAddress) + + /** @type {[string,string,string]} */ + const operators = await keepContract.methods.getMembers().call() + 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})` + } + } + + return { + beneficiary1: beneficiaries[0], + beneficiary2: beneficiaries[1], + beneficiary3: beneficiaries[2] + } + } catch (e) { + return { error: `beneficiary lookup failed: ${e}` } + } +} + +async function buildAndBroadcastLiquidationSplit(/** @type {any} */ keepData) { + return {} +} + +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).map(async keep => { + // 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) => + ((await keySharesReady(row.keep)) && row) || { + ...row, + error: "missing key shares" + }, + 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" } + } + } + ] + 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 transactionData = await buildAndBroadcastLiquidationSplit(row) + + if (transactionData) { + return { ...row, ...transactionData } + } else { + return { + ...row, + error: + "failed to build and broadcast liquidation split BTC transaction" + } + } + } + ] + const misfundProcessors = [async (/** @type {any} */ row) => row] + // refundAddressAvailable, + // refundAddressSigned + // ] + const processThrough = async ( + /** @type {any} */ inputData, + /** @type {(function(any):Promise)[]} */ processors + ) => { + return await processors.reduce(async (rowPromise, process) => { + const row = await rowPromise + if (!row.error) { + return await process(row) + } else { + return row + } + }, inputData) + } + + // @ts-ignore No really, this is a valid config. + BitcoinHelpers.electrumConfig = AvailableBitcoinConfigs["1"].electrum + const basicInfo = await processThrough({ keep }, genericStatusProcessors) + + if (basicInfo.status == "terminated") { + return processThrough(basicInfo, liquidationProcessors) + } else if (basicInfo.status == "closed") { + return processThrough(basicInfo, misfundProcessors) + } else { + return basicInfo + } + }) + + return Promise.all(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 }) => { + 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) + } + // TODO + // - Check keep for terminated vs closed; make sure state has been settled for + // past 100 blocks. + // - Check keep to see if it still holds BTC; if it does, make sure it has for + // past 6 blocks. + // - If terminated, assume liquidation. + // - If closed, assume misfund. + // + // TODO liquidation + // - Check for BTC beneficiary availability for each operator in the keep + // (= file exists + is JSON). + // - If available, verify that each beneficiary address is correctly signed by + // the operator, staker, or beneficiary address of its delegation. + // (= await web3.eth.personal.ecRecover(address.msg, address.sig) == address.account && + // [operator, staker, beneficiary].includes(message.account)) + // - If yes, build, sign, and broadcast splitter transaction. + // + // TODO misfund + // - 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. + }) }) /** diff --git a/jsconfig.json b/jsconfig.json index 73840637..0bb119ac 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -10,7 +10,8 @@ "strictNullChecks": true, "checkJs": true, "lib": [ - "es2019" + "es2019", + "es2020.string" ] } } From 7b3fce881defe8d21723559f1010211ad2902f62 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 17:17:56 -0500 Subject: [PATCH 09/52] Factor sighash and tx signing to exported functions bitcoin.js now separates most of the hard work into exported functions that can be used elsewhere---for example, in the refund script... --- bin/commands/bitcoin.js | 259 +++++++++++++++++++++++----------------- 1 file changed, 147 insertions(+), 112 deletions(-) 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") + ) + ) +} From 3cd5e0761badd613f8d62e2d7b9aaaed2f4e694e Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 17:18:43 -0500 Subject: [PATCH 10/52] refunds script catches exceptions in the processor flow These are reported as errors in the resulting CSV, and can be handled later/separately without blocking other transactions from being processed. --- bin/refunds.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 4ea29bb0..774b279f 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -511,10 +511,14 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { ) => { return await processors.reduce(async (rowPromise, process) => { const row = await rowPromise - if (!row.error) { - return await process(row) - } else { - return row + try { + if (!row.error) { + return await process(row) + } else { + return row + } + } catch (e) { + return { ...row, error: `Error processing transaction: ${e}` } } }, inputData) } From 1b80dbcde0e3159cb04cd116b6089283cb087988 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 17:19:48 -0500 Subject: [PATCH 11/52] In refund script args, refund becomes misfund This is more closely aligned with how we're tracking requests. --- bin/refunds.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 774b279f..41370060 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -29,14 +29,14 @@ // operator, staker, or beneficiary addresses for that operator's // delegation. Defaults to `./beneficiaries`. // -// -r -// The directory that contains Bitcoin refund 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 `deposit-
.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 `./refunds`. +// -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`. // // Iterates through the specified keep ids, looks up their public keys in // order to compute their Bitcoin addresses, and verifies that they are still @@ -101,8 +101,8 @@ const { /* account,*/ rpc, c: keepEcdsaClientPath, s: keyShareDirectory, - o: beneficiaryDirectory - // r: refundDirectory + o: beneficiaryDirectory, + m: misfundDirectory }, remaining: commandArgs } = findAndConsumeArgsValues( @@ -113,6 +113,7 @@ const { "-c", "-s", "-o", + "-m", "-r" ) const engine = new ProviderEngine({ pollingInterval: 1000 }) From cce51b0f2f5d23c1a1e9efd75227688a2edab4a4 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 17:21:00 -0500 Subject: [PATCH 12/52] refunds.js signDigest now depends only on keep address It used to depend on the key shares directory, but some callers will only have the keep address and needn't know about how to construct directory paths. --- bin/refunds.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 41370060..85741f10 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -145,11 +145,12 @@ const web3 = new Web3(engine) engine.start() /** - * @param {string} keyShareDirectory + * @param {string} keepAddress, * @param {string} digest - * @return {Promise} + * @return {Promise<{ signature: string, publicKey: string }>} */ -function signDigest(keyShareDirectory, digest) { +function signDigest(keepAddress, digest) { + const keepDirectory = (keyShareDirectory || "key-shares") + "/" + keepAddress return new Promise((resolve, reject) => { let output = "" let errorOutput = "" @@ -157,7 +158,7 @@ function signDigest(keyShareDirectory, digest) { "signing", "sign-digest", digest, - keyShareDirectory + keepDirectory ]) process.stdout.setEncoding("utf8") process.stdout.on("data", chunk => (output += chunk)) @@ -166,7 +167,8 @@ function signDigest(keyShareDirectory, digest) { process .on("exit", (code, signal) => { if (code === 0) { - resolve(output) + const [publicKey, signature] = output.split("\t") + resolve({ signature: signature.trim(), publicKey: publicKey.trim() }) } else { reject( new Error( @@ -191,7 +193,7 @@ async function keySharesReady(keepAddress) { return ( (await stat(keepDirectory)).isDirectory() && (await readdir(keepDirectory)).length == 3 && - (await signDigest(keepDirectory, "deadbeef")) !== null + (await signDigest(keepAddress, "deadbeef")) !== null ) } catch (_) { return false From 7e2efdb14e7c0b817ffd694790ded6e27a7565cc Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 17:21:57 -0500 Subject: [PATCH 13/52] Fix beneficiary address reading/sig verification A few things were wrong: - The expected JSON object keys were incorrect. - The matchAll call and its results were overly complex. --- bin/refunds.js | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 85741f10..20521d27 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -339,24 +339,20 @@ async function readBeneficiary(operatorAddress) { return null } - /** @type {{message: string, signature: string, address: string}} */ + /** @type {{msg: string, sig: string, address: string}} */ const jsonContents = JSON.parse( await readFile(beneficiaryFile, { encoding: "utf-8" }) ) - if ( - !jsonContents.message || - !jsonContents.signature || - !jsonContents.address - ) { + if (!jsonContents.msg || !jsonContents.sig || !jsonContents.address) { throw new Error( `Invalid format for ${operatorAddress}: message, signature, or signing address missing.` ) } - const recoveredAddress = await web3.eth.personal.ecRecover( - jsonContents.message, - jsonContents.signature + const recoveredAddress = web3.eth.accounts.recover( + jsonContents.msg, + jsonContents.sig ) if (recoveredAddress !== jsonContents.address) { throw new Error( @@ -376,8 +372,8 @@ async function readBeneficiary(operatorAddress) { } const addresses = [ - ...jsonContents.message.matchAll(/((?:1|3|bc1)[A-Za-z0-9]{26,33})/) - ].map(_ => _[1]) + ...jsonContents.msg.matchAll(/(?:1|3|bc1)[A-Za-z0-9]{26,33}/g) + ].map(_ => _[0]) if (addresses.length > 1) { throw new Error( `Beneficiary message for ${operatorAddress} includes too many addresses: ${addresses}` @@ -387,8 +383,8 @@ async function readBeneficiary(operatorAddress) { return addresses[0] } - const pubs = [...jsonContents.message.matchAll(/([xyz]pub[a-zA-Z0-9]*)/)].map( - _ => _[1] + const pubs = [...jsonContents.msg.matchAll(/[xyz]pub[a-zA-Z0-9]*/g)].map( + _ => _[0] ) if (pubs.length > 1) { throw new Error( @@ -401,7 +397,7 @@ async function readBeneficiary(operatorAddress) { throw new Error( `Could not find a valid BTC address or *pub in signed message for ${operatorAddress}: ` + - `${jsonContents.message}` + `${jsonContents.msg}` ) } From e90e4f35026b9d218edf73816a9b34f8a3d27ea4 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 17:25:05 -0500 Subject: [PATCH 14/52] Add full misfund handling to refunds.js Allow specifying a fixed BTC fee as a -f parameter, and handle misfunds end-to-end including reading and verifying the refund address, resolving the funding input, and building, signing, and broadcasting the refund transaction. The final CSV output for these transactions includes the transaction id and the signed transaction data for debugging or broadcasting elsewhere if needed. --- bin/refunds.js | 284 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 277 insertions(+), 7 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 20521d27..bcf23d0a 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -8,6 +8,11 @@ // 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). @@ -69,8 +74,11 @@ import WebsocketSubprovider from "web3-provider-engine/subproviders/websocket.js // import EthereumHelpers from "../src/EthereumHelpers.js" /** @typedef { import('../src/EthereumHelpers.js').TruffleArtifact } TruffleArtifact */ -import BondedECDSAKeepJSON from "@keep-network/keep-ecdsa/artifacts/BondedECDSAKeep.json" 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, @@ -81,6 +89,12 @@ 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")) { @@ -99,6 +113,7 @@ const { found: { mnemonic, /* account,*/ rpc, + f: transactionFee, c: keepEcdsaClientPath, s: keyShareDirectory, o: beneficiaryDirectory, @@ -110,6 +125,7 @@ const { "--mnemonic", "--account", "--rpc", + "-f", "-c", "-s", "-o", @@ -204,6 +220,12 @@ async function keySharesReady(keepAddress) { 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 @@ -215,7 +237,7 @@ function keepAt(/** @type {string} */ keepAddress) { /** @type {TruffleArtifact} */ (BondedECDSAKeepJSON).abi ) - const requestedKeep = baseKeepContract.clone() + const requestedKeep = /** @type {typeof baseKeepContract} */ (baseKeepContract.clone()) requestedKeep.options.address = keepAddress return requestedKeep } @@ -231,6 +253,42 @@ function tokenStaking() { 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 @@ -308,7 +366,6 @@ async function keepHoldsBtc(keepAddress) { ) const btcBalance = await BitcoinHelpers.Transaction.getBalance(bitcoinAddress) - console.log(bitcoinAddress, btcBalance) return { bitcoinAddress, btcBalance } } @@ -401,6 +458,64 @@ async function readBeneficiary(operatorAddress) { ) } +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|bc1)[A-Za-z0-9]{26,33}/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 ) { @@ -436,10 +551,123 @@ async function beneficiariesAvailableAndSigned( } } +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 buildAndBroadcastLiquidationSplit(/** @type {any} */ keepData) { return {} } +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 } = await BitcoinHelpers.Transaction.broadcast( + // signedTransaction + // ) + const transactionID = "lolnope" + + return { + refundAmount, + signature, + publicKey, + transactionID, + signedTransaction + } + } catch (e) { + console.log(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 @@ -500,10 +728,52 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { } } ] - const misfundProcessors = [async (/** @type {any} */ row) => row] - // refundAddressAvailable, - // refundAddressSigned - // ] + 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 beneficiary" + } + } + }, + async (/** @type {any} */ row) => { + const fundingInfo = await findFundingInfo(row.bitcoinAddress) + + if (fundingInfo) { + return { ...row, ...fundingInfo } + } else { + return { + ...row, + error: + "failed to build and broadcast liquidation split BTC transaction" + } + } + }, + async (/** @type {any} */ row) => { + const transactionData = await buildAndBroadcastRefund(row) + + if (transactionData) { + return { ...row, ...transactionData } + } else { + return { + ...row, + error: + "failed to build and broadcast liquidation split BTC transaction" + } + } + } + ] const processThrough = async ( /** @type {any} */ inputData, /** @type {(function(any):Promise)[]} */ processors From 9b0e9c7c585db3e18a9a30b716cb9009ec5651ed Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 17:25:53 -0500 Subject: [PATCH 15/52] Add BitcoinHelpers.constructOneInputWitnessTransaction This allows constructing a raw transaction with one input and multiple outputs. --- src/BitcoinHelpers.js | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/BitcoinHelpers.js b/src/BitcoinHelpers.js index d58696a1..4d6d63c3 100644 --- a/src/BitcoinHelpers.js +++ b/src/BitcoinHelpers.js @@ -661,6 +661,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`. From 0eb28520185f1b06467ca3eca383269ac51c8724 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:02:29 -0500 Subject: [PATCH 16/52] Bubble out exceptions from keySharesReady The error is caught higher up and can allow for bubbling e.g. the error messages from keep-ecdsa. --- bin/refunds.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index bcf23d0a..15692c63 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -205,15 +205,11 @@ function signDigest(keepAddress, digest) { */ async function keySharesReady(keepAddress) { const keepDirectory = (keyShareDirectory || "key-shares") + "/" + keepAddress - try { - return ( - (await stat(keepDirectory)).isDirectory() && - (await readdir(keepDirectory)).length == 3 && - (await signDigest(keepAddress, "deadbeef")) !== null - ) - } catch (_) { - return false - } + return ( + (await stat(keepDirectory)).isDirectory() && + (await readdir(keepDirectory)).length == 3 && + (await signDigest(keepAddress, "deadbeef")) !== null + ) } /** @type {import("../src/EthereumHelpers.js").Contract} */ From 00f135fd4c4c6d03d52b673bdcfefe78bb8c8cba Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:03:37 -0500 Subject: [PATCH 17/52] Capture full keep-ecdsa output and report with errors Before, error output only was being reported, but this could prevent seeing certain explanatory outputs that the executable is putting on stdout. --- bin/refunds.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 15692c63..e5940fb5 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -169,7 +169,7 @@ function signDigest(keepAddress, digest) { const keepDirectory = (keyShareDirectory || "key-shares") + "/" + keepAddress return new Promise((resolve, reject) => { let output = "" - let errorOutput = "" + let allOutput = "" const process = spawn(keepEcdsaClientPath || "keep-ecdsa", [ "signing", "sign-digest", @@ -177,8 +177,13 @@ function signDigest(keepAddress, digest) { keepDirectory ]) process.stdout.setEncoding("utf8") - process.stdout.on("data", chunk => (output += chunk)) - process.stderr.on("data", chunk => (errorOutput += chunk)) + process.stdout.on("data", chunk => { + output += chunk + allOutput += chunk + }) + process.stderr.on("data", chunk => { + allOutput += chunk + }) process .on("exit", (code, signal) => { @@ -188,14 +193,14 @@ function signDigest(keepAddress, digest) { } else { reject( new Error( - `Process exited abnormally with signal ${signal} and code ${code}` + - errorOutput + `Process exited abnormally with signal ${signal} and code ${code}\n` + + allOutput ) ) } }) .on("error", error => { - reject(errorOutput || error) + reject(allOutput || error) }) }) } From dda2b93fafdad282018960a41f341d548e70b457 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:04:10 -0500 Subject: [PATCH 18/52] Fix refunds TokenStaking usage in beneficiaryOf TokenStaking was being constructed as if it didn't need a set address, whereas it does. --- bin/refunds.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index e5940fb5..e2656b09 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -243,13 +243,14 @@ function keepAt(/** @type {string} */ keepAddress) { return requestedKeep } -function tokenStaking() { +async function tokenStaking() { tokenStakingContract = tokenStakingContract || - EthereumHelpers.buildContract( + (await EthereumHelpers.getDeployedContract( + /** @type {TruffleArtifact} */ (TokenStakingJSON), web3, - /** @type {TruffleArtifact} */ (TokenStakingJSON).abi - ) + "1" + )) return tokenStakingContract } @@ -300,7 +301,7 @@ async function referenceBlock() { */ const delegationInfoCache = {} -function beneficiaryOf(/** @type {string} */ operatorAddress) { +async function beneficiaryOf(/** @type {string} */ operatorAddress) { if ( delegationInfoCache[operatorAddress] && delegationInfoCache[operatorAddress].beneficiary @@ -308,8 +309,8 @@ function beneficiaryOf(/** @type {string} */ operatorAddress) { return delegationInfoCache[operatorAddress].beneficiary } - const beneficiary = tokenStaking() - .methods.beneficiaryOf(operatorAddress) + const beneficiary = (await tokenStaking()).methods + .beneficiaryOf(operatorAddress) .call() delegationInfoCache[operatorAddress] = delegationInfoCache[operatorAddress] || {} From 5728f57cf4350e081c7d7c0fda9e2946e114eb3e Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:05:37 -0500 Subject: [PATCH 19/52] Use case-insensitive address comparison in readBeneficiary The address recovered by accounts.recover can be in a different checksum mode than the one included in the JSON payload. --- bin/refunds.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/refunds.js b/bin/refunds.js index e2656b09..6f65885d 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -413,7 +413,7 @@ async function readBeneficiary(operatorAddress) { jsonContents.msg, jsonContents.sig ) - if (recoveredAddress !== jsonContents.address) { + if (recoveredAddress.toLowerCase() !== jsonContents.address.toLowerCase()) { throw new Error( `Recovered address does not match signing address for ${operatorAddress}.` ) From 0bcae67e842f5c55a4e8c12095718057d4c70288 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:05:54 -0500 Subject: [PATCH 20/52] Fix refunds beneficiary file lookup path --- bin/refunds.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/refunds.js b/bin/refunds.js index 6f65885d..16233d4d 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -385,7 +385,7 @@ async function readBeneficiary(operatorAddress) { const beneficiaryFile = (beneficiaryDirectory || "beneficiaries") + - "/" + + "/beneficiary-" + operatorAddress.toLowerCase() + ".json" From 1a31c255b5f5d7e093c8a33648b808e2629c378a Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:07:06 -0500 Subject: [PATCH 21/52] Check for beneficiary *pubs before addresses The inner content of *pubs can match the address regexp, so switch the check around. Also, *pubs are accepted even if more than one is detected, provided that all matched *pubs are the same. This can happen in cases where the message is a JSON payload of a type that can be outputted by some tools to include more info than just the *pub. --- bin/refunds.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 16233d4d..9114c447 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -430,6 +430,18 @@ async function readBeneficiary(operatorAddress) { ) } + 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) { + operatorBeneficiaries[operatorAddress] = pubs[0] + return pubs[0] + } + const addresses = [ ...jsonContents.msg.matchAll(/(?:1|3|bc1)[A-Za-z0-9]{26,33}/g) ].map(_ => _[0]) @@ -437,23 +449,11 @@ async function readBeneficiary(operatorAddress) { throw new Error( `Beneficiary message for ${operatorAddress} includes too many addresses: ${addresses}` ) - } else if (addresses.length === 1) { + } else if (addresses.length !== 0) { operatorBeneficiaries[operatorAddress] = addresses[0] return addresses[0] } - const pubs = [...jsonContents.msg.matchAll(/[xyz]pub[a-zA-Z0-9]*/g)].map( - _ => _[0] - ) - if (pubs.length > 1) { - throw new Error( - `Beneficiary message for ${operatorAddress} includes too many addresses: ${addresses}` - ) - } else if (pubs.length === 1) { - operatorBeneficiaries[operatorAddress] = pubs[0] - return pubs[0] - } - throw new Error( `Could not find a valid BTC address or *pub in signed message for ${operatorAddress}: ` + `${jsonContents.msg}` From 2fec1e212a28896e4658ad8be40b8da724a80247 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:08:45 -0500 Subject: [PATCH 22/52] Add basic *pub address generation based on xpub-lib When a beneficiary is read, a fresh address is generated for it by iterating the *pub's key index until an address is found with no transactions. Currently only p2pkh addresses are generated. --- bin/refunds.js | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 9114c447..88bfb1a6 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -74,6 +74,11 @@ import WebsocketSubprovider from "web3-provider-engine/subproviders/websocket.js // import EthereumHelpers from "../src/EthereumHelpers.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, Purpose } = 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" @@ -375,12 +380,35 @@ async function keepHoldsBtc(keepAddress) { /** @type {{[operatorAddress: string]: string}} */ const operatorBeneficiaries = {} +async function generateAddress(/** @type {string} */ beneficiary) { + if (!beneficiary.match(/^.pub/)) { + return beneficiary // standard address, always returns itself + } + + const metadata = getExtPubKeyMetadata(beneficiary) + let latestAddress = "" + let addressIndex = 0 + do { + const derivedAddressInfo = addressFromExtPubKey({ + extPubKey: beneficiary, + keyIndex: addressIndex, + purpose: Purpose.P2PKH, + network: metadata.network + }) + latestAddress = derivedAddressInfo.address + // TODO Store address index? + addressIndex++ + } while (await BitcoinHelpers.Transaction.find(latestAddress, 0)) + + return latestAddress +} + /** * @param {string} operatorAddress */ async function readBeneficiary(operatorAddress) { if (operatorBeneficiaries[operatorAddress]) { - return operatorBeneficiaries[operatorAddress] + return generateAddress(operatorBeneficiaries[operatorAddress]) } const beneficiaryFile = @@ -439,7 +467,7 @@ async function readBeneficiary(operatorAddress) { ) } else if (pubs.length !== 0) { operatorBeneficiaries[operatorAddress] = pubs[0] - return pubs[0] + return generateAddress(pubs[0]) } const addresses = [ @@ -451,7 +479,7 @@ async function readBeneficiary(operatorAddress) { ) } else if (addresses.length !== 0) { operatorBeneficiaries[operatorAddress] = addresses[0] - return addresses[0] + return generateAddress(addresses[0]) } throw new Error( From 677b3553a107ef3d77b825aa1e6405b514b3be43 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:09:51 -0500 Subject: [PATCH 23/52] First attempt at a liquidation split transaction Hasn't been checked yet as the necessary key material isn't yet available. --- bin/refunds.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 88bfb1a6..66536a3a 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -581,6 +581,75 @@ async function beneficiariesAvailableAndSigned( } } +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(18) + .muln(5) + .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 } = await BitcoinHelpers.Transaction.broadcast( + // signedTransaction + // ) + const transactionID = "lolnope" + + return { + perBeneficiaryAmount, + signature, + publicKey, + transactionID, + signedTransaction + } + } catch (e) { + console.log(e) + return { refundAmount, error: `Error signing: ${e}` } + } +} + async function misfundRecipientAvailableAndSigned( /** @type {string} */ keepAddress ) { @@ -611,10 +680,6 @@ async function misfundRecipientAvailableAndSigned( } } -async function buildAndBroadcastLiquidationSplit(/** @type {any} */ keepData) { - return {} -} - async function findFundingInfo(/** @type {string} */ bitcoinAddress) { const unspent = await BitcoinHelpers.Transaction.findAllUnspent( bitcoinAddress From ea2f6e783a6f12465b643c276218ae6286cf39b8 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:10:10 -0500 Subject: [PATCH 24/52] Comment and import cleanup for refunds.js --- bin/refunds.js | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 66536a3a..e0360963 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -64,14 +64,13 @@ // all on-chain state checks on Bitcoin require at least 6 confirmations. // //// import PapaParse from "papaparse" -import { promises /* createReadStream, existsSync, writeFileSync }*/ } from "fs" +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" -// import EthereumHelpers from "../src/EthereumHelpers.js" /** @typedef { import('../src/EthereumHelpers.js').TruffleArtifact } TruffleArtifact */ // @ts-ignore This lib is built all sorts of poorly for imports. @@ -929,30 +928,6 @@ run(() => { } catch (err) { reject(err) } - // TODO - // - Check keep for terminated vs closed; make sure state has been settled for - // past 100 blocks. - // - Check keep to see if it still holds BTC; if it does, make sure it has for - // past 6 blocks. - // - If terminated, assume liquidation. - // - If closed, assume misfund. - // - // TODO liquidation - // - Check for BTC beneficiary availability for each operator in the keep - // (= file exists + is JSON). - // - If available, verify that each beneficiary address is correctly signed by - // the operator, staker, or beneficiary address of its delegation. - // (= await web3.eth.personal.ecRecover(address.msg, address.sig) == address.account && - // [operator, staker, beneficiary].includes(message.account)) - // - If yes, build, sign, and broadcast splitter transaction. - // - // TODO misfund - // - 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. }) }) From 10fbc8c3d5b53ee5800a9b3712363d7f5fd8703a Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:19:20 -0500 Subject: [PATCH 25/52] Add nested electrum client reuse to BitcoinHelpers This ensures that when a withElectrumClient call occurs inside an existing withElectrumClient call, the client and connection are reused instead of another client being created and another connection being opened to the server. --- src/BitcoinHelpers.js | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/BitcoinHelpers.js b/src/BitcoinHelpers.js index 4d6d63c3..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 + ) + + try { + await backoffRetrier(10)(() => client.connect()) + resolve(client) + } catch (error) { + reject(error) + } + }) + } - await electrumClient.connect() + 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) - result.then( - () => { - electrumClient.close() - }, - () => { - electrumClient.close() - } - ) + // Ensure we clean up the connection once all is said and done. + result.then(cleanup, cleanup) return result }, From 6a66581e3ac61785046c25b36302405b13a0069b Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:19:59 -0500 Subject: [PATCH 26/52] Use single Electrum connection for refunds script Using the new withElectrumClient nested reuse, a single connection can be used for all refunds handled in the script, instead of many connections being required throughout. --- bin/refunds.js | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index e0360963..c09e3e59 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -868,6 +868,7 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { } } ] + const processThrough = async ( /** @type {any} */ inputData, /** @type {(function(any):Promise)[]} */ processors @@ -886,8 +887,6 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { }, inputData) } - // @ts-ignore No really, this is a valid config. - BitcoinHelpers.electrumConfig = AvailableBitcoinConfigs["1"].electrum const basicInfo = await processThrough({ keep }, genericStatusProcessors) if (basicInfo.status == "terminated") { @@ -912,17 +911,23 @@ run(() => { return unspaced[0].toLowerCase() + unspaced.slice(1) }, complete: ({ data }) => { - 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") - ) - }) + // @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) { From 0e63c6947be41a95ef7268d49475c99cf08dbfb8 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:32:35 -0500 Subject: [PATCH 27/52] Look up funding info before building liquidation split tx Without this, the funding info is unavailable and the liquidation split transaction can't be constructed properly. --- bin/refunds.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bin/refunds.js b/bin/refunds.js index c09e3e59..311a8fdd 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -808,6 +808,18 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { } } }, + 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) From a5a2e53078bc42c7f5a466d5375d2891c8b72b7d Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:33:16 -0500 Subject: [PATCH 28/52] Fix misfunding error messages All misfunding-related errors said the same thing, which was related to liquidation. They now correctly reflect the stage of misfund processing that they are reporting an error on. --- bin/refunds.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 311a8fdd..bb0e737b 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -849,7 +849,7 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { } else { return { ...row, - error: "no beneficiary" + error: "no misfunder" } } }, @@ -861,8 +861,7 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { } else { return { ...row, - error: - "failed to build and broadcast liquidation split BTC transaction" + error: "failed to find funding info for keep" } } }, @@ -874,8 +873,7 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { } else { return { ...row, - error: - "failed to build and broadcast liquidation split BTC transaction" + error: "failed to build and broadcast refund BTC transaction" } } } From b44f580ee660f5856ec46efc58bf80d02ccaf630 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 10 Dec 2020 23:45:12 -0500 Subject: [PATCH 29/52] Use *pub metadata type to generate appropriate refund address Before, it was hardcoded to p2pkh. Now, an xpub yields p2pkh, ypub yields p2wpkh-p2sh, and zpub yields p2wpkh. --- bin/refunds.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index bb0e737b..a5b345a3 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -76,7 +76,7 @@ import WebsocketSubprovider from "web3-provider-engine/subproviders/websocket.js // @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, Purpose } = xpubLib +const { getExtPubKeyMetadata, addressFromExtPubKey } = xpubLib import TokenStakingJSON from "@keep-network/keep-core/artifacts/TokenStaking.json" import BondedECDSAKeepJSON from "@keep-network/keep-ecdsa/artifacts/BondedECDSAKeep.json" @@ -391,7 +391,7 @@ async function generateAddress(/** @type {string} */ beneficiary) { const derivedAddressInfo = addressFromExtPubKey({ extPubKey: beneficiary, keyIndex: addressIndex, - purpose: Purpose.P2PKH, + purpose: metadata.type, network: metadata.network }) latestAddress = derivedAddressInfo.address From e987c0c720c152ae41169e31c9091ff40e91027b Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Tue, 15 Dec 2020 15:51:17 -0500 Subject: [PATCH 30/52] Clean up typing for refunds processing function --- bin/refunds.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index a5b345a3..85728592 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -892,9 +892,12 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { return row } } catch (e) { - return { ...row, error: `Error processing transaction: ${e}` } + return Promise.resolve({ + ...row, + error: `Error processing transaction: ${e}` + }) } - }, inputData) + }, Promise.resolve(inputData)) } const basicInfo = await processThrough({ keep }, genericStatusProcessors) From 777af78755509a386cc78e34c2b05c233d3ad1b2 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Tue, 15 Dec 2020 15:52:18 -0500 Subject: [PATCH 31/52] Better missing key share errors in refunds Missing key shares are now called out, and too many key shares gets a separate error. --- bin/refunds.js | 58 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 85728592..e7a89a3f 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -212,15 +212,42 @@ function signDigest(keepAddress, digest) { /** * @param {string} keepAddress */ -async function keySharesReady(keepAddress) { +async function validateKeyShares(keepAddress) { const keepDirectory = (keyShareDirectory || "key-shares") + "/" + keepAddress - return ( - (await stat(keepDirectory)).isDirectory() && - (await readdir(keepDirectory)).length == 3 && - (await signDigest(keepAddress, "deadbeef")) !== null - ) -} + 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} */ @@ -771,11 +798,6 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { // data; if the updated row data includes an `error` key, subsequent // processors don't run. const genericStatusProcessors = [ - async (/** @type {any} */ row) => - ((await keySharesReady(row.keep)) && row) || { - ...row, - error: "missing key shares" - }, async (/** @type {any} */ row) => { const status = await keepStatusCompleted(row.keep) @@ -793,6 +815,18 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { } 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 = [ From d578f06ac9d53549ebf4ec9f5b76915fd283ace7 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Tue, 15 Dec 2020 16:06:25 -0500 Subject: [PATCH 32/52] Refunds handle close/exit event ordering better Sometimes the `exit` event on the process will fire before the stdout stream has finished reporting all of its output. In these cases, if the process completed successfully, the output needs to be allowed to finish before the public key and signature can be extracted. The code now waits for the `close` event on the stdout stream, and throws in cases where the final output cannot be split into public key and signature correctly. --- bin/refunds.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index e7a89a3f..81d5d119 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -192,8 +192,18 @@ function signDigest(keepAddress, digest) { process .on("exit", (code, signal) => { if (code === 0) { - const [publicKey, signature] = output.split("\t") - resolve({ signature: signature.trim(), publicKey: publicKey.trim() }) + process.stdout.on("close", () => { + const [publicKey, signature] = output.split("\t") + + if (publicKey && signature) { + resolve({ + signature: signature.trim(), + publicKey: publicKey.trim() + }) + } else { + reject(new Error(`Unexpected output:\n${allOutput}`)) + } + }) } else { reject( new Error( From 3f480021a5026fc246d9645f6968a13d021bde01 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Tue, 15 Dec 2020 16:07:48 -0500 Subject: [PATCH 33/52] Sort refund operator info for stable beneficiary ordering This makes it easier, when analyzing the CSV output, to see cases where multiple deposits are being refunded to one BTC address, or one deposit has multiple slots to one BTC address. Both are potential signs that there was an issue with beneficiary handling. --- bin/refunds.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 81d5d119..ee1f1708 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -587,8 +587,12 @@ async function beneficiariesAvailableAndSigned( ) { const keepContract = keepAt(keepAddress) - /** @type {[string,string,string]} */ - const operators = await keepContract.methods.getMembers().call() + 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 From 735c62b2b717d656ae36c2a89e2b840ff2f441ca Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:42:20 -0500 Subject: [PATCH 34/52] Fix owner lookups with TokenStakingEscrow The original approach was straight up broken, and didn't invoke resolveGrantee correctly to boot. Previously, the grant lookup would always resolve to the 0 grant. --- bin/owner-lookup.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bin/owner-lookup.js b/bin/owner-lookup.js index d736296d..34cf5fe9 100755 --- a/bin/owner-lookup.js +++ b/bin/owner-lookup.js @@ -177,11 +177,15 @@ async function resolveOwner(web3, contracts, owner, operator) { .call() return resolveOwner(web3, contracts, owner, operator) } else if (owner == TokenStakingEscrow.options.address) { - const grantId = await TokenStakingEscrow.methods - .depositGrantId(operator) - .call() + const { + returnValues: { grantId } + } = await EthereumHelpers.getExistingEvent( + TokenStakingEscrow, + "DepositRedelegated", + { newOperator: operator } + ) const { grantee } = await TokenGrant.methods.getGrant(grantId).call() - return resolveGrantee(grantee) + return resolveGrantee(web3, grantee) } 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. From 46a18f27bef1c6131e2cdfc404b1c66afd79e38c Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:42:37 -0500 Subject: [PATCH 35/52] Fix direct invocation of bin/owner-lookup.js --- bin/owner-lookup.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bin/owner-lookup.js b/bin/owner-lookup.js index 34cf5fe9..61ff7ef5 100755 --- a/bin/owner-lookup.js +++ b/bin/owner-lookup.js @@ -26,9 +26,8 @@ const ManagedGrantABI = ManagedGrantJSON.abi const utils = Web3.utils let standalone = false -let args = process.argv.slice(2) -if (process.argv[0].includes("owner-lookup.js")) { - args = process.argv.slice(1) // invoked directly, no node +const args = process.argv.slice(2) +if (process.argv.some(_ => _.includes("owner-lookup.js"))) { standalone = true } From d92579683771b42e4c8ee6964637adbfe9423437 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:44:00 -0500 Subject: [PATCH 36/52] Include refunds in CSV output for bin/liquidations.js The script probably needs to be renamed... --- bin/liquidations.js | 91 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/bin/liquidations.js b/bin/liquidations.js index 9c7c5708..e0d9d840 100755 --- a/bin/liquidations.js +++ b/bin/liquidations.js @@ -11,6 +11,8 @@ // //// import https from "https" import moment from "moment" +import BitcoinHelpers, { BitcoinNetwork } from "../src/BitcoinHelpers.js" +import AvailableBitcoinConfigs from "./config.json" let args = process.argv.slice(2) if (process.argv[0].includes("liquidations.js")) { @@ -34,7 +36,7 @@ const fields = (args[1] || "operator,owner,beneficiary,keep") run(async () => { const liquidations = await queryLiquidations(startDate) - const rows = liquidations.reduce( + const liquidationRows = liquidations.reduce( ( rows, { @@ -54,10 +56,50 @@ run(async () => { }) return rows }, - [fields] + /** @type {string[][]} */ ([]) ) - return rows.map(_ => _.join(",")).join("\n") + 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") }) /** @@ -127,6 +169,49 @@ async function queryLiquidations(startDate) { ).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 From 554996c3456d0c8a17b2214eab2700ef5d7b8a58 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:45:40 -0500 Subject: [PATCH 37/52] Fix date parsing and add --debug support By default, all console.log output goes to the debug output. By default, debug output is suppressed. --debug enables debug output. Also, fix moment handling of the passed date; it was always being parsed as an integer, which meant the start date was always effectively "since the system was turned on". --- bin/liquidations.js | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/bin/liquidations.js b/bin/liquidations.js index e0d9d840..0536d09e 100755 --- a/bin/liquidations.js +++ b/bin/liquidations.js @@ -13,22 +13,34 @@ 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 } -const startDate = isNaN(parseInt(args[0])) - ? moment(args[0]) - : moment.unix(parseInt(args[0])) +// 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 ${args[0]} is either invalid or not recent enough.`) + console.error( + `Start time ${remainingArgs[0]} is either invalid or not recent enough.` + ) process.exit(1) } const validFields = ["operator", "owner", "beneficiary", "keep"] -const fields = (args[1] || "operator,owner,beneficiary,keep") +const fields = (remainingArgs[1] || "operator,owner,beneficiary,keep") .toLowerCase() .split(",") .filter(_ => validFields.includes(_)) @@ -109,12 +121,17 @@ run(async () => { * 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) } From 6c5694c8dc8068cdb517e72a1dc8462fa130fad3 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:47:43 -0500 Subject: [PATCH 38/52] Fix race condition in keep-ecdsa output handling for refunds When keep-ecdsa was invoked and exited, sometimes the exit would happen before the process would finish notifying the JS process of output. The result would be partial output that could not be parsed correctly. To fix this, a 'close' event handler was added on the stdout stream when the process exited. Unfortunately, this broke behavior when stdout had already been closed, and made the whole process hang. Whether or not the output has ended is now tracked, and if it has it is immediately processed on process exit. If not, an event handler is attached to process the output at that point. --- bin/refunds.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index ee1f1708..8abd27ed 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -174,6 +174,7 @@ function signDigest(keepAddress, digest) { return new Promise((resolve, reject) => { let output = "" let allOutput = "" + let outputFinished = false const process = spawn(keepEcdsaClientPath || "keep-ecdsa", [ "signing", "sign-digest", @@ -188,11 +189,12 @@ function signDigest(keepAddress, digest) { process.stderr.on("data", chunk => { allOutput += chunk }) + process.stdout.on("end", () => (outputFinished = true)) process .on("exit", (code, signal) => { if (code === 0) { - process.stdout.on("close", () => { + const processOutput = () => { const [publicKey, signature] = output.split("\t") if (publicKey && signature) { @@ -203,7 +205,12 @@ function signDigest(keepAddress, digest) { } else { reject(new Error(`Unexpected output:\n${allOutput}`)) } - }) + } + if (outputFinished) { + processOutput() + } else { + process.stdout.on("close", processOutput) + } } else { reject( new Error( From 6796c225e9a8b6df7ad5ea39b16880a60a72c2b3 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:49:18 -0500 Subject: [PATCH 39/52] Rework refunds *pub handling to generate addresses properly Because refunds were running in parallel, there was no guarantee that two refunds would use separate generated addresses from a *pub. Refunds are now processed serially. Beneficiary addresses also track their lastIndex for *pubs, so that already-checked addresses can be skipped on subsequent refunds. --- bin/refunds.js | 183 +++++++++++++++++++++++++++++-------------------- 1 file changed, 107 insertions(+), 76 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 8abd27ed..d0917d19 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -420,17 +420,20 @@ async function keepHoldsBtc(keepAddress) { return { bitcoinAddress, btcBalance } } -/** @type {{[operatorAddress: string]: string}} */ +/** @type {{[operatorAddress: string]: { beneficiary: string, latestIndex: number}} */ const operatorBeneficiaries = {} -async function generateAddress(/** @type {string} */ beneficiary) { +async function generateAddress( + /** @type {string} */ beneficiary, + /** @type {number} */ latestIndex +) { if (!beneficiary.match(/^.pub/)) { - return beneficiary // standard address, always returns itself + return { latestAddress: beneficiary, latestIndex } // standard address, always returns itself } const metadata = getExtPubKeyMetadata(beneficiary) let latestAddress = "" - let addressIndex = 0 + let addressIndex = latestIndex do { const derivedAddressInfo = addressFromExtPubKey({ extPubKey: beneficiary, @@ -443,92 +446,114 @@ async function generateAddress(/** @type {string} */ beneficiary) { addressIndex++ } while (await BitcoinHelpers.Transaction.find(latestAddress, 0)) - return latestAddress + return { latestAddress, latestIndex: addressIndex } } /** * @param {string} operatorAddress */ async function readBeneficiary(operatorAddress) { - if (operatorBeneficiaries[operatorAddress]) { - return generateAddress(operatorBeneficiaries[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 + } - const beneficiaryFile = - (beneficiaryDirectory || "beneficiaries") + - "/beneficiary-" + - operatorAddress.toLowerCase() + - ".json" + /** @type {{msg: string, sig: string, address: string}} */ + const jsonContents = JSON.parse( + await readFile(beneficiaryFile, { encoding: "utf-8" }) + ) - // If it doesn't exist, return empty. - try { - if (!(await stat(beneficiaryFile)).isFile()) { - return null + if (!jsonContents.msg || !jsonContents.sig || !jsonContents.address) { + throw new Error( + `Invalid format for ${operatorAddress}: message, signature, or signing address missing.` + ) } - } catch (e) { - return null - } - /** @type {{msg: string, sig: string, address: string}} */ - const jsonContents = JSON.parse( - await readFile(beneficiaryFile, { encoding: "utf-8" }) - ) + // 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 + } - if (!jsonContents.msg || !jsonContents.sig || !jsonContents.address) { - throw new Error( - `Invalid format for ${operatorAddress}: 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 for ${operatorAddress}.` + ) + } - 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.` + ) + } - if ( - recoveredAddress.toLowerCase() !== - (await beneficiaryOf(operatorAddress)).toLowerCase() && - recoveredAddress.toLowerCase() === - (await deepOwnerOf(operatorAddress)).toLowerCase() - ) { - 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|bc1)[A-Za-z0-9]{26,33}/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 pubs = [...jsonContents.msg.matchAll(/[xyz]pub[a-zA-Z0-9]+/g)].map( - _ => _[0] + const { latestAddress, latestIndex } = await generateAddress( + beneficiaryInfo.beneficiary, + beneficiaryInfo.latestIndex ) - 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) { - operatorBeneficiaries[operatorAddress] = pubs[0] - return generateAddress(pubs[0]) - } - const addresses = [ - ...jsonContents.msg.matchAll(/(?:1|3|bc1)[A-Za-z0-9]{26,33}/g) - ].map(_ => _[0]) - if (addresses.length > 1) { - throw new Error( - `Beneficiary message for ${operatorAddress} includes too many addresses: ${addresses}` - ) - } else if (addresses.length !== 0) { - operatorBeneficiaries[operatorAddress] = addresses[0] - return generateAddress(addresses[0]) - } + beneficiaryInfo.latestIndex = latestIndex + operatorBeneficiaries[operatorAddress] = beneficiaryInfo - throw new Error( - `Could not find a valid BTC address or *pub in signed message for ${operatorAddress}: ` + - `${jsonContents.msg}` - ) + return latestAddress } async function readRefundAddress(/** @type {string} */ depositAddress) { @@ -814,7 +839,11 @@ 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).map(async keep => { + 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. @@ -958,15 +987,17 @@ async function processKeeps(/** @type {{[any: string]: string}[]} */ keepRows) { const basicInfo = await processThrough({ keep }, genericStatusProcessors) if (basicInfo.status == "terminated") { - return processThrough(basicInfo, liquidationProcessors) + return rows.concat([ + await processThrough(basicInfo, liquidationProcessors) + ]) } else if (basicInfo.status == "closed") { - return processThrough(basicInfo, misfundProcessors) + return rows.concat([await processThrough(basicInfo, misfundProcessors)]) } else { - return basicInfo + return rows.concat([basicInfo]) } - }) + }, []) - return Promise.all(results) + return results } run(() => { From cac50d4bfd7fd99c3f4b9ea3479e8707e07310dd Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:49:36 -0500 Subject: [PATCH 40/52] Drop extra console.logs in refunds.js --- bin/refunds.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index d0917d19..42e2171e 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -717,7 +717,6 @@ async function buildAndBroadcastLiquidationSplit(/** @type {any} */ keepData) { signedTransaction } } catch (e) { - console.log(e) return { refundAmount, error: `Error signing: ${e}` } } } @@ -830,7 +829,6 @@ async function buildAndBroadcastRefund(/** @type {any} */ keepData) { signedTransaction } } catch (e) { - console.log(e) return { refundAmount, error: `Error signing: ${e}` } } } From 268d8800d00fc63edec44de112515e4de70dcf06 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:49:56 -0500 Subject: [PATCH 41/52] Reduce default refund fee to reflect current Bitcoin fee reqs --- bin/refunds.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 42e2171e..c8c229a1 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -668,8 +668,7 @@ async function buildAndBroadcastLiquidationSplit(/** @type {any} */ keepData) { const fee = parseInt(transactionFee || "0") || toBN(await (await tbtcConstants()).methods.getMinimumRedemptionFee().call()) - .muln(18) - .muln(5) + .muln(15) .toNumber() // Math this out in BN-land to minimize the likelihood of precision issues. From 47618bdc50adf824c992e4dcc7dec670c537b146 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:50:41 -0500 Subject: [PATCH 42/52] Move all ElectrumClient debug output to console.debug This is more reflective of the kind of output being used, and helps in cases where command-line tools suppress debug output. --- src/lib/ElectrumClient.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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}`) } From 4b08bdd02e75cce0ceaba50f47d0dc0ba69aa631 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Wed, 16 Dec 2020 18:51:27 -0500 Subject: [PATCH 43/52] Add support for end block in getExistingEvent(s) This allows for event searches to stop searching past a certain block, in addition to the existing functionality that allows starting the search at a certain block. --- src/EthereumHelpers.js | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) 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] } From 747e5ae95afdf040bff04e73a3a34efc53b25344 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 17 Dec 2020 11:19:39 -0500 Subject: [PATCH 44/52] Fix recognition of bech32 beneficiary addresses in refunds.js The regex in question was trying to overgeneralize between standard and bech32 addresses, and it was truncating some bech32 addresses in the process. --- bin/refunds.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/refunds.js b/bin/refunds.js index c8c229a1..cbda8b07 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -523,7 +523,9 @@ async function readBeneficiary(operatorAddress) { } } else { const addresses = [ - ...jsonContents.msg.matchAll(/(?:1|3|bc1)[A-Za-z0-9]{26,33}/g) + ...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( From b91560f579cb41e414fba3a80678bf61659e3061 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Tue, 12 Jan 2021 18:15:57 -0500 Subject: [PATCH 45/52] Update to latest subgraph for fund recovery CSV The old subgraph is no longer generating. --- bin/liquidations.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/liquidations.js b/bin/liquidations.js index 0536d09e..3c85f08f 100755 --- a/bin/liquidations.js +++ b/bin/liquidations.js @@ -238,7 +238,7 @@ function queryGraph(graphql) { return new Promise((resolve, reject) => { let responseContent = "" const request = https.request( - "https://api.thegraph.com/subgraphs/name/miracle2k/keep-network", + "https://api.thegraph.com/subgraphs/name/miracle2k/all-the-keeps", { method: "POST" }, response => { response.setEncoding("utf8") From 7d06af3a1fe14ebc37023bb93ef605e11efb66bd Mon Sep 17 00:00:00 2001 From: Albert Date: Tue, 26 Jan 2021 17:42:21 +0100 Subject: [PATCH 46/52] Remove unused package --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 72caefca..3a8e6fdc 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "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", From b8ec119c97f4912daf9e2d206c7af9256378dd46 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Fri, 29 Jan 2021 18:46:25 -0500 Subject: [PATCH 47/52] Fix BTC address regexp for misfund addresses This was already fixed for liquidation recovery, but the fix didn't get ported over to misfund address handling, so longer-than-36-character bech32 addresses were getting lopped off. --- bin/refunds.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/refunds.js b/bin/refunds.js index cbda8b07..79004923 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -602,7 +602,9 @@ async function readRefundAddress(/** @type {string} */ depositAddress) { } const addresses = [ - ...jsonContents.msg.matchAll(/(?:1|3|bc1)[A-Za-z0-9]{26,33}/g) + ...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}`) From aacf98ae51e0e6f031b12e713eb25df7681e8e3a Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Fri, 29 Jan 2021 18:47:07 -0500 Subject: [PATCH 48/52] In refunds CSV, unavailable beneficiaries are joined by ; These were joined by , due to default array serialization, but of course this wreaked absolute havoc on CSV parsing. --- bin/refunds.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/refunds.js b/bin/refunds.js index 79004923..340d1775 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -643,7 +643,9 @@ async function beneficiariesAvailableAndSigned( if (unavailableBeneficiaries.length > 0) { return { - error: `not all beneficiaries are available (missing ${unavailableBeneficiaries})` + error: `not all beneficiaries are available (missing ${unavailableBeneficiaries.join( + "; " + )})` } } From 4d28dc4d6c2db2d5430ad03ccfd891b1856a5f4c Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Fri, 29 Jan 2021 18:49:25 -0500 Subject: [PATCH 49/52] Add copied stake status script This script is mostly a helper to look at the StakingPortBacker and check whether the ported stakes still need to be repaid. Notably, it's really a Keep script, not a tBTC script, but it reuses infrastructure that tbtc.js has that doesn't exist on the Keep repo yet. --- bin/copied-stake-status.js | 199 +++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100755 bin/copied-stake-status.js diff --git a/bin/copied-stake-status.js b/bin/copied-stake-status.js new file mode 100755 index 00000000..b0d4b029 --- /dev/null +++ b/bin/copied-stake-status.js @@ -0,0 +1,199 @@ +#!/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" + +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" + ) + + return ( + "operator,owner,oldAmount,amountCopied,availableBalance,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() + + return ( + already + + "\n" + + operatorAddress + + "," + + copyInfo.owner + + "," + + stakingInfo.amount + + "," + + copyInfo.amount + + "," + + ownerBalance + + "," + + 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) + }) +} From b69a3aa46507b3b22c02227fd70a9db4797c1270 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Mon, 22 Feb 2021 16:55:57 -0500 Subject: [PATCH 50/52] Add dry-run flag for bin/refunds.js This flag prevents broadcasting of the refund transaction, replacing its transaction ID in the final CSV with a note indicating that this is a dry run. --- bin/refunds.js | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/bin/refunds.js b/bin/refunds.js index 340d1775..daa102e6 100755 --- a/bin/refunds.js +++ b/bin/refunds.js @@ -3,6 +3,7 @@ // 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 @@ -43,6 +44,10 @@ // 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 @@ -53,15 +58,13 @@ // 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 a Bitcoin transaction -// sending the BTC held by the keep to the refund address. +// 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 -// -// All on-chain state checks on Ethereum require at least 100 confirmations; -// all on-chain state checks on Bitcoin require at least 6 confirmations. // //// import PapaParse from "papaparse" import { promises } from "fs" @@ -123,7 +126,7 @@ const { o: beneficiaryDirectory, m: misfundDirectory }, - remaining: commandArgs + remaining: booleanArgs } = findAndConsumeArgsValues( flagArgs, "--mnemonic", @@ -133,9 +136,16 @@ const { "-c", "-s", "-o", - "-m", - "-r" + "-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( @@ -709,10 +719,9 @@ async function buildAndBroadcastLiquidationSplit(/** @type {any} */ keepData) { { value: perBeneficiaryAmount, address: beneficiary3 } ) - // const { transactionID } = await BitcoinHelpers.Transaction.broadcast( - // signedTransaction - // ) - const transactionID = "lolnope" + const { transactionID } = dryRun + ? { transactionID: "dry run: transaction not broadcast" } + : await BitcoinHelpers.Transaction.broadcast(signedTransaction) return { perBeneficiaryAmount, @@ -821,10 +830,9 @@ async function buildAndBroadcastRefund(/** @type {any} */ keepData) { } ) - // const { transactionID } = await BitcoinHelpers.Transaction.broadcast( - // signedTransaction - // ) - const transactionID = "lolnope" + const { transactionID } = dryRun + ? { transactionID: "dry run: transaction not broadcast" } + : await BitcoinHelpers.Transaction.broadcast(signedTransaction) return { refundAmount, From 308fcd1628bbee1d86c1c263f5bc373d694ded09 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Sat, 6 Mar 2021 11:45:13 -0500 Subject: [PATCH 51/52] Allow owner and grant lookup for old staking contract By default, bin/owner-lookup.js continues to check the new staking contract for ownership info. However, if used as a library, it now allows the return of the grant type (as "no grant", "direct grant", and "managed grant") and allows the caller to substitute the old staking contract for the `TokenStaking` key of the passed contracts. To do this, the process for resolving TokenGrantStakes changes completely. The old staking contract provides no strategy for looking up an event for the stake delegation using just the operator address, meaning the owner is needed in order to properly resolve information about the grant---but this script uses the grant info to resolve the owner! Instead, the new approach directly reads the on-chain storage slots and heuristically determines if a contract is a TokenGrantStake. If it does, it assumes the fourth slot represents the grant id, and resolves the grant id in this way. --- bin/owner-lookup.js | 127 ++++++++++++++++++++++++++++++++------------ 1 file changed, 92 insertions(+), 35 deletions(-) diff --git a/bin/owner-lookup.js b/bin/owner-lookup.js index 61ff7ef5..395b1524 100755 --- a/bin/owner-lookup.js +++ b/bin/owner-lookup.js @@ -14,13 +14,13 @@ import { 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 TokenGrantABI = TokenGrantJSON.abi const ManagedGrantABI = ManagedGrantJSON.abi const utils = Web3.utils @@ -112,6 +112,11 @@ 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, @@ -153,6 +158,24 @@ export function lookupOwner( }) } +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 @@ -161,20 +184,44 @@ export function lookupOwner( * @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 - if ((await web3.eth.getStorageAt(owner, 0)) === "0x") { - return owner // owner is already a user-owned account + 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 resolveOwner(web3, contracts, owner, operator) + return resolveOwnerAndGrantType(web3, contracts, owner, operator) } else if (owner == TokenStakingEscrow.options.address) { const { returnValues: { grantId } @@ -184,54 +231,61 @@ async function resolveOwner(web3, contracts, owner, operator) { { newOperator: operator } ) const { grantee } = await TokenGrant.methods.getGrant(grantId).call() - return resolveGrantee(web3, grantee) + 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 { - const { - transactionHash - } = await EthereumHelpers.getExistingEvent( - TokenStaking, - "StakeDelegated", - { operator } - ) - const { logs } = await web3.eth.getTransactionReceipt(transactionHash) - const TokenGrantStakedABI = TokenGrantABI.filter( - _ => _.type == "event" && _.name == "TokenGrantStaked" - )[0] let grantId = null - // eslint-disable-next-line guard-for-in - for (const i in logs) { - const { data, topics } = logs[i] - // @ts-ignore Oh but there is a signature property on events foo'. - if (topics[0] == TokenGrantStakedABI.signature) { - const decoded = web3.eth.abi.decodeLog( - TokenGrantStakedABI.inputs, - data, - topics.slice(1) - ) - grantId = decoded.grantId - break - } + + // 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() - return resolveGrantee(web3, grantee) + 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 + return { owner, grantType: GrantType.None } } } } -async function resolveGrantee( +async function resolveGranteeAndGrantType( /** @type {Web3} */ web3, /** @type {string} */ grantee ) { if ((await web3.eth.getStorageAt(grantee, 0)) === "0x") { - return grantee // grantee is already a user-owned account + return { grantee, grantType: GrantType.SimpleGrant } // grantee is already a user-owned account } else { try { const grant = EthereumHelpers.buildContract( @@ -241,11 +295,14 @@ async function resolveGrantee( grantee ) - return await grant.methods.grantee().call() + 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 + return { grantee, grantType: GrantType.SimpleGrant } } } } From 27f24cfb4a4ae7701593040a9dec31924500eaac Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Sat, 6 Mar 2021 11:46:18 -0500 Subject: [PATCH 52/52] Copied stake status script resolves all owners and grant type The new output includes the grant type (as "no grant"/"direct grant"/ "managed grant"), the owner (even if no stake copying occurred), and separately reports a boolean indicating if the owner used stake copying or not. --- bin/copied-stake-status.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/bin/copied-stake-status.js b/bin/copied-stake-status.js index b0d4b029..77238014 100755 --- a/bin/copied-stake-status.js +++ b/bin/copied-stake-status.js @@ -32,6 +32,7 @@ import { 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")) { @@ -141,8 +142,12 @@ run(async () => { "1" ) + const contractsForOwnerLookup = await contractsFromWeb3(web3) + // Run owner lookup on the original contract. + contractsForOwnerLookup.TokenStaking = otsContract + return ( - "operator,owner,oldAmount,amountCopied,availableBalance,paidBack,oldUndelegatedAt" + + "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. @@ -157,12 +162,22 @@ run(async () => { .balanceOf(copyInfo.owner) .call() + const copied = copyInfo.timestamp != 0 + + const { owner, grantType } = await lookupOwnerAndGrantType( + web3, + contractsForOwnerLookup, + operatorAddress + ) + return ( already + "\n" + operatorAddress + "," + - copyInfo.owner + + owner + + "," + + grantType + "," + stakingInfo.amount + "," + @@ -170,6 +185,8 @@ run(async () => { "," + ownerBalance + "," + + copied + + "," + copyInfo.paidBack + "," + stakingInfo.undelegatedAt