diff --git a/package-lock.json b/package-lock.json index d6c93ab16..5a1a2dda0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3103,15 +3103,6 @@ "dev": true, "optional": true }, - "async-mutex": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.1.tgz", - "integrity": "sha512-vRfQwcqBnJTLzVQo72Sf7KIUbcSUP5hNchx6udI1U6LuPQpfePgdjJzlCe76yFZ8pxlLjn9lwcl/Ya0TSOv0Tw==", - "dev": true, - "requires": { - "tslib": "^2.1.0" - } - }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", diff --git a/package.json b/package.json index bb17634fb..5ffed06f4 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^4.15.1", "@typescript-eslint/parser": "^4.15.1", - "async-mutex": "^0.3.0", "babel-loader": "^8.2.2", "babel-plugin-add-module-exports": "^1.0.4", "babel-plugin-transform-class-properties": "^6.24.1", @@ -114,6 +113,7 @@ "@ethersproject/sha2": "^5.0.8", "@ethersproject/transactions": "^5.0.10", "@ethersproject/wallet": "^5.0.11", + "@ethersproject/web": "^5.0.13", "debug": "^4.3.2", "eventemitter3": "^4.0.7", "lodash.uniqueid": "^4.0.1", diff --git a/src/Config.ts b/src/Config.ts index 576b3dcce..54d903dc9 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -1,65 +1,55 @@ import qs from 'qs' -import Debug from 'debug' import { ControlLayer, MessageLayer } from 'streamr-client-protocol' import { ExternalProvider, JsonRpcFetchFunc } from '@ethersproject/providers' import { BigNumber } from '@ethersproject/bignumber' -import { O } from 'ts-toolbelt' -import { getVersionString, counterId } from './utils' +import { getVersionString } from './utils' +import { ConnectionInfo } from '@ethersproject/web' import { Todo } from './types' export type EthereumConfig = ExternalProvider|JsonRpcFetchFunc export type StreamrClientOptions = { - id?: string - debug?: Debug.Debugger, - auth?: { + auth: { privateKey?: string ethereum?: EthereumConfig apiKey?: string username?: string password?: string } - url?: string - restUrl?: string - streamrNodeAddress?: string - autoConnect?: boolean - autoDisconnect?: boolean - orderMessages?: boolean, - retryResendAfter?: number, - gapFillTimeout?: number, - maxGapRequests?: number, - maxPublishQueueSize?: number, - publishWithSignature?: Todo, - verifySignatures?: Todo, - publisherStoreKeyHistory?: boolean, - groupKeys?: Todo - keyExchange?: Todo - mainnet?: Todo - sidechain?: { - url?: string - }, + url: string + restUrl: string + streamrNodeAddress: string + autoConnect: boolean + autoDisconnect: boolean + orderMessages: boolean + retryResendAfter: number + gapFillTimeout: number + maxGapRequests: number + maxPublishQueueSize: number + publishWithSignature: Todo + verifySignatures: Todo + publisherStoreKeyHistory: boolean + groupKeys: Todo + keyExchange: Todo + mainnet?: ConnectionInfo|string + sidechain?: ConnectionInfo|string dataUnion?: string - tokenAddress?: string, - minimumWithdrawTokenWei?: BigNumber|number|string, - sidechainTokenAddress?: string - factoryMainnetAddress?: string - factorySidechainAddress?: string - payForSignatureTransport?: boolean - cache?: { - maxSize?: number, - maxAge?: number + tokenAddress: string, + minimumWithdrawTokenWei?: BigNumber|number|string + factoryMainnetAddress: string + factorySidechainAddress: string + payForSignatureTransport: boolean + cache: { + maxSize: number, + maxAge: number } } -export type StreamrClientConfig = O.Compulsory const { ControlMessage } = ControlLayer const { StreamMessage } = MessageLayer export default function ClientConfig(opts: Partial = {}) { - const { id = counterId('StreamrClient') } = opts - - const defaults = { - debug: Debug(id), + const defaults: StreamrClientOptions = { // Authentication: identity used by this StreamrClient instance auth: {}, // can contain member privateKey or (window.)ethereum @@ -87,16 +77,11 @@ export default function ClientConfig(opts: Partial = {}) { // Ethereum and Data Union related options // For ethers.js provider params, see https://docs.ethers.io/ethers.js/v5-beta/api-providers.html#provider mainnet: undefined, // Default to ethers.js default provider settings - sidechain: { - url: undefined, // TODO: add our default public service sidechain node, also find good PoA params below - // timeout: - // pollingInterval: - }, + sidechain: undefined, // TODO: add our default public service sidechain node, also find good PoA params below tokenAddress: '0x0Cf0Ee63788A0849fE5297F3407f701E122cC023', minimumWithdrawTokenWei: '1000000', // Threshold value set in AMB configs, smallest token amount to pass over the bridge - sidechainTokenAddress: undefined, // TODO // sidechain token - factoryMainnetAddress: undefined, // TODO // Data Union factory that creates a new Data Union - factorySidechainAddress: undefined, + factoryMainnetAddress: 'TODO', // TODO // Data Union factory that creates a new Data Union + factorySidechainAddress: 'TODO', payForSignatureTransport: true, // someone must pay for transporting the withdraw tx to mainnet, either us or bridge operator cache: { maxSize: 10000, @@ -104,7 +89,7 @@ export default function ClientConfig(opts: Partial = {}) { } } - const options: StreamrClientConfig = { + const options: StreamrClientOptions = { ...defaults, ...opts, cache: { diff --git a/src/Connection.js b/src/Connection.js index d804ec041..ddff7ecde 100644 --- a/src/Connection.js +++ b/src/Connection.js @@ -63,12 +63,8 @@ async function OpenWebSocket(url, opts, ...args) { }) // attach debug - if (opts && opts.debug) { - socket.debug = opts.debug.extend(socket.id) - socket.debug.color = opts.debug.color // use existing colour - } else { - socket.debug = Debug('StreamrClient::ws').extend(socket.id) - } + socket.debug = opts.debug.extend(socket.id) + socket.debug.color = opts.debug.color // use existing colour } catch (err) { reject(err) } @@ -292,15 +288,9 @@ export default class Connection extends EventEmitter { })) } - constructor(options = {}) { + constructor(options = {}, client) { super() - const id = counterId(this.constructor.name) - /* istanbul ignore next */ - if (options.debug) { - this._debug = options.debug.extend(id) - } else { - this._debug = Debug(`StreamrClient::${id}`) - } + this._debug = client.debug.extend(counterId(this.constructor.name)) this.options = options this.options.autoConnect = !!this.options.autoConnect diff --git a/src/StreamrClient.ts b/src/StreamrClient.ts index 4188b71ce..3f0202ac4 100644 --- a/src/StreamrClient.ts +++ b/src/StreamrClient.ts @@ -4,7 +4,7 @@ import Debug from 'debug' import { counterId, uuid, CacheAsyncFn } from './utils' import { validateOptions } from './stream/utils' -import Config, { StreamrClientOptions, StreamrClientConfig } from './Config' +import Config, { StreamrClientOptions } from './Config' import StreamrEthereum from './Ethereum' import Session from './Session' import Connection, { ConnectionError } from './Connection' @@ -14,8 +14,10 @@ import { getUserId } from './user' import { Todo, MaybeAsync } from './types' import { StreamEndpoints } from './rest/StreamEndpoints' import { LoginEndpoints } from './rest/LoginEndpoints' -import { DataUnionEndpoints } from './rest/DataUnionEndpoints' import { DataUnion, DataUnionDeployOptions } from './dataunion/DataUnion' +import { BigNumber } from '@ethersproject/bignumber' +import { getAddress } from '@ethersproject/address' +import { Contract } from '@ethersproject/contracts' // TODO get metadata type from streamr-protocol-js project (it doesn't export the type definitions yet) export type OnMessageCallback = MaybeAsync<(message: any, metadata: any) => void> @@ -32,8 +34,8 @@ export { StreamrClientOptions } class StreamrConnection extends Connection { // TODO define args type when we convert Connection class to TypeScript - constructor(...args: any) { - super(...args) + constructor(options: Todo, client: StreamrClient) { + super(options, client) this.on('message', this.onConnectionMessage) } @@ -137,13 +139,13 @@ function Plugin(targetInstance: any, srcInstance: any) { } // these are mixed in via Plugin function above -interface StreamrClient extends StreamEndpoints, LoginEndpoints, DataUnionEndpoints {} +interface StreamrClient extends StreamEndpoints, LoginEndpoints {} // eslint-disable-next-line no-redeclare class StreamrClient extends EventEmitter { id: string debug: Debug.Debugger - options: StreamrClientConfig + options: StreamrClientOptions session: Session connection: StreamrConnection publisher: Todo @@ -152,18 +154,13 @@ class StreamrClient extends EventEmitter { ethereum: StreamrEthereum streamEndpoints: StreamEndpoints loginEndpoints: LoginEndpoints - dataUnionEndpoints: DataUnionEndpoints constructor(options: Partial = {}, connection?: StreamrConnection) { super() this.id = counterId(`${this.constructor.name}:${uid}`) this.debug = Debug(this.id) - this.options = Config({ - id: this.id, - debug: this.debug, - ...options, - }) + this.options = Config(options) this.debug('new StreamrClient %s: %o', this.id, { version: process.env.version, @@ -182,7 +179,7 @@ class StreamrClient extends EventEmitter { this.on('error', this._onError) // attach before creating sub-components incase they fire error events this.session = new Session(this, this.options.auth) - this.connection = connection || new StreamrConnection(this.options) + this.connection = connection || new StreamrConnection(this.options, this) this.connection .on('connected', this.onConnectionConnected) @@ -195,7 +192,6 @@ class StreamrClient extends EventEmitter { this.streamEndpoints = Plugin(this, new StreamEndpoints(this)) this.loginEndpoints = Plugin(this, new LoginEndpoints(this)) - this.dataUnionEndpoints = Plugin(this, new DataUnionEndpoints(this)) this.cached = new StreamrCached(this) } @@ -380,18 +376,42 @@ class StreamrClient extends EventEmitter { return this.getAddress() } + /** + * Get token balance in "wei" (10^-18 parts) for given address + */ + async getTokenBalance(address: string): Promise { + const { tokenAddress } = this.options + if (!tokenAddress) { + throw new Error('StreamrClient has no tokenAddress configuration.') + } + const addr = getAddress(address) + const provider = this.ethereum.getMainnetProvider() + + const token = new Contract(tokenAddress, [{ + name: 'balanceOf', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint256' }], + constant: true, + payable: false, + stateMutability: 'view', + type: 'function' + }], provider) + return token.balanceOf(addr) + } + getDataUnion(contractAddress: string) { - return new DataUnion(contractAddress, undefined, this.dataUnionEndpoints) + return DataUnion._fromContractAddress(contractAddress, this) // eslint-disable-line no-underscore-dangle } async deployDataUnion(options?: DataUnionDeployOptions) { - const contract = await this.dataUnionEndpoints.deployDataUnionContract(options) - return new DataUnion(contract.address, contract.sidechain.address, this.dataUnionEndpoints) + return DataUnion._deploy(options, this) // eslint-disable-line no-underscore-dangle } _getDataUnionFromName({ dataUnionName, deployerAddress }: { dataUnionName: string, deployerAddress: string}) { - const contractAddress = this.dataUnionEndpoints.calculateDataUnionMainnetAddress(dataUnionName, deployerAddress) - return this.getDataUnion(contractAddress) + return DataUnion._fromName({ // eslint-disable-line no-underscore-dangle + dataUnionName, + deployerAddress + }, this) } static generateEthereumAccount() { diff --git a/src/dataunion/Contracts.ts b/src/dataunion/Contracts.ts new file mode 100644 index 000000000..e190f44ba --- /dev/null +++ b/src/dataunion/Contracts.ts @@ -0,0 +1,329 @@ +import { getCreate2Address, isAddress } from '@ethersproject/address' +import { arrayify, hexZeroPad } from '@ethersproject/bytes' +import { Contract } from '@ethersproject/contracts' +import { keccak256 } from '@ethersproject/keccak256' +import { defaultAbiCoder } from '@ethersproject/abi' +import { verifyMessage } from '@ethersproject/wallet' +import debug from 'debug' +import { EthereumAddress, Todo } from '../types' +import { dataUnionMainnetABI, dataUnionSidechainABI, factoryMainnetABI, mainnetAmbABI, sidechainAmbABI } from './abi' +import { until } from '../utils' +import { BigNumber } from '@ethersproject/bignumber' +import StreamrEthereum from '../Ethereum' +import StreamrClient from '../StreamrClient' + +const log = debug('StreamrClient::DataUnion') + +export class Contracts { + + ethereum: StreamrEthereum + factoryMainnetAddress: string + factorySidechainAddress: string + cachedSidechainAmb?: Todo + + constructor(client: StreamrClient) { + this.ethereum = client.ethereum + this.factoryMainnetAddress = client.options.factoryMainnetAddress + this.factorySidechainAddress = client.options.factorySidechainAddress + } + + async fetchDataUnionMainnetAddress( + dataUnionName: string, + deployerAddress: EthereumAddress + ): Promise { + const provider = this.ethereum.getMainnetProvider() + const factoryMainnet = new Contract(this.factoryMainnetAddress, factoryMainnetABI, provider) + return factoryMainnet.mainnetAddress(deployerAddress, dataUnionName) + } + + getDataUnionMainnetAddress(dataUnionName: string, deployerAddress: EthereumAddress) { + if (!this.factoryMainnetAddress) { + throw new Error('StreamrClient has no factoryMainnetAddress configuration.') + } + // NOTE! this must be updated when DU sidechain smartcontract changes: keccak256(CloneLib.cloneBytecode(data_union_mainnet_template)); + const codeHash = '0x50a78bac973bdccfc8415d7d9cfd62898b8f7cf6e9b3a15e7d75c0cb820529eb' + const salt = keccak256(defaultAbiCoder.encode(['string', 'address'], [dataUnionName, deployerAddress])) + return getCreate2Address(this.factoryMainnetAddress, salt, codeHash) + } + + async fetchDataUnionSidechainAddress(duMainnetAddress: EthereumAddress): Promise { + const provider = this.ethereum.getMainnetProvider() + const factoryMainnet = new Contract(this.factoryMainnetAddress, factoryMainnetABI, provider) + return factoryMainnet.sidechainAddress(duMainnetAddress) + } + + getDataUnionSidechainAddress(mainnetAddress: EthereumAddress) { + if (!this.factorySidechainAddress) { + throw new Error('StreamrClient has no factorySidechainAddress configuration.') + } + // NOTE! this must be updated when DU sidechain smartcontract changes: keccak256(CloneLib.cloneBytecode(data_union_sidechain_template)) + const codeHash = '0x040cf686e25c97f74a23a4bf01c29dd77e260c4b694f5611017ce9713f58de83' + return getCreate2Address(this.factorySidechainAddress, hexZeroPad(mainnetAddress, 32), codeHash) + } + + getMainnetContractReadOnly(contractAddress: EthereumAddress) { + if (isAddress(contractAddress)) { + const provider = this.ethereum.getMainnetProvider() + return new Contract(contractAddress, dataUnionMainnetABI, provider) + } + throw new Error(`${contractAddress} was not a good Ethereum address`) + } + + getMainnetContract(contractAddress: EthereumAddress) { + const du = this.getMainnetContractReadOnly(contractAddress) + const signer = this.ethereum.getSigner() + return du.connect(signer) + } + + async getSidechainContract(contractAddress: EthereumAddress) { + const signer = await this.ethereum.getSidechainSigner() + const duMainnet = this.getMainnetContractReadOnly(contractAddress) + const duSidechainAddress = this.getDataUnionSidechainAddress(duMainnet.address) + const duSidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, signer) + return duSidechain + } + + async getSidechainContractReadOnly(contractAddress: EthereumAddress) { + const provider = this.ethereum.getSidechainProvider() + const duMainnet = this.getMainnetContractReadOnly(contractAddress) + const duSidechainAddress = this.getDataUnionSidechainAddress(duMainnet.address) + const duSidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, provider) + return duSidechain + } + + // Find the Asyncronous Message-passing Bridge sidechain ("home") contract + async getSidechainAmb() { + if (!this.cachedSidechainAmb) { + const getAmbPromise = async () => { + const mainnetProvider = this.ethereum.getMainnetProvider() + const factoryMainnet = new Contract(this.factoryMainnetAddress, factoryMainnetABI, mainnetProvider) + const sidechainProvider = this.ethereum.getSidechainProvider() + const factorySidechainAddress = await factoryMainnet.data_union_sidechain_factory() // TODO use getDataUnionSidechainAddress() + const factorySidechain = new Contract(factorySidechainAddress, [{ + name: 'amb', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' + }], sidechainProvider) + const sidechainAmbAddress = await factorySidechain.amb() + return new Contract(sidechainAmbAddress, sidechainAmbABI, sidechainProvider) + } + this.cachedSidechainAmb = getAmbPromise() + this.cachedSidechainAmb = await this.cachedSidechainAmb // eslint-disable-line require-atomic-updates + } + return this.cachedSidechainAmb + } + + async getMainnetAmb() { + const mainnetProvider = this.ethereum.getMainnetProvider() + const factoryMainnet = new Contract(this.factoryMainnetAddress, factoryMainnetABI, mainnetProvider) + const mainnetAmbAddress = await factoryMainnet.amb() + return new Contract(mainnetAmbAddress, mainnetAmbABI, mainnetProvider) + } + + async requiredSignaturesHaveBeenCollected(messageHash: Todo) { + const sidechainAmb = await this.getSidechainAmb() + const requiredSignatureCount = await sidechainAmb.requiredSignatures() + + // Bit 255 is set to mark completion, double check though + const sigCountStruct = await sidechainAmb.numMessagesSigned(messageHash) + const collectedSignatureCount = sigCountStruct.mask(255) + const markedComplete = sigCountStruct.shr(255).gt(0) + + log(`${collectedSignatureCount.toString()} out of ${requiredSignatureCount.toString()} collected`) + if (markedComplete) { log('All signatures collected') } + return markedComplete + } + + // move signatures from sidechain to mainnet + async transportSignatures(messageHash: string) { + const sidechainAmb = await this.getSidechainAmb() + const message = await sidechainAmb.message(messageHash) + const messageId = '0x' + message.substr(2, 64) + const sigCountStruct = await sidechainAmb.numMessagesSigned(messageHash) + const collectedSignatureCount = sigCountStruct.mask(255).toNumber() + + log(`${collectedSignatureCount} signatures reported, getting them from the sidechain AMB...`) + const signatures = await Promise.all(Array(collectedSignatureCount).fill(0).map(async (_, i) => sidechainAmb.signature(messageHash, i))) + + const [vArray, rArray, sArray]: Todo = [[], [], []] + signatures.forEach((signature: string, i) => { + log(` Signature ${i}: ${signature} (len=${signature.length}=${signature.length / 2 - 1} bytes)`) + rArray.push(signature.substr(2, 64)) + sArray.push(signature.substr(66, 64)) + vArray.push(signature.substr(130, 2)) + }) + const packedSignatures = BigNumber.from(signatures.length).toHexString() + vArray.join('') + rArray.join('') + sArray.join('') + log(`All signatures packed into one: ${packedSignatures}`) + + // Gas estimation also checks that the transaction would succeed, and provides a helpful error message in case it would fail + const mainnetAmb = await this.getMainnetAmb() + log(`Estimating gas using mainnet AMB @ ${mainnetAmb.address}, message=${message}`) + let gasLimit + try { + // magic number suggested by https://github.com/poanetwork/tokenbridge/blob/master/oracle/src/utils/constants.js + gasLimit = BigNumber.from(await mainnetAmb.estimateGas.executeSignatures(message, packedSignatures)).add(200000) + log(`Calculated gas limit: ${gasLimit.toString()}`) + } catch (e) { + // Failure modes from https://github.com/poanetwork/tokenbridge/blob/master/oracle/src/events/processAMBCollectedSignatures/estimateGas.js + log('Gas estimation failed: Check if the message was already processed') + const alreadyProcessed = await mainnetAmb.relayedMessages(messageId) + if (alreadyProcessed) { + log(`WARNING: Tried to transport signatures but they have already been transported (Message ${messageId} has already been processed)`) + log('This could happen if payForSignatureTransport=true, but bridge operator also pays for signatures, and got there before your client') + return null + } + + log('Gas estimation failed: Check if number of signatures is enough') + const mainnetProvider = this.ethereum.getMainnetProvider() + const validatorContractAddress = await mainnetAmb.validatorContract() + const validatorContract = new Contract(validatorContractAddress, [{ + name: 'isValidator', + inputs: [{ type: 'address' }], + outputs: [{ type: 'bool' }], + stateMutability: 'view', + type: 'function' + }, { + name: 'requiredSignatures', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' + }], mainnetProvider) + const requiredSignatures = await validatorContract.requiredSignatures() + if (requiredSignatures.gt(signatures.length)) { + throw new Error('The number of required signatures does not match between sidechain(' + + signatures.length + ' and mainnet( ' + requiredSignatures.toString()) + } + + log('Gas estimation failed: Check if all the signatures were made by validators') + log(` Recover signer addresses from signatures [${signatures.join(', ')}]`) + const signers = signatures.map((signature) => verifyMessage(arrayify(message), signature)) + log(` Check that signers are validators [[${signers.join(', ')}]]`) + const isValidatorArray = await Promise.all(signers.map((address) => [address, validatorContract.isValidator(address)])) + const nonValidatorSigners = isValidatorArray.filter(([, isValidator]) => !isValidator) + if (nonValidatorSigners.length > 0) { + throw new Error(`Following signers are not listed as validators in mainnet validator contract at ${validatorContractAddress}:\n - ` + + nonValidatorSigners.map(([address]) => address).join('\n - ')) + } + + throw new Error(`Gas estimation failed: Unknown error while processing message ${message} with ${e.stack}`) + } + + const signer = this.ethereum.getSigner() + log(`Sending message from signer=${await signer.getAddress()}`) + const txAMB = await mainnetAmb.connect(signer).executeSignatures(message, packedSignatures) + const trAMB = await txAMB.wait() + return trAMB + } + + async payForSignatureTransport(tr: { events: any[] }, options: { pollingIntervalMs?: number, retryTimeoutMs?: number } = {}) { + const { + pollingIntervalMs = 1000, + retryTimeoutMs = 60000, + } = options + log(`Got receipt, filtering UserRequestForSignature from ${tr.events.length} events...`) + // event UserRequestForSignature(bytes32 indexed messageId, bytes encodedData); + const sigEventArgsArray = tr.events.filter((e: Todo) => e.event === 'UserRequestForSignature').map((e: Todo) => e.args) + if (sigEventArgsArray.length < 1) { + throw new Error("No UserRequestForSignature events emitted from withdraw transaction, can't transport withdraw to mainnet") + } + /* eslint-disable no-await-in-loop */ + // eslint-disable-next-line no-restricted-syntax + for (const eventArgs of sigEventArgsArray) { + const messageId = eventArgs[0] + const messageHash = keccak256(eventArgs[1]) + + log(`Waiting until sidechain AMB has collected required signatures for hash=${messageHash}...`) + await until(async () => this.requiredSignaturesHaveBeenCollected(messageHash), pollingIntervalMs, retryTimeoutMs) + + log(`Checking mainnet AMB hasn't already processed messageId=${messageId}`) + const mainnetAmb = await this.getMainnetAmb() + const alreadySent = await mainnetAmb.messageCallStatus(messageId) + const failAddress = await mainnetAmb.failedMessageSender(messageId) + if (alreadySent || failAddress !== '0x0000000000000000000000000000000000000000') { // zero address means no failed messages + log(`WARNING: Mainnet bridge has already processed withdraw messageId=${messageId}`) + log([ + 'This could happen if payForSignatureTransport=true, but bridge operator also pays for', + 'signatures, and got there before your client', + ].join(' ')) + continue + } + + log(`Transporting signatures for hash=${messageHash}`) + await this.transportSignatures(messageHash) + } + /* eslint-enable no-await-in-loop */ + } + + async deployDataUnion({ + ownerAddress, + agentAddressList, + duName, + deployerAddress, + adminFeeBN, + sidechainRetryTimeoutMs, + sidechainPollingIntervalMs, + confirmations, + gasPrice + }: { + ownerAddress: EthereumAddress, + agentAddressList: EthereumAddress[] + duName: string + deployerAddress: EthereumAddress + adminFeeBN: BigNumber + sidechainRetryTimeoutMs: number + sidechainPollingIntervalMs: number + confirmations: number + gasPrice?: BigNumber, + }) { + const mainnetProvider = this.ethereum.getMainnetProvider() + const mainnetWallet = this.ethereum.getSigner() + const sidechainProvider = this.ethereum.getSidechainProvider() + + const duMainnetAddress = await this.fetchDataUnionMainnetAddress(duName, deployerAddress) + const duSidechainAddress = await this.fetchDataUnionSidechainAddress(duMainnetAddress) + + if (await mainnetProvider.getCode(duMainnetAddress) !== '0x') { + throw new Error(`Mainnet data union "${duName}" contract ${duMainnetAddress} already exists!`) + } + + if (!isAddress(this.factoryMainnetAddress)) { + throw new Error('StreamrClient has invalid factoryMainnetAddress configuration.') + } + + if (await mainnetProvider.getCode(this.factoryMainnetAddress) === '0x') { + throw new Error(`Data union factory contract not found at ${this.factoryMainnetAddress}, check StreamrClient.options.factoryMainnetAddress!`) + } + + const factoryMainnet = new Contract(this.factoryMainnetAddress!, factoryMainnetABI, mainnetWallet) + const ethersOptions: any = {} + if (gasPrice) { + ethersOptions.gasPrice = gasPrice + } + const tx = await factoryMainnet.deployNewDataUnion( + ownerAddress, + adminFeeBN, + agentAddressList, + duName, + ethersOptions + ) + const tr = await tx.wait(confirmations) + + log(`Data Union "${duName}" (mainnet: ${duMainnetAddress}, sidechain: ${duSidechainAddress}) deployed to mainnet, waiting for side-chain...`) + await until( + async () => await sidechainProvider.getCode(duSidechainAddress) !== '0x', + sidechainRetryTimeoutMs, + sidechainPollingIntervalMs + ) + + const dataUnion = new Contract(duMainnetAddress, dataUnionMainnetABI, mainnetWallet) + // @ts-expect-error + dataUnion.deployTxReceipt = tr + // @ts-expect-error + dataUnion.sidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, sidechainProvider) + return dataUnion + + } +} diff --git a/src/dataunion/DataUnion.ts b/src/dataunion/DataUnion.ts index a336f2f16..70d72c1a4 100644 --- a/src/dataunion/DataUnion.ts +++ b/src/dataunion/DataUnion.ts @@ -1,9 +1,18 @@ +import { getAddress } from '@ethersproject/address' import { BigNumber } from '@ethersproject/bignumber' -import { DataUnionEndpoints } from '../rest/DataUnionEndpoints' +import { arrayify, hexZeroPad } from '@ethersproject/bytes' +import { Contract } from '@ethersproject/contracts' +import { TransactionReceipt, TransactionResponse } from '@ethersproject/providers' +import debug from 'debug' +import { Contracts } from './Contracts' +import StreamrClient from '../StreamrClient' +import { EthereumAddress, Todo } from '../types' +import { until, getEndpointUrl } from '../utils' +import authFetch from '../rest/authFetch' export interface DataUnionDeployOptions { - owner?: string, - joinPartAgents?: string[], + owner?: EthereumAddress, + joinPartAgents?: EthereumAddress[], dataUnionName?: string, adminFee?: number, sidechainPollingIntervalMs?: number, @@ -33,16 +42,41 @@ export interface DataUnionMemberListModificationOptions { confirmations?: number } +export interface DataUnionStats { + activeMemberCount: BigNumber, + inactiveMemberCount: BigNumber, + joinPartAgentCount: BigNumber, + totalEarnings: BigNumber, + totalWithdrawable: BigNumber, + lifetimeMemberEarnings: BigNumber +} + +export enum MemberStatus { + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', + NONE = 'NONE', +} + +export interface MemberStats { + status: MemberStatus + earningsBeforeLastJoin: BigNumber + totalEarnings: BigNumber + withdrawableEarnings: BigNumber +} + +const log = debug('StreamrClient::DataUnion') + export class DataUnion { - contractAddress: string - sidechainAddress: string - dataUnionEndpoints: DataUnionEndpoints + contractAddress: EthereumAddress + sidechainAddress: EthereumAddress + client: StreamrClient - constructor(contractAddress: string, sidechainAddress: string|undefined, dataUnionEndpoints: DataUnionEndpoints) { - this.contractAddress = contractAddress - this.sidechainAddress = sidechainAddress || dataUnionEndpoints.calculateDataUnionSidechainAddress(contractAddress) - this.dataUnionEndpoints = dataUnionEndpoints + constructor(contractAddress: EthereumAddress, sidechainAddress: EthereumAddress, client: StreamrClient) { + // validate and convert to checksum case + this.contractAddress = getAddress(contractAddress) + this.sidechainAddress = getAddress(sidechainAddress) + this.client = client } getAddress() { @@ -55,85 +89,474 @@ export class DataUnion { // Member functions - async join(secret?: string) { - return this.dataUnionEndpoints.join(secret, this.contractAddress) + /** + * Send a joinRequest, or get into data union instantly with a data union secret + */ + async join(secret?: string): Promise { + const memberAddress = this.client.getAddress() as string + const body: any = { + memberAddress + } + if (secret) { body.secret = secret } + + const url = getEndpointUrl(this.client.options.restUrl, 'dataunions', this.contractAddress, 'joinRequests') + const response = await authFetch( + url, + this.client.session, + { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + if (secret) { + await until(async () => this.isMember(memberAddress)) + } + return response } - async isMember(memberAddress: string) { - return this.dataUnionEndpoints.isMember(memberAddress, this.contractAddress) + async isMember(memberAddress: EthereumAddress): Promise { + const address = getAddress(memberAddress) + const duSidechain = await this.getContracts().getSidechainContractReadOnly(this.contractAddress) + const ACTIVE = 1 // memberData[0] is enum ActiveStatus {None, Active, Inactive} + const memberData = await duSidechain.memberData(address) + const state = memberData[0] + return (state === ACTIVE) } - async withdrawAll(options?: DataUnionWithdrawOptions) { - return this.dataUnionEndpoints.withdrawAll(this.contractAddress, options) + /** + * Withdraw all your earnings + * @returns receipt once withdraw is complete (tokens are seen in mainnet) + */ + async withdrawAll(options?: DataUnionWithdrawOptions): Promise { + const recipientAddress = this.client.getAddress() + return this._executeWithdraw( + () => this.getWithdrawAllTx(), + recipientAddress, + options + ) } - async withdrawAllTo(recipientAddress: string, options?: DataUnionWithdrawOptions) { - return this.dataUnionEndpoints.withdrawAllTo(recipientAddress, options, this.contractAddress) + /** + * Get the tx promise for withdrawing all your earnings + * @returns await on call .wait to actually send the tx + */ + private async getWithdrawAllTx(): Promise { + const signer = await this.client.ethereum.getSidechainSigner() + const address = await signer.getAddress() + const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) + + const withdrawable = await duSidechain.getWithdrawableEarnings(address) + if (withdrawable.eq(0)) { + throw new Error(`${address} has nothing to withdraw in (sidechain) data union ${duSidechain.address}`) + } + + if (this.client.options.minimumWithdrawTokenWei && withdrawable.lt(this.client.options.minimumWithdrawTokenWei)) { + throw new Error(`${address} has only ${withdrawable} to withdraw in ` + + `(sidechain) data union ${duSidechain.address} (min: ${this.client.options.minimumWithdrawTokenWei})`) + } + return duSidechain.withdrawAll(address, true) // sendToMainnet=true } - async signWithdrawAllTo(recipientAddress: string) { - return this.dataUnionEndpoints.signWithdrawAllTo(recipientAddress, this.contractAddress) + /** + * Withdraw earnings and "donate" them to the given address + * @returns get receipt once withdraw is complete (tokens are seen in mainnet) + */ + async withdrawAllTo( + recipientAddress: EthereumAddress, + options?: DataUnionWithdrawOptions + ): Promise { + const to = getAddress(recipientAddress) // throws if bad address + return this._executeWithdraw( + () => this.getWithdrawAllToTx(to), + to, + options + ) } - async signWithdrawAmountTo(recipientAddress: string, amountTokenWei: BigNumber|number|string) { - return this.dataUnionEndpoints.signWithdrawAmountTo(recipientAddress, amountTokenWei, this.contractAddress) + /** + * Withdraw earnings and "donate" them to the given address + * @param recipientAddress - the address to receive the tokens + * @returns await on call .wait to actually send the tx + */ + private async getWithdrawAllToTx(recipientAddress: EthereumAddress): Promise { + const signer = await this.client.ethereum.getSidechainSigner() + const address = await signer.getAddress() + const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) + const withdrawable = await duSidechain.getWithdrawableEarnings(address) + if (withdrawable.eq(0)) { + throw new Error(`${address} has nothing to withdraw in (sidechain) data union ${duSidechain.address}`) + } + return duSidechain.withdrawAllTo(recipientAddress, true) // sendToMainnet=true + } + + /** + * Member can sign off to "donate" all earnings to another address such that someone else + * can submit the transaction (and pay for the gas) + * This signature is only valid until next withdrawal takes place (using this signature or otherwise). + * Note that while it's a "blank cheque" for withdrawing all earnings at the moment it's used, it's + * invalidated by the first withdraw after signing it. In other words, any signature can be invalidated + * by making a "normal" withdraw e.g. `await streamrClient.withdrawAll()` + * Admin can execute the withdraw using this signature: ``` + * await adminStreamrClient.withdrawAllToSigned(memberAddress, recipientAddress, signature) + * ``` + * @param recipientAddress - the address authorized to receive the tokens + * @returns signature authorizing withdrawing all earnings to given recipientAddress + */ + async signWithdrawAllTo(recipientAddress: EthereumAddress): Promise { + return this.signWithdrawAmountTo(recipientAddress, BigNumber.from(0)) + } + + /** + * Member can sign off to "donate" specific amount of earnings to another address such that someone else + * can submit the transaction (and pay for the gas) + * This signature is only valid until next withdrawal takes place (using this signature or otherwise). + * @param recipientAddress - the address authorized to receive the tokens + * @param amountTokenWei - that the signature is for (can't be used for less or for more) + * @returns signature authorizing withdrawing all earnings to given recipientAddress + */ + async signWithdrawAmountTo( + recipientAddress: EthereumAddress, + amountTokenWei: BigNumber|number|string + ): Promise { + const to = getAddress(recipientAddress) // throws if bad address + const signer = this.client.ethereum.getSigner() // it shouldn't matter if it's mainnet or sidechain signer since key should be the same + const address = await signer.getAddress() + const duSidechain = await this.getContracts().getSidechainContractReadOnly(this.contractAddress) + const memberData = await duSidechain.memberData(address) + if (memberData[0] === '0') { throw new Error(`${address} is not a member in Data Union (sidechain address ${duSidechain.address})`) } + const withdrawn = memberData[3] + // @ts-expect-error + const message = to + hexZeroPad(amountTokenWei, 32).slice(2) + duSidechain.address.slice(2) + hexZeroPad(withdrawn, 32).slice(2) + const signature = await signer.signMessage(arrayify(message)) + return signature } // Query functions - async getStats() { - return this.dataUnionEndpoints.getStats(this.contractAddress) + async getStats(): Promise { + const duSidechain = await this.getContracts().getSidechainContractReadOnly(this.contractAddress) + const [ + totalEarnings, + totalEarningsWithdrawn, + activeMemberCount, + inactiveMemberCount, + lifetimeMemberEarnings, + joinPartAgentCount, + ] = await duSidechain.getStats() + const totalWithdrawable = totalEarnings.sub(totalEarningsWithdrawn) + return { + activeMemberCount, + inactiveMemberCount, + joinPartAgentCount, + totalEarnings, + totalWithdrawable, + lifetimeMemberEarnings, + } } - async getMemberStats(memberAddress: string) { - return this.dataUnionEndpoints.getMemberStats(memberAddress, this.contractAddress) + /** + * Get stats of a single data union member + */ + async getMemberStats(memberAddress: EthereumAddress): Promise { + const address = getAddress(memberAddress) + // TODO: use duSidechain.getMemberStats(address) once it's implemented, to ensure atomic read + // (so that memberData is from same block as getEarnings, otherwise withdrawable will be foobar) + const duSidechain = await this.getContracts().getSidechainContractReadOnly(this.contractAddress) + const mdata = await duSidechain.memberData(address) + const total = await duSidechain.getEarnings(address).catch(() => BigNumber.from(0)) + const withdrawnEarnings = mdata[3] + const withdrawable = total ? total.sub(withdrawnEarnings) : BigNumber.from(0) + const STATUSES = [MemberStatus.NONE, MemberStatus.ACTIVE, MemberStatus.INACTIVE] + return { + status: STATUSES[mdata[0]], + earningsBeforeLastJoin: mdata[1], + totalEarnings: total, + withdrawableEarnings: withdrawable, + } } - async getWithdrawableEarnings(memberAddress: string) { - return this.dataUnionEndpoints.getWithdrawableEarnings(memberAddress, this.contractAddress) + /** + * Get the amount of tokens the member would get from a successful withdraw + */ + async getWithdrawableEarnings(memberAddress: EthereumAddress): Promise { + const address = getAddress(memberAddress) + const duSidechain = await this.getContracts().getSidechainContractReadOnly(this.contractAddress) + return duSidechain.getWithdrawableEarnings(address) } - async getAdminFee() { - return this.dataUnionEndpoints.getAdminFee(this.contractAddress) + /** + * Get data union admin fee fraction (between 0.0 and 1.0) that admin gets from each revenue event + */ + async getAdminFee(): Promise { + const duMainnet = this.getContracts().getMainnetContractReadOnly(this.contractAddress) + const adminFeeBN = await duMainnet.adminFeeFraction() + return +adminFeeBN.toString() / 1e18 } - async getAdminAddress() { - return this.dataUnionEndpoints.getAdminAddress(this.contractAddress) + async getAdminAddress(): Promise { + const duMainnet = this.getContracts().getMainnetContractReadOnly(this.contractAddress) + return duMainnet.owner() } - async getVersion() { - return this.dataUnionEndpoints.getVersion(this.contractAddress) + /** + * Figure out if given mainnet address is old DataUnion (v 1.0) or current 2.0 + * NOTE: Current version of streamr-client-javascript can only handle current version! + */ + async getVersion(): Promise { + const provider = this.client.ethereum.getMainnetProvider() + const du = new Contract(this.contractAddress, [{ + name: 'version', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' + }], provider) + try { + const version = await du.version() + return +version + } catch (e) { + // "not a data union" + return 0 + } } // Admin functions - async createSecret(name: string = 'Untitled Data Union Secret') { - return this.dataUnionEndpoints.createSecret(this.contractAddress, name) + /** + * Add a new data union secret + */ + async createSecret(name: string = 'Untitled Data Union Secret'): Promise { + const url = getEndpointUrl(this.client.options.restUrl, 'dataunions', this.contractAddress, 'secrets') + const res = await authFetch( + url, + this.client.session, + { + method: 'POST', + body: JSON.stringify({ + name + }), + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + return res.secret + } + + /** + * Add given Ethereum addresses as data union members + */ + async addMembers( + memberAddressList: EthereumAddress[], + options: DataUnionMemberListModificationOptions = {} + ): Promise { + const members = memberAddressList.map(getAddress) // throws if there are bad addresses + const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) + const tx = await duSidechain.addMembers(members) + // TODO: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) + const { confirmations = 1 } = options + return tx.wait(confirmations) } - async addMembers(memberAddressList: string[], options?: DataUnionMemberListModificationOptions) { - return this.dataUnionEndpoints.addMembers(memberAddressList, options, this.contractAddress) + /** + * Remove given members from data union + */ + async removeMembers( + memberAddressList: EthereumAddress[], + options: DataUnionMemberListModificationOptions = {}, + ): Promise { + const members = memberAddressList.map(getAddress) // throws if there are bad addresses + const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) + const tx = await duSidechain.partMembers(members) + // TODO: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) + const { confirmations = 1 } = options + return tx.wait(confirmations) } - async removeMembers(memberAddressList: string[], options?: DataUnionMemberListModificationOptions) { - return this.dataUnionEndpoints.removeMembers(memberAddressList, options, this.contractAddress) + /** + * Admin: withdraw earnings (pay gas) on behalf of a member + * TODO: add test + * @param memberAddress - the other member who gets their tokens out of the Data Union + * @returns Receipt once withdraw transaction is confirmed + */ + async withdrawAllToMember( + memberAddress: EthereumAddress, + options?: DataUnionWithdrawOptions + ): Promise { + const address = getAddress(memberAddress) // throws if bad address + return this._executeWithdraw( + () => this.getWithdrawAllToMemberTx(address), + address, + options + ) } - async withdrawAllToMember(memberAddress: string, options?: DataUnionWithdrawOptions) { - return this.dataUnionEndpoints.withdrawAllToMember(memberAddress, options, this.contractAddress) + /** + * Admin: get the tx promise for withdrawing all earnings on behalf of a member + * @param memberAddress - the other member who gets their tokens out of the Data Union + * @returns await on call .wait to actually send the tx + */ + private async getWithdrawAllToMemberTx(memberAddress: EthereumAddress): Promise { + const a = getAddress(memberAddress) // throws if bad address + const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) + return duSidechain.withdrawAll(a, true) // sendToMainnet=true } - async withdrawAllToSigned(memberAddress: string, recipientAddress: string, signature: string, options?: DataUnionWithdrawOptions) { - return this.dataUnionEndpoints.withdrawAllToSigned(memberAddress, recipientAddress, signature, options, this.contractAddress) + /** + * Admin: Withdraw a member's earnings to another address, signed by the member + * @param memberAddress - the member whose earnings are sent out + * @param recipientAddress - the address to receive the tokens in mainnet + * @param signature - from member, produced using signWithdrawAllTo + * @returns receipt once withdraw transaction is confirmed + */ + async withdrawAllToSigned( + memberAddress: EthereumAddress, + recipientAddress: EthereumAddress, + signature: string, + options?: DataUnionWithdrawOptions + ): Promise { + const from = getAddress(memberAddress) // throws if bad address + const to = getAddress(recipientAddress) + return this._executeWithdraw( + () => this.getWithdrawAllToSignedTx(from, to, signature), + to, + options + ) } - async setAdminFee(newFeeFraction: number) { - return this.dataUnionEndpoints.setAdminFee(newFeeFraction, this.contractAddress) + /** + * Admin: Withdraw a member's earnings to another address, signed by the member + * @param memberAddress - the member whose earnings are sent out + * @param recipientAddress - the address to receive the tokens in mainnet + * @param signature - from member, produced using signWithdrawAllTo + * @returns await on call .wait to actually send the tx + */ + private async getWithdrawAllToSignedTx( + memberAddress: EthereumAddress, + recipientAddress: EthereumAddress, + signature: string, + ): Promise { + const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) + return duSidechain.withdrawAllToSigned(memberAddress, recipientAddress, true, signature) // sendToMainnet=true + } + + /** + * Admin: set admin fee (between 0.0 and 1.0) for the data union + */ + async setAdminFee(newFeeFraction: number): Promise { + if (newFeeFraction < 0 || newFeeFraction > 1) { + throw new Error('newFeeFraction argument must be a number between 0...1, got: ' + newFeeFraction) + } + const adminFeeBN = BigNumber.from((newFeeFraction * 1e18).toFixed()) // last 2...3 decimals are going to be gibberish + const duMainnet = this.getContracts().getMainnetContract(this.contractAddress) + const tx = await duMainnet.setAdminFee(adminFeeBN) + return tx.wait() + } + + /** + * Create a new DataUnionMainnet contract to mainnet with DataUnionFactoryMainnet + * This triggers DataUnionSidechain contract creation in sidechain, over the bridge (AMB) + * @return that resolves when the new DU is deployed over the bridge to side-chain + */ + static async _deploy(options: DataUnionDeployOptions = {}, client: StreamrClient): Promise { + const deployerAddress = client.getAddress() + const { + owner, + joinPartAgents, + dataUnionName, + adminFee = 0, + sidechainPollingIntervalMs = 1000, + sidechainRetryTimeoutMs = 600000, + confirmations = 1, + gasPrice + } = options + + let duName = dataUnionName + if (!duName) { + duName = `DataUnion-${Date.now()}` // TODO: use uuid + log(`dataUnionName generated: ${duName}`) + } + + if (adminFee < 0 || adminFee > 1) { throw new Error('options.adminFeeFraction must be a number between 0...1, got: ' + adminFee) } + const adminFeeBN = BigNumber.from((adminFee * 1e18).toFixed()) // last 2...3 decimals are going to be gibberish + + const ownerAddress = (owner) ? getAddress(owner) : deployerAddress + + let agentAddressList + if (Array.isArray(joinPartAgents)) { + // getAddress throws if there's an invalid address in the array + agentAddressList = joinPartAgents.map(getAddress) + } else { + // streamrNode needs to be joinPartAgent so that EE join with secret works (and join approvals from Marketplace UI) + agentAddressList = [ownerAddress] + if (client.options.streamrNodeAddress) { + agentAddressList.push(getAddress(client.options.streamrNodeAddress)) + } + } + + const contract = await new Contracts(client).deployDataUnion({ + ownerAddress, + agentAddressList, + duName, + deployerAddress, + adminFeeBN, + sidechainRetryTimeoutMs, + sidechainPollingIntervalMs, + confirmations, + gasPrice + }) + return new DataUnion(contract.address, contract.sidechain.address, client) } // Internal functions + static _fromContractAddress(contractAddress: string, client: StreamrClient) { + const contracts = new Contracts(client) + const sidechainAddress = contracts.getDataUnionSidechainAddress(getAddress(contractAddress)) // throws if bad address + return new DataUnion(contractAddress, sidechainAddress, client) + } + + static _fromName({ dataUnionName, deployerAddress }: { dataUnionName: string, deployerAddress: string}, client: StreamrClient) { + const contracts = new Contracts(client) + const contractAddress = contracts.getDataUnionMainnetAddress(dataUnionName, getAddress(deployerAddress)) // throws if bad address + return DataUnion._fromContractAddress(contractAddress, client) // eslint-disable-line no-underscore-dangle + } + async _getContract() { - return this.dataUnionEndpoints.getContract(this.contractAddress) + const ret = this.getContracts().getMainnetContract(this.contractAddress) + // @ts-expect-error + ret.sidechain = await this.getContracts().getSidechainContract(this.contractAddress) + return ret + } + + private getContracts() { + return new Contracts(this.client) + } + + // template for withdraw functions + // client could be replaced with AMB (mainnet and sidechain) + private async _executeWithdraw( + getWithdrawTxFunc: () => Promise, + recipientAddress: EthereumAddress, + options: DataUnionWithdrawOptions = {} + ): Promise { + const { + pollingIntervalMs = 1000, + retryTimeoutMs = 60000, + payForSignatureTransport = this.client.options.payForSignatureTransport + }: any = options + const getBalanceFunc = () => this.client.getTokenBalance(recipientAddress) + const balanceBefore = await getBalanceFunc() + const tx = await getWithdrawTxFunc() + const tr = await tx.wait() + if (payForSignatureTransport) { + await this.getContracts().payForSignatureTransport(tr, options) + } + log(`Waiting for balance ${balanceBefore.toString()} to change`) + await until(async () => !(await getBalanceFunc()).eq(balanceBefore), retryTimeoutMs, pollingIntervalMs) + return tr } } diff --git a/src/dataunion/abi.ts b/src/dataunion/abi.ts new file mode 100644 index 000000000..df28546ac --- /dev/null +++ b/src/dataunion/abi.ts @@ -0,0 +1,223 @@ +export const dataUnionMainnetABI = [{ + name: 'sendTokensToBridge', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'token', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'owner', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'setAdminFee', + inputs: [{ type: 'uint256' }], + outputs: [], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'adminFeeFraction', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}] + +export const dataUnionSidechainABI = [{ + name: 'addMembers', + inputs: [{ type: 'address[]', internalType: 'address payable[]', }], + outputs: [], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'partMembers', + inputs: [{ type: 'address[]' }], + outputs: [], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'withdrawAll', + inputs: [{ type: 'address' }, { type: 'bool' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'withdrawAllTo', + inputs: [{ type: 'address' }, { type: 'bool' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'withdrawAllToSigned', + inputs: [{ type: 'address' }, { type: 'address' }, { type: 'bool' }, { type: 'bytes' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + // enum ActiveStatus {None, Active, Inactive, Blocked} + // struct MemberInfo { + // ActiveStatus status; + // uint256 earnings_before_last_join; + // uint256 lme_at_join; + // uint256 withdrawnEarnings; + // } + name: 'memberData', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint8' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + inputs: [], + name: 'getStats', + outputs: [{ type: 'uint256[6]' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'getEarnings', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'getWithdrawableEarnings', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'lifetimeMemberEarnings', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'totalWithdrawable', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'totalEarnings', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'activeMemberCount', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + // this event is emitted by withdrawing process, + // see https://github.com/poanetwork/tokenbridge-contracts/blob/master/contracts/upgradeable_contracts/arbitrary_message/HomeAMB.sol + name: 'UserRequestForSignature', + inputs: [ + { indexed: true, name: 'messageId', type: 'bytes32' }, + { indexed: false, name: 'encodedData', type: 'bytes' } + ], + anonymous: false, + type: 'event' +}] + +// Only the part of ABI that is needed by deployment (and address resolution) +export const factoryMainnetABI = [{ + type: 'constructor', + inputs: [{ type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'uint256' }], + stateMutability: 'nonpayable' +}, { + name: 'sidechainAddress', + inputs: [{ type: 'address' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'mainnetAddress', + inputs: [{ type: 'address' }, { type: 'string' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'deployNewDataUnion', + inputs: [{ type: 'address' }, { type: 'uint256' }, { type: 'address[]' }, { type: 'string' }], + outputs: [{ type: 'address' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'amb', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'data_union_sidechain_factory', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}] + +export const mainnetAmbABI = [{ + name: 'executeSignatures', + inputs: [{ type: 'bytes' }, { type: 'bytes' }], // data, signatures + outputs: [], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'messageCallStatus', + inputs: [{ type: 'bytes32' }], // messageId + outputs: [{ type: 'bool' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'failedMessageSender', + inputs: [{ type: 'bytes32' }], // messageId + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'relayedMessages', + inputs: [{ type: 'bytes32' }], // messageId, was called "_txhash" though?! + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'validatorContract', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}] + +export const sidechainAmbABI = [{ + name: 'signature', + inputs: [{ type: 'bytes32' }, { type: 'uint256' }], // messageHash, index + outputs: [{ type: 'bytes' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'message', + inputs: [{ type: 'bytes32' }], // messageHash + outputs: [{ type: 'bytes' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'requiredSignatures', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'numMessagesSigned', + inputs: [{ type: 'bytes32' }], // messageHash (TODO: double check) + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}] diff --git a/src/rest/DataUnionEndpoints.ts b/src/rest/DataUnionEndpoints.ts deleted file mode 100644 index e78c1a55a..000000000 --- a/src/rest/DataUnionEndpoints.ts +++ /dev/null @@ -1,1110 +0,0 @@ -/** - * Streamr Data Union related functions - * - * Table of Contents: - * ABIs - * helper utils - * admin: DEPLOY AND SETUP DATA UNION Functions for deploying the contract and adding secrets for smooth joining - * admin: MANAGE DATA UNION add and part members - * member: JOIN & QUERY DATA UNION Publicly available info about dataunions and their members (with earnings and proofs) - * member: WITHDRAW EARNINGS Withdrawing functions, there's many: normal, agent, donate - */ - -import { getAddress, getCreate2Address, isAddress } from '@ethersproject/address' -import { BigNumber } from '@ethersproject/bignumber' -import { arrayify, hexZeroPad } from '@ethersproject/bytes' -import { Contract } from '@ethersproject/contracts' -import { keccak256 } from '@ethersproject/keccak256' -import { defaultAbiCoder } from '@ethersproject/abi' -import { TransactionReceipt, TransactionResponse } from '@ethersproject/providers' -import { verifyMessage } from '@ethersproject/wallet' -import debug from 'debug' -import { DataUnionDeployOptions, DataUnionMemberListModificationOptions, DataUnionWithdrawOptions } from '../dataunion/DataUnion' -import StreamrClient from '../StreamrClient' -import { Todo } from '../types' - -import { until, getEndpointUrl } from '../utils' - -import authFetch from './authFetch' - -export interface DataUnionStats { - activeMemberCount: Todo, - inactiveMemberCount: Todo, - joinPartAgentCount: Todo, - totalEarnings: Todo, - totalWithdrawable: Todo, - lifetimeMemberEarnings: Todo -} - -export interface MemberStats { - status: Todo - earningsBeforeLastJoin: Todo - lmeAtJoin: Todo - totalEarnings: Todo - withdrawableEarnings: Todo -} - -const log = debug('StreamrClient::DataUnionEndpoints') -// const log = console.log // useful for debugging sometimes - -// /////////////////////////////////////////////////////////////////////// -// ABIs: contract functions we want to call within the client -// /////////////////////////////////////////////////////////////////////// - -const dataUnionMainnetABI = [{ - name: 'sendTokensToBridge', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'token', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'owner', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'setAdminFee', - inputs: [{ type: 'uint256' }], - outputs: [], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'adminFeeFraction', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}] - -const dataUnionSidechainABI = [{ - name: 'addMembers', - inputs: [{ type: 'address[]', internalType: 'address payable[]', }], - outputs: [], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'partMembers', - inputs: [{ type: 'address[]' }], - outputs: [], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'withdrawAll', - inputs: [{ type: 'address' }, { type: 'bool' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'withdrawAllTo', - inputs: [{ type: 'address' }, { type: 'bool' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'withdrawAllToSigned', - inputs: [{ type: 'address' }, { type: 'address' }, { type: 'bool' }, { type: 'bytes' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - // enum ActiveStatus {None, Active, Inactive, Blocked} - // struct MemberInfo { - // ActiveStatus status; - // uint256 earnings_before_last_join; - // uint256 lme_at_join; - // uint256 withdrawnEarnings; - // } - name: 'memberData', - inputs: [{ type: 'address' }], - outputs: [{ type: 'uint8' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - inputs: [], - name: 'getStats', - outputs: [{ type: 'uint256[6]' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'getEarnings', - inputs: [{ type: 'address' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'getWithdrawableEarnings', - inputs: [{ type: 'address' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'lifetimeMemberEarnings', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'totalWithdrawable', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'totalEarnings', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'activeMemberCount', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - // this event is emitted by withdrawing process, - // see https://github.com/poanetwork/tokenbridge-contracts/blob/master/contracts/upgradeable_contracts/arbitrary_message/HomeAMB.sol - name: 'UserRequestForSignature', - inputs: [ - { indexed: true, name: 'messageId', type: 'bytes32' }, - { indexed: false, name: 'encodedData', type: 'bytes' } - ], - anonymous: false, - type: 'event' -}] - -// Only the part of ABI that is needed by deployment (and address resolution) -const factoryMainnetABI = [{ - type: 'constructor', - inputs: [{ type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'uint256' }], - stateMutability: 'nonpayable' -}, { - name: 'sidechainAddress', - inputs: [{ type: 'address' }], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'mainnetAddress', - inputs: [{ type: 'address' }, { type: 'string' }], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'deployNewDataUnion', - inputs: [{ type: 'address' }, { type: 'uint256' }, { type: 'address[]' }, { type: 'string' }], - outputs: [{ type: 'address' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'amb', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'data_union_sidechain_factory', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}] - -const mainnetAmbABI = [{ - name: 'executeSignatures', - inputs: [{ type: 'bytes' }, { type: 'bytes' }], // data, signatures - outputs: [], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'messageCallStatus', - inputs: [{ type: 'bytes32' }], // messageId - outputs: [{ type: 'bool' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'failedMessageSender', - inputs: [{ type: 'bytes32' }], // messageId - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'relayedMessages', - inputs: [{ type: 'bytes32' }], // messageId, was called "_txhash" though?! - outputs: [{ name: '', type: 'bool' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'validatorContract', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}] - -const sidechainAmbABI = [{ - name: 'signature', - inputs: [{ type: 'bytes32' }, { type: 'uint256' }], // messageHash, index - outputs: [{ type: 'bytes' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'message', - inputs: [{ type: 'bytes32' }], // messageHash - outputs: [{ type: 'bytes' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'requiredSignatures', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'numMessagesSigned', - inputs: [{ type: 'bytes32' }], // messageHash (TODO: double check) - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}] - -// ////////////////////////////////////////////////////////////////// -// Contract utils -// ////////////////////////////////////////////////////////////////// - -type EthereumAddress = string - -function throwIfBadAddress(address: string, variableDescription: Todo) { - try { - return getAddress(address) - } catch (e) { - throw new Error(`${variableDescription || 'Error'}: Bad Ethereum address ${address}. Original error: ${e.stack}.`) - } -} - -// Find the Asyncronous Message-passing Bridge sidechain ("home") contract -let cachedSidechainAmb: Todo -async function getSidechainAmb(client: StreamrClient) { - if (!cachedSidechainAmb) { - const getAmbPromise = async () => { - const mainnetProvider = client.ethereum.getMainnetProvider() - const { factoryMainnetAddress } = client.options - const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, mainnetProvider) - const sidechainProvider = client.ethereum.getSidechainProvider() - const factorySidechainAddress = await factoryMainnet.data_union_sidechain_factory() // TODO use getDataUnionSidechainAddress() - const factorySidechain = new Contract(factorySidechainAddress, [{ - name: 'amb', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' - }], sidechainProvider) - const sidechainAmbAddress = await factorySidechain.amb() - return new Contract(sidechainAmbAddress, sidechainAmbABI, sidechainProvider) - } - cachedSidechainAmb = getAmbPromise() - cachedSidechainAmb = await cachedSidechainAmb // eslint-disable-line require-atomic-updates - } - return cachedSidechainAmb -} - -async function getMainnetAmb(client: StreamrClient) { - const mainnetProvider = client.ethereum.getMainnetProvider() - const { factoryMainnetAddress } = client.options - const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, mainnetProvider) - const mainnetAmbAddress = await factoryMainnet.amb() - return new Contract(mainnetAmbAddress, mainnetAmbABI, mainnetProvider) -} - -async function requiredSignaturesHaveBeenCollected(client: StreamrClient, messageHash: Todo) { - const sidechainAmb = await getSidechainAmb(client) - const requiredSignatureCount = await sidechainAmb.requiredSignatures() - - // Bit 255 is set to mark completion, double check though - const sigCountStruct = await sidechainAmb.numMessagesSigned(messageHash) - const collectedSignatureCount = sigCountStruct.mask(255) - const markedComplete = sigCountStruct.shr(255).gt(0) - - log(`${collectedSignatureCount.toString()} out of ${requiredSignatureCount.toString()} collected`) - if (markedComplete) { log('All signatures collected') } - return markedComplete -} - -// move signatures from sidechain to mainnet -async function transportSignatures(client: StreamrClient, messageHash: string) { - const sidechainAmb = await getSidechainAmb(client) - const message = await sidechainAmb.message(messageHash) - const messageId = '0x' + message.substr(2, 64) - const sigCountStruct = await sidechainAmb.numMessagesSigned(messageHash) - const collectedSignatureCount = sigCountStruct.mask(255).toNumber() - - log(`${collectedSignatureCount} signatures reported, getting them from the sidechain AMB...`) - const signatures = await Promise.all(Array(collectedSignatureCount).fill(0).map(async (_, i) => sidechainAmb.signature(messageHash, i))) - - const [vArray, rArray, sArray]: Todo = [[], [], []] - signatures.forEach((signature: string, i) => { - log(` Signature ${i}: ${signature} (len=${signature.length}=${signature.length / 2 - 1} bytes)`) - rArray.push(signature.substr(2, 64)) - sArray.push(signature.substr(66, 64)) - vArray.push(signature.substr(130, 2)) - }) - const packedSignatures = BigNumber.from(signatures.length).toHexString() + vArray.join('') + rArray.join('') + sArray.join('') - log(`All signatures packed into one: ${packedSignatures}`) - - // Gas estimation also checks that the transaction would succeed, and provides a helpful error message in case it would fail - const mainnetAmb = await getMainnetAmb(client) - log(`Estimating gas using mainnet AMB @ ${mainnetAmb.address}, message=${message}`) - let gasLimit - try { - // magic number suggested by https://github.com/poanetwork/tokenbridge/blob/master/oracle/src/utils/constants.js - gasLimit = BigNumber.from(await mainnetAmb.estimateGas.executeSignatures(message, packedSignatures)).add(200000) - log(`Calculated gas limit: ${gasLimit.toString()}`) - } catch (e) { - // Failure modes from https://github.com/poanetwork/tokenbridge/blob/master/oracle/src/events/processAMBCollectedSignatures/estimateGas.js - log('Gas estimation failed: Check if the message was already processed') - const alreadyProcessed = await mainnetAmb.relayedMessages(messageId) - if (alreadyProcessed) { - log(`WARNING: Tried to transport signatures but they have already been transported (Message ${messageId} has already been processed)`) - log('This could happen if payForSignatureTransport=true, but bridge operator also pays for signatures, and got there before your client') - return null - } - - log('Gas estimation failed: Check if number of signatures is enough') - const mainnetProvider = client.ethereum.getMainnetProvider() - const validatorContractAddress = await mainnetAmb.validatorContract() - const validatorContract = new Contract(validatorContractAddress, [{ - name: 'isValidator', - inputs: [{ type: 'address' }], - outputs: [{ type: 'bool' }], - stateMutability: 'view', - type: 'function' - }, { - name: 'requiredSignatures', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' - }], mainnetProvider) - const requiredSignatures = await validatorContract.requiredSignatures() - if (requiredSignatures.gt(signatures.length)) { - throw new Error('The number of required signatures does not match between sidechain(' - + signatures.length + ' and mainnet( ' + requiredSignatures.toString()) - } - - log('Gas estimation failed: Check if all the signatures were made by validators') - log(` Recover signer addresses from signatures [${signatures.join(', ')}]`) - const signers = signatures.map((signature) => verifyMessage(arrayify(message), signature)) - log(` Check that signers are validators [[${signers.join(', ')}]]`) - const isValidatorArray = await Promise.all(signers.map((address) => [address, validatorContract.isValidator(address)])) - const nonValidatorSigners = isValidatorArray.filter(([, isValidator]) => !isValidator) - if (nonValidatorSigners.length > 0) { - throw new Error(`Following signers are not listed as validators in mainnet validator contract at ${validatorContractAddress}:\n - ` - + nonValidatorSigners.map(([address]) => address).join('\n - ')) - } - - throw new Error(`Gas estimation failed: Unknown error while processing message ${message} with ${e.stack}`) - } - - const signer = client.ethereum.getSigner() - log(`Sending message from signer=${await signer.getAddress()}`) - const txAMB = await mainnetAmb.connect(signer).executeSignatures(message, packedSignatures) - const trAMB = await txAMB.wait() - return trAMB -} - -// template for withdraw functions -// client could be replaced with AMB (mainnet and sidechain) -async function untilWithdrawIsComplete( - client: StreamrClient, - getWithdrawTxFunc: () => Promise, - getBalanceFunc: () => Promise, - options: DataUnionWithdrawOptions = {} -) { - const { - pollingIntervalMs = 1000, - retryTimeoutMs = 60000, - }: Todo = options - const balanceBefore = await getBalanceFunc() - const tx = await getWithdrawTxFunc() - const tr = await tx.wait() - - if (options.payForSignatureTransport || client.options.payForSignatureTransport) { - log(`Got receipt, filtering UserRequestForSignature from ${tr.events.length} events...`) - // event UserRequestForSignature(bytes32 indexed messageId, bytes encodedData); - const sigEventArgsArray = tr.events.filter((e: Todo) => e.event === 'UserRequestForSignature').map((e: Todo) => e.args) - if (sigEventArgsArray.length < 1) { - throw new Error("No UserRequestForSignature events emitted from withdraw transaction, can't transport withdraw to mainnet") - } - /* eslint-disable no-await-in-loop */ - // eslint-disable-next-line no-restricted-syntax - for (const eventArgs of sigEventArgsArray) { - const messageId = eventArgs[0] - const messageHash = keccak256(eventArgs[1]) - - log(`Waiting until sidechain AMB has collected required signatures for hash=${messageHash}...`) - await until(async () => requiredSignaturesHaveBeenCollected(client, messageHash), pollingIntervalMs, retryTimeoutMs) - - log(`Checking mainnet AMB hasn't already processed messageId=${messageId}`) - const mainnetAmb = await getMainnetAmb(client) - const alreadySent = await mainnetAmb.messageCallStatus(messageId) - const failAddress = await mainnetAmb.failedMessageSender(messageId) - if (alreadySent || failAddress !== '0x0000000000000000000000000000000000000000') { // zero address means no failed messages - log(`WARNING: Mainnet bridge has already processed withdraw messageId=${messageId}`) - log([ - 'This could happen if payForSignatureTransport=true, but bridge operator also pays for', - 'signatures, and got there before your client', - ].join(' ')) - continue - } - - log(`Transporting signatures for hash=${messageHash}`) - await transportSignatures(client, messageHash) - } - /* eslint-enable no-await-in-loop */ - } - - log(`Waiting for balance ${balanceBefore.toString()} to change`) - await until(async () => !(await getBalanceFunc()).eq(balanceBefore), retryTimeoutMs, pollingIntervalMs) - - return tr -} - -// TODO remove caching as we calculate the values only when deploying the DU -const mainnetAddressCache: Todo = {} // mapping: "name" -> mainnet address - -/** @returns Mainnet address for Data Union */ -async function fetchDataUnionMainnetAddress( - client: StreamrClient, - dataUnionName: string, - deployerAddress: EthereumAddress -): Promise { - if (!mainnetAddressCache[dataUnionName]) { - const provider = client.ethereum.getMainnetProvider() - const { factoryMainnetAddress } = client.options - const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, provider) - const addressPromise = factoryMainnet.mainnetAddress(deployerAddress, dataUnionName) - mainnetAddressCache[dataUnionName] = addressPromise - mainnetAddressCache[dataUnionName] = await addressPromise // eslint-disable-line require-atomic-updates - } - return mainnetAddressCache[dataUnionName] -} - -function getDataUnionMainnetAddress(client: StreamrClient, dataUnionName: string, deployerAddress: EthereumAddress) { - const { factoryMainnetAddress } = client.options - if (!factoryMainnetAddress) { - throw new Error('StreamrClient has no factoryMainnetAddress configuration.') - } - // NOTE! this must be updated when DU sidechain smartcontract changes: keccak256(CloneLib.cloneBytecode(data_union_mainnet_template)); - const codeHash = '0x50a78bac973bdccfc8415d7d9cfd62898b8f7cf6e9b3a15e7d75c0cb820529eb' - const salt = keccak256(defaultAbiCoder.encode(['string', 'address'], [dataUnionName, deployerAddress])) - return getCreate2Address(factoryMainnetAddress, salt, codeHash) -} - -// TODO remove caching as we calculate the values only when deploying the DU -const sidechainAddressCache: Todo = {} // mapping: mainnet address -> sidechain address -/** @returns Sidechain address for Data Union */ -async function fetchDataUnionSidechainAddress(client: StreamrClient, duMainnetAddress: EthereumAddress): Promise { - if (!sidechainAddressCache[duMainnetAddress]) { - const provider = client.ethereum.getMainnetProvider() - const { factoryMainnetAddress } = client.options - const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, provider) - const addressPromise = factoryMainnet.sidechainAddress(duMainnetAddress) - sidechainAddressCache[duMainnetAddress] = addressPromise - sidechainAddressCache[duMainnetAddress] = await addressPromise // eslint-disable-line require-atomic-updates - } - return sidechainAddressCache[duMainnetAddress] -} - -function getDataUnionSidechainAddress(client: StreamrClient, mainnetAddress: EthereumAddress) { - const { factorySidechainAddress } = client.options - if (!factorySidechainAddress) { - throw new Error('StreamrClient has no factorySidechainAddress configuration.') - } - // NOTE! this must be updated when DU sidechain smartcontract changes: keccak256(CloneLib.cloneBytecode(data_union_sidechain_template)) - const codeHash = '0x040cf686e25c97f74a23a4bf01c29dd77e260c4b694f5611017ce9713f58de83' - return getCreate2Address(factorySidechainAddress, hexZeroPad(mainnetAddress, 32), codeHash) -} - -function getMainnetContractReadOnly(contractAddress: EthereumAddress, client: StreamrClient) { - if (isAddress(contractAddress)) { - const provider = client.ethereum.getMainnetProvider() - return new Contract(contractAddress, dataUnionMainnetABI, provider) - } - throw new Error(`${contractAddress} was not a good Ethereum address`) - -} - -function getMainnetContract(contractAddress: EthereumAddress, client: StreamrClient) { - const du = getMainnetContractReadOnly(contractAddress, client) - const signer = client.ethereum.getSigner() - return du.connect(signer) -} - -async function getSidechainContract(contractAddress: EthereumAddress, client: StreamrClient) { - const signer = await client.ethereum.getSidechainSigner() - const duMainnet = getMainnetContractReadOnly(contractAddress, client) - const duSidechainAddress = getDataUnionSidechainAddress(client, duMainnet.address) - const duSidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, signer) - return duSidechain -} - -async function getSidechainContractReadOnly(contractAddress: EthereumAddress, client: StreamrClient) { - const provider = client.ethereum.getSidechainProvider() - const duMainnet = getMainnetContractReadOnly(contractAddress, client) - const duSidechainAddress = getDataUnionSidechainAddress(client, duMainnet.address) - const duSidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, provider) - return duSidechain -} - -export class DataUnionEndpoints { - - client: StreamrClient - - constructor(client: StreamrClient) { - this.client = client - } - - // ////////////////////////////////////////////////////////////////// - // admin: DEPLOY AND SETUP DATA UNION - // ////////////////////////////////////////////////////////////////// - - // TODO inline this function? - calculateDataUnionMainnetAddress(dataUnionName: string, deployerAddress: EthereumAddress) { - const address = getAddress(deployerAddress) // throws if bad address - return getDataUnionMainnetAddress(this.client, dataUnionName, address) - } - - // TODO inline this function? - calculateDataUnionSidechainAddress(duMainnetAddress: EthereumAddress) { - const address = getAddress(duMainnetAddress) // throws if bad address - return getDataUnionSidechainAddress(this.client, address) - } - - /** - * Create a new DataUnionMainnet contract to mainnet with DataUnionFactoryMainnet - * This triggers DataUnionSidechain contract creation in sidechain, over the bridge (AMB) - * @return that resolves when the new DU is deployed over the bridge to side-chain - */ - async deployDataUnionContract(options: DataUnionDeployOptions = {}): Promise { - const { - owner, - joinPartAgents, - dataUnionName, - adminFee = 0, - sidechainPollingIntervalMs = 1000, - sidechainRetryTimeoutMs = 600000, - confirmations = 1, - gasPrice - } = options - - let duName = dataUnionName - if (!duName) { - duName = `DataUnion-${Date.now()}` // TODO: use uuid - log(`dataUnionName generated: ${duName}`) - } - - if (adminFee < 0 || adminFee > 1) { throw new Error('options.adminFeeFraction must be a number between 0...1, got: ' + adminFee) } - const adminFeeBN = BigNumber.from((adminFee * 1e18).toFixed()) // last 2...3 decimals are going to be gibberish - - const mainnetProvider = this.client.ethereum.getMainnetProvider() - const mainnetWallet = this.client.ethereum.getSigner() - const sidechainProvider = this.client.ethereum.getSidechainProvider() - - const ownerAddress = (owner) ? getAddress(owner) : this.client.getAddress() - - let agentAddressList - if (Array.isArray(joinPartAgents)) { - // getAddress throws if there's an invalid address in the array - agentAddressList = joinPartAgents.map(getAddress) - } else { - // streamrNode needs to be joinPartAgent so that EE join with secret works (and join approvals from Marketplace UI) - agentAddressList = [ownerAddress] - if (this.client.options.streamrNodeAddress) { - agentAddressList.push(getAddress(this.client.options.streamrNodeAddress)) - } - } - - const deployerAddress = this.client.getAddress() - const duMainnetAddress = await fetchDataUnionMainnetAddress(this.client, duName, deployerAddress) - const duSidechainAddress = await fetchDataUnionSidechainAddress(this.client, duMainnetAddress) - - if (await mainnetProvider.getCode(duMainnetAddress) !== '0x') { - throw new Error(`Mainnet data union "${duName}" contract ${duMainnetAddress} already exists!`) - } - - const factoryMainnetAddress = throwIfBadAddress( - this.client.options.factoryMainnetAddress!, - 'StreamrClient.options.factoryMainnetAddress' - ) - if (await mainnetProvider.getCode(factoryMainnetAddress) === '0x') { - throw new Error(`Data union factory contract not found at ${factoryMainnetAddress}, check StreamrClient.options.factoryMainnetAddress!`) - } - - // function deployNewDataUnion(address owner, uint256 adminFeeFraction, address[] agents, string duName) - const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, mainnetWallet) - const ethersOptions: any = {} - if (gasPrice) { - ethersOptions.gasPrice = gasPrice - } - const tx = await factoryMainnet.deployNewDataUnion( - ownerAddress, - adminFeeBN, - agentAddressList, - duName, - ethersOptions - ) - const tr = await tx.wait(confirmations) - - log(`Data Union "${duName}" (mainnet: ${duMainnetAddress}, sidechain: ${duSidechainAddress}) deployed to mainnet, waiting for side-chain...`) - await until( - async () => await sidechainProvider.getCode(duSidechainAddress) !== '0x', - sidechainRetryTimeoutMs, - sidechainPollingIntervalMs - ) - - const dataUnion = new Contract(duMainnetAddress, dataUnionMainnetABI, mainnetWallet) - // @ts-expect-error - dataUnion.deployTxReceipt = tr - // @ts-expect-error - dataUnion.sidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, sidechainProvider) - return dataUnion - } - - async getContract(contractAddress: EthereumAddress) { - const ret = getMainnetContract(contractAddress, this.client) - // @ts-expect-error - ret.sidechain = await getSidechainContract(contractAddress, this.client) - return ret - } - - /** - * Add a new data union secret - */ - async createSecret(dataUnionMainnetAddress: EthereumAddress, name: string = 'Untitled Data Union Secret'): Promise { - const duAddress = getAddress(dataUnionMainnetAddress) // throws if bad address - const url = getEndpointUrl(this.client.options.restUrl, 'dataunions', duAddress, 'secrets') - const res = await authFetch( - url, - this.client.session, - { - method: 'POST', - body: JSON.stringify({ - name - }), - headers: { - 'Content-Type': 'application/json', - }, - }, - ) - return res.secret - } - - // ////////////////////////////////////////////////////////////////// - // admin: MANAGE DATA UNION - // ////////////////////////////////////////////////////////////////// - - /** - * Add given Ethereum addresses as data union members - */ - async addMembers( - memberAddressList: string[], - options: DataUnionMemberListModificationOptions|undefined = {}, - contractAddress: EthereumAddress - ): Promise { - const members = memberAddressList.map(getAddress) // throws if there are bad addresses - const duSidechain = await getSidechainContract(contractAddress, this.client) - const tx = await duSidechain.addMembers(members) - // TODO: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) - const { confirmations = 1 } = options - return tx.wait(confirmations) - } - - /** - * Remove given members from data union - */ - async removeMembers( - memberAddressList: string[], - options: DataUnionMemberListModificationOptions|undefined = {}, - contractAddress: EthereumAddress - ): Promise { - const members = memberAddressList.map(getAddress) // throws if there are bad addresses - const duSidechain = await getSidechainContract(contractAddress, this.client) - const tx = await duSidechain.partMembers(members) - // TODO: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) - const { confirmations = 1 } = options - return tx.wait(confirmations) - } - - /** - * Admin: withdraw earnings (pay gas) on behalf of a member - * TODO: add test - * @param memberAddress - the other member who gets their tokens out of the Data Union - * @returns Receipt once withdraw transaction is confirmed - */ - async withdrawAllToMember( - memberAddress: EthereumAddress, - options: DataUnionWithdrawOptions|undefined, - contractAddress: EthereumAddress - ): Promise { - const address = getAddress(memberAddress) // throws if bad address - const tr = await untilWithdrawIsComplete( - this.client, - () => this.getWithdrawAllToMemberTx(address, contractAddress), - () => this.getTokenBalance(address), - options - ) - return tr - } - - /** - * Admin: get the tx promise for withdrawing all earnings on behalf of a member - * @param memberAddress - the other member who gets their tokens out of the Data Union - * @returns await on call .wait to actually send the tx - */ - async getWithdrawAllToMemberTx(memberAddress: EthereumAddress, contractAddress: EthereumAddress): Promise { - const a = getAddress(memberAddress) // throws if bad address - const duSidechain = await getSidechainContract(contractAddress, this.client) - return duSidechain.withdrawAll(a, true) // sendToMainnet=true - } - - /** - * Admin: Withdraw a member's earnings to another address, signed by the member - * @param memberAddress - the member whose earnings are sent out - * @param recipientAddress - the address to receive the tokens in mainnet - * @param signature - from member, produced using signWithdrawAllTo - * @returns receipt once withdraw transaction is confirmed - */ - async withdrawAllToSigned( - memberAddress: EthereumAddress, - recipientAddress: EthereumAddress, - signature: string, - options: DataUnionWithdrawOptions|undefined, - contractAddress: EthereumAddress - ): Promise { - const from = getAddress(memberAddress) // throws if bad address - const to = getAddress(recipientAddress) - const tr = await untilWithdrawIsComplete( - this.client, - () => this.getWithdrawAllToSignedTx(from, to, signature, contractAddress), - () => this.getTokenBalance(to), - options - ) - return tr - } - - /** - * Admin: Withdraw a member's earnings to another address, signed by the member - * @param memberAddress - the member whose earnings are sent out - * @param recipientAddress - the address to receive the tokens in mainnet - * @param signature - from member, produced using signWithdrawAllTo - * @returns await on call .wait to actually send the tx - */ - async getWithdrawAllToSignedTx( - memberAddress: EthereumAddress, - recipientAddress: EthereumAddress, - signature: string, - contractAddress: EthereumAddress - ): Promise { - const duSidechain = await getSidechainContract(contractAddress, this.client) - return duSidechain.withdrawAllToSigned(memberAddress, recipientAddress, true, signature) // sendToMainnet=true - } - - /** - * Admin: set admin fee (between 0.0 and 1.0) for the data union - */ - async setAdminFee(newFeeFraction: number, contractAddress: EthereumAddress): Promise { - if (newFeeFraction < 0 || newFeeFraction > 1) { - throw new Error('newFeeFraction argument must be a number between 0...1, got: ' + newFeeFraction) - } - const adminFeeBN = BigNumber.from((newFeeFraction * 1e18).toFixed()) // last 2...3 decimals are going to be gibberish - const duMainnet = getMainnetContract(contractAddress, this.client) - const tx = await duMainnet.setAdminFee(adminFeeBN) - return tx.wait() - } - - /** - * Get data union admin fee fraction (between 0.0 and 1.0) that admin gets from each revenue event - */ - async getAdminFee(contractAddress: EthereumAddress): Promise { - const duMainnet = getMainnetContractReadOnly(contractAddress, this.client) - const adminFeeBN = await duMainnet.adminFeeFraction() - return +adminFeeBN.toString() / 1e18 - } - - async getAdminAddress(contractAddress: EthereumAddress): Promise { - const duMainnet = getMainnetContractReadOnly(contractAddress, this.client) - return duMainnet.owner() - } - - // ////////////////////////////////////////////////////////////////// - // member: JOIN & QUERY DATA UNION - // ////////////////////////////////////////////////////////////////// - - /** - * Send a joinRequest, or get into data union instantly with a data union secret - */ - async join(secret: string|undefined, contractAddress: EthereumAddress): Promise { - const memberAddress = this.client.getAddress() as string - const body: any = { - memberAddress - } - if (secret) { body.secret = secret } - - const url = getEndpointUrl(this.client.options.restUrl, 'dataunions', contractAddress, 'joinRequests') - const response = await authFetch( - url, - this.client.session, - { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json', - }, - }, - ) - if (secret) { - await until(async () => this.isMember(memberAddress, contractAddress)) - } - return response - } - - async isMember(memberAddress: EthereumAddress, contractAddress: EthereumAddress): Promise { - const address = getAddress(memberAddress) - const duSidechain = await getSidechainContractReadOnly(contractAddress, this.client) - const ACTIVE = 1 // memberData[0] is enum ActiveStatus {None, Active, Inactive} - const memberData = await duSidechain.memberData(address) - const state = memberData[0] - return (state === ACTIVE) - } - - // TODO: this needs more thought: probably something like getEvents from sidechain? Heavy on RPC? - async getMembers(contractAddress: EthereumAddress) { - const duSidechain = await getSidechainContractReadOnly(contractAddress, this.client) - throw new Error(`Not implemented for side-chain data union (at ${duSidechain.address})`) - // event MemberJoined(address indexed); - // event MemberParted(address indexed); - } - - async getStats(contractAddress: EthereumAddress): Promise { - const duSidechain = await getSidechainContractReadOnly(contractAddress, this.client) - const [ - totalEarnings, - totalEarningsWithdrawn, - activeMemberCount, - inactiveMemberCount, - lifetimeMemberEarnings, - joinPartAgentCount, - ] = await duSidechain.getStats() - const totalWithdrawable = totalEarnings.sub(totalEarningsWithdrawn) - return { - activeMemberCount, - inactiveMemberCount, - joinPartAgentCount, - totalEarnings, - totalWithdrawable, - lifetimeMemberEarnings, - } - } - - /** - * Get stats of a single data union member - */ - async getMemberStats(memberAddress: EthereumAddress, contractAddress: EthereumAddress): Promise { - const address = getAddress(memberAddress) - // TODO: use duSidechain.getMemberStats(address) once it's implemented, to ensure atomic read - // (so that memberData is from same block as getEarnings, otherwise withdrawable will be foobar) - const duSidechain = await getSidechainContractReadOnly(contractAddress, this.client) - const mdata = await duSidechain.memberData(address) - const total = await duSidechain.getEarnings(address).catch(() => 0) - const withdrawnEarnings = mdata[3].toString() - const withdrawable = total ? total.sub(withdrawnEarnings) : 0 - return { - status: ['unknown', 'active', 'inactive', 'blocked'][mdata[0]], - earningsBeforeLastJoin: mdata[1].toString(), - lmeAtJoin: mdata[2].toString(), - totalEarnings: total.toString(), - withdrawableEarnings: withdrawable.toString(), - } - } - - /** - * Get the amount of tokens the member would get from a successful withdraw - */ - async getWithdrawableEarnings(memberAddress: EthereumAddress, contractAddress: EthereumAddress): Promise { - const address = getAddress(memberAddress) - const duSidechain = await getSidechainContractReadOnly(contractAddress, this.client) - return duSidechain.getWithdrawableEarnings(address) - } - - /** - * Get token balance in "wei" (10^-18 parts) for given address - */ - async getTokenBalance(address: string): Promise { - const { tokenAddress } = this.client.options - if (!tokenAddress) { - throw new Error('StreamrClient has no tokenAddress configuration.') - } - const addr = getAddress(address) - const provider = this.client.ethereum.getMainnetProvider() - - const token = new Contract(tokenAddress, [{ - name: 'balanceOf', - inputs: [{ type: 'address' }], - outputs: [{ type: 'uint256' }], - constant: true, - payable: false, - stateMutability: 'view', - type: 'function' - }], provider) - return token.balanceOf(addr) - } - - /** - * Figure out if given mainnet address is old DataUnion (v 1.0) or current 2.0 - * NOTE: Current version of streamr-client-javascript can only handle current version! - */ - async getVersion(contractAddress: EthereumAddress): Promise { - const a = getAddress(contractAddress) // throws if bad address - const provider = this.client.ethereum.getMainnetProvider() - const du = new Contract(a, [{ - name: 'version', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' - }], provider) - try { - const version = await du.version() - return +version - } catch (e) { - // "not a data union" - return 0 - } - } - - // ////////////////////////////////////////////////////////////////// - // member: WITHDRAW EARNINGS - // ////////////////////////////////////////////////////////////////// - - /** - * Withdraw all your earnings - * @returns receipt once withdraw is complete (tokens are seen in mainnet) - */ - async withdrawAll(contractAddress: EthereumAddress, options?: DataUnionWithdrawOptions): Promise { - const recipientAddress = this.client.getAddress() - const tr = await untilWithdrawIsComplete( - this.client, - () => this.getWithdrawAllTx(contractAddress), - () => this.getTokenBalance(recipientAddress), - options - ) - return tr - } - - /** - * Get the tx promise for withdrawing all your earnings - * @returns await on call .wait to actually send the tx - */ - async getWithdrawAllTx(contractAddress: EthereumAddress): Promise { - const signer = await this.client.ethereum.getSidechainSigner() - const address = await signer.getAddress() - const duSidechain = await getSidechainContract(contractAddress, this.client) - - const withdrawable = await duSidechain.getWithdrawableEarnings(address) - if (withdrawable.eq(0)) { - throw new Error(`${address} has nothing to withdraw in (sidechain) data union ${duSidechain.address}`) - } - - if (this.client.options.minimumWithdrawTokenWei && withdrawable.lt(this.client.options.minimumWithdrawTokenWei)) { - throw new Error(`${address} has only ${withdrawable} to withdraw in ` - + `(sidechain) data union ${duSidechain.address} (min: ${this.client.options.minimumWithdrawTokenWei})`) - } - return duSidechain.withdrawAll(address, true) // sendToMainnet=true - } - - /** - * Withdraw earnings and "donate" them to the given address - * @returns get receipt once withdraw is complete (tokens are seen in mainnet) - */ - async withdrawAllTo( - recipientAddress: EthereumAddress, - options: DataUnionWithdrawOptions|undefined, - contractAddress: EthereumAddress - ): Promise { - const to = getAddress(recipientAddress) // throws if bad address - const tr = await untilWithdrawIsComplete( - this.client, - () => this.getWithdrawAllToTx(to, contractAddress), - () => this.getTokenBalance(to), - options - ) - return tr - } - - /** - * Withdraw earnings and "donate" them to the given address - * @param recipientAddress - the address to receive the tokens - * @returns await on call .wait to actually send the tx - */ - async getWithdrawAllToTx(recipientAddress: EthereumAddress, contractAddress: EthereumAddress): Promise { - const signer = await this.client.ethereum.getSidechainSigner() - const address = await signer.getAddress() - const duSidechain = await getSidechainContract(contractAddress, this.client) - const withdrawable = await duSidechain.getWithdrawableEarnings(address) - if (withdrawable.eq(0)) { - throw new Error(`${address} has nothing to withdraw in (sidechain) data union ${duSidechain.address}`) - } - return duSidechain.withdrawAllTo(recipientAddress, true) // sendToMainnet=true - } - - /** - * Member can sign off to "donate" all earnings to another address such that someone else - * can submit the transaction (and pay for the gas) - * This signature is only valid until next withdrawal takes place (using this signature or otherwise). - * Note that while it's a "blank cheque" for withdrawing all earnings at the moment it's used, it's - * invalidated by the first withdraw after signing it. In other words, any signature can be invalidated - * by making a "normal" withdraw e.g. `await streamrClient.withdrawAll()` - * Admin can execute the withdraw using this signature: ``` - * await adminStreamrClient.withdrawAllToSigned(memberAddress, recipientAddress, signature) - * ``` - * @param recipientAddress - the address authorized to receive the tokens - * @returns signature authorizing withdrawing all earnings to given recipientAddress - */ - async signWithdrawAllTo(recipientAddress: EthereumAddress, contractAddress: EthereumAddress): Promise { - return this.signWithdrawAmountTo(recipientAddress, BigNumber.from(0), contractAddress) - } - - /** - * Member can sign off to "donate" specific amount of earnings to another address such that someone else - * can submit the transaction (and pay for the gas) - * This signature is only valid until next withdrawal takes place (using this signature or otherwise). - * @param recipientAddress - the address authorized to receive the tokens - * @param amountTokenWei - that the signature is for (can't be used for less or for more) - * @returns signature authorizing withdrawing all earnings to given recipientAddress - */ - async signWithdrawAmountTo( - recipientAddress: EthereumAddress, - amountTokenWei: BigNumber|number|string, - contractAddress: EthereumAddress - ): Promise { - const to = getAddress(recipientAddress) // throws if bad address - const signer = this.client.ethereum.getSigner() // it shouldn't matter if it's mainnet or sidechain signer since key should be the same - const address = await signer.getAddress() - const duSidechain = await getSidechainContractReadOnly(contractAddress, this.client) - const memberData = await duSidechain.memberData(address) - if (memberData[0] === '0') { throw new Error(`${address} is not a member in Data Union (sidechain address ${duSidechain.address})`) } - const withdrawn = memberData[3] - // @ts-expect-error - const message = to + hexZeroPad(amountTokenWei, 32).slice(2) + duSidechain.address.slice(2) + hexZeroPad(withdrawn, 32).slice(2) - const signature = await signer.signMessage(arrayify(message)) - return signature - } -} - diff --git a/src/rest/authFetch.js b/src/rest/authFetch.js index 7fbc5b110..7fd8b2297 100644 --- a/src/rest/authFetch.js +++ b/src/rest/authFetch.js @@ -21,7 +21,7 @@ export class AuthFetchError extends Error { } } -const debug = Debug('StreamrClient:utils:authfetch') +const debug = Debug('StreamrClient:utils:authfetch') // TODO: could use the debug instance from the client? (e.g. client.debug.extend('authFetch')) let ID = 0 diff --git a/src/types.ts b/src/types.ts index 3daaca418..739afa588 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ import { F } from 'ts-toolbelt' +export type EthereumAddress = string + export type MaybeAsync = T | F.Promisify // Utility Type: make a function maybe async export type Todo = any diff --git a/test/integration/dataunion/DataUnionEndpoints.test.ts b/test/integration/dataunion/DataUnionEndpoints.test.ts deleted file mode 100644 index da5681917..000000000 --- a/test/integration/dataunion/DataUnionEndpoints.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { Contract, providers, Wallet } from 'ethers' -import { parseEther, formatEther } from 'ethers/lib/utils' -import { Mutex } from 'async-mutex' -import debug from 'debug' - -import { getEndpointUrl } from '../../../src/utils' -import authFetch from '../../../src/rest/authFetch' -import StreamrClient from '../../../src/StreamrClient' -import * as Token from '../../../contracts/TestToken.json' -import config from '../config' -import { DataUnion } from '../../../src/dataunion/DataUnion' - -const log = debug('StreamrClient::DataUnionEndpoints::integration-test') -// const log = console.log - -// @ts-expect-error -const providerSidechain = new providers.JsonRpcProvider(config.clientOptions.sidechain) -// @ts-expect-error -const providerMainnet = new providers.JsonRpcProvider(config.clientOptions.mainnet) -const adminWalletMainnet = new Wallet(config.clientOptions.auth.privateKey, providerMainnet) - -describe('DataUnionEndPoints', () => { - let adminClient: StreamrClient - - const tokenAdminWallet = new Wallet(config.tokenAdminPrivateKey, providerMainnet) - const tokenMainnet = new Contract(config.clientOptions.tokenAddress, Token.abi, tokenAdminWallet) - - afterAll(async () => { - providerMainnet.removeAllListeners() - providerSidechain.removeAllListeners() - await adminClient.ensureDisconnected() - }) - - const streamrClientCleanupList: StreamrClient[] = [] - afterAll(async () => Promise.all(streamrClientCleanupList.map((c) => c.ensureDisconnected()))) - - beforeAll(async () => { - log(`Connecting to Ethereum networks, config = ${JSON.stringify(config)}`) - const network = await providerMainnet.getNetwork() - log('Connected to "mainnet" network: ', JSON.stringify(network)) - const network2 = await providerSidechain.getNetwork() - log('Connected to sidechain network: ', JSON.stringify(network2)) - - log(`Minting 100 tokens to ${adminWalletMainnet.address}`) - const tx1 = await tokenMainnet.mint(adminWalletMainnet.address, parseEther('100')) - await tx1.wait() - - adminClient = new StreamrClient(config.clientOptions as any) - await adminClient.ensureConnected() - }, 10000) - - // fresh dataUnion for each test case, created NOT in parallel to avoid nonce troubles - const adminMutex = new Mutex() - async function deployDataUnionSync(testName: string) { - let dataUnion: DataUnion - await adminMutex.runExclusive(async () => { - const dataUnionName = testName + Date.now() - log(`Starting deployment of dataUnionName=${dataUnionName}`) - dataUnion = await adminClient.deployDataUnion({ dataUnionName }) - log(`DataUnion ${dataUnion.getAddress()} is ready to roll`) - - // product is needed for join requests to analyze the DU version - const createProductUrl = getEndpointUrl(config.clientOptions.restUrl, 'products') - await authFetch( - createProductUrl, - adminClient.session, - { - method: 'POST', - body: JSON.stringify({ - beneficiaryAddress: dataUnion.getAddress(), - type: 'DATAUNION', - dataUnionVersion: 2 - }) - } - ) - }) - return dataUnion! - } - - describe('Admin', () => { - const memberAddressList = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - '0x000000000000000000000000000000000000bEEF', - ] - - it('can add members', async () => { - const dataUnion = await deployDataUnionSync('add-members-test') - await adminMutex.runExclusive(() => dataUnion.addMembers(memberAddressList)) - const res = await dataUnion.getStats() - expect(+res.activeMemberCount).toEqual(3) - expect(+res.inactiveMemberCount).toEqual(0) - }, 150000) - - it('can remove members', async () => { - const dataUnion = await deployDataUnionSync('remove-members-test') - await adminMutex.runExclusive(async () => { - await dataUnion.addMembers(memberAddressList) - await dataUnion.removeMembers(memberAddressList.slice(1)) - }) - const res = await dataUnion.getStats() - expect(+res.activeMemberCount).toEqual(1) - expect(+res.inactiveMemberCount).toEqual(2) - }, 150000) - - it('can set admin fee', async () => { - const dataUnion = await deployDataUnionSync('set-admin-fee-test') - const oldFee = await dataUnion.getAdminFee() - await adminMutex.runExclusive(async () => { - log(`DU owner: ${await dataUnion.getAdminAddress()}`) - log(`Sending tx from ${adminClient.getAddress()}`) - const tr = await dataUnion.setAdminFee(0.1) - log(`Transaction receipt: ${JSON.stringify(tr)}`) - }) - const newFee = await dataUnion.getAdminFee() - expect(oldFee).toEqual(0) - expect(newFee).toEqual(0.1) - }, 150000) - - it('receives admin fees', async () => { - const dataUnion = await deployDataUnionSync('withdraw-admin-fees-test') - - await adminMutex.runExclusive(async () => { - await dataUnion.addMembers(memberAddressList) - const tr = await dataUnion.setAdminFee(0.1) - log(`Transaction receipt: ${JSON.stringify(tr)}`) - }) - - const amount = parseEther('2') - // eslint-disable-next-line no-underscore-dangle - const contract = await dataUnion._getContract() - const tokenAddress = await contract.token() - const adminTokenMainnet = new Contract(tokenAddress, Token.abi, adminWalletMainnet) - - await adminMutex.runExclusive(async () => { - log(`Transferring ${amount} token-wei ${adminWalletMainnet.address}->${dataUnion.getAddress()}`) - const txTokenToDU = await adminTokenMainnet.transfer(dataUnion.getAddress(), amount) - await txTokenToDU.wait() - }) - - const balance1 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) - log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance1)} (${balance1.toString()})`) - - log(`Transferred ${formatEther(amount)} tokens, next sending to bridge`) - const tx2 = await contract.sendTokensToBridge() - await tx2.wait() - - const balance2 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) - log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance2)} (${balance2.toString()})`) - - expect(formatEther(balance2.sub(balance1))).toEqual('0.2') - }, 150000) - }) - - describe('Anyone', () => { - const nonce = Date.now() - const memberAddressList = [ - `0x100000000000000000000000000${nonce}`, - `0x200000000000000000000000000${nonce}`, - `0x300000000000000000000000000${nonce}`, - ] - - async function getOutsiderClient() { - const client = new StreamrClient({ - ...config.clientOptions, - auth: { - apiKey: 'tester1-api-key' - }, - autoConnect: false, - autoDisconnect: false, - } as any) - streamrClientCleanupList.push(client) - return client - } - - it('can get dataUnion stats', async () => { - const dataUnion = await deployDataUnionSync('get-du-stats-test') - await adminMutex.runExclusive(async () => { - await dataUnion.addMembers(memberAddressList) - }) - const client = await getOutsiderClient() - const stats = await client.getDataUnion(dataUnion.getAddress()).getStats() - expect(+stats.activeMemberCount).toEqual(3) - expect(+stats.inactiveMemberCount).toEqual(0) - expect(+stats.joinPartAgentCount).toEqual(2) - expect(+stats.totalEarnings).toEqual(0) - expect(+stats.totalWithdrawable).toEqual(0) - expect(+stats.lifetimeMemberEarnings).toEqual(0) - }, 150000) - - it('can get member stats', async () => { - const dataUnion = await deployDataUnionSync('get-member-stats-test') - await adminMutex.runExclusive(async () => { - await dataUnion.addMembers(memberAddressList) - }) - const client = await getOutsiderClient() - const memberStats = await Promise.all(memberAddressList.map((m) => client.getDataUnion(dataUnion.getAddress()).getMemberStats(m))) - expect(memberStats).toMatchObject([{ - status: 'active', - earningsBeforeLastJoin: '0', - lmeAtJoin: '0', - totalEarnings: '0', - withdrawableEarnings: '0', - }, { - status: 'active', - earningsBeforeLastJoin: '0', - lmeAtJoin: '0', - totalEarnings: '0', - withdrawableEarnings: '0', - }, { - status: 'active', - earningsBeforeLastJoin: '0', - lmeAtJoin: '0', - totalEarnings: '0', - withdrawableEarnings: '0', - }]) - }, 150000) - }) -}) diff --git a/test/integration/dataunion/adminFee.test.ts b/test/integration/dataunion/adminFee.test.ts new file mode 100644 index 000000000..18a2bd48e --- /dev/null +++ b/test/integration/dataunion/adminFee.test.ts @@ -0,0 +1,80 @@ +import { Contract, providers, Wallet } from 'ethers' +import { parseEther, formatEther } from 'ethers/lib/utils' +import debug from 'debug' + +import StreamrClient from '../../../src/StreamrClient' +import * as Token from '../../../contracts/TestToken.json' +import config from '../config' + +const log = debug('StreamrClient::DataUnion::integration-test-adminFee') + +// @ts-expect-error +const providerSidechain = new providers.JsonRpcProvider(config.clientOptions.sidechain) +// @ts-expect-error +const providerMainnet = new providers.JsonRpcProvider(config.clientOptions.mainnet) +const adminWalletMainnet = new Wallet(config.clientOptions.auth.privateKey, providerMainnet) + +describe('DataUnion admin fee', () => { + let adminClient: StreamrClient + + const tokenAdminWallet = new Wallet(config.tokenAdminPrivateKey, providerMainnet) + const tokenMainnet = new Contract(config.clientOptions.tokenAddress, Token.abi, tokenAdminWallet) + + beforeAll(async () => { + log(`Connecting to Ethereum networks, config = ${JSON.stringify(config)}`) + const network = await providerMainnet.getNetwork() + log('Connected to "mainnet" network: ', JSON.stringify(network)) + const network2 = await providerSidechain.getNetwork() + log('Connected to sidechain network: ', JSON.stringify(network2)) + log(`Minting 100 tokens to ${adminWalletMainnet.address}`) + const tx1 = await tokenMainnet.mint(adminWalletMainnet.address, parseEther('100')) + await tx1.wait() + adminClient = new StreamrClient(config.clientOptions as any) + }, 10000) + + afterAll(() => { + providerMainnet.removeAllListeners() + providerSidechain.removeAllListeners() + }) + + it('can set admin fee', async () => { + const dataUnion = await adminClient.deployDataUnion() + const oldFee = await dataUnion.getAdminFee() + log(`DU owner: ${await dataUnion.getAdminAddress()}`) + log(`Sending tx from ${adminClient.getAddress()}`) + const tr = await dataUnion.setAdminFee(0.1) + log(`Transaction receipt: ${JSON.stringify(tr)}`) + const newFee = await dataUnion.getAdminFee() + expect(oldFee).toEqual(0) + expect(newFee).toEqual(0.1) + }, 150000) + + it('receives admin fees', async () => { + const dataUnion = await adminClient.deployDataUnion() + const tr = await dataUnion.setAdminFee(0.1) + log(`Transaction receipt: ${JSON.stringify(tr)}`) + + const amount = parseEther('2') + // eslint-disable-next-line no-underscore-dangle + const contract = await dataUnion._getContract() + const tokenAddress = await contract.token() + const adminTokenMainnet = new Contract(tokenAddress, Token.abi, adminWalletMainnet) + + log(`Transferring ${amount} token-wei ${adminWalletMainnet.address}->${dataUnion.getAddress()}`) + const txTokenToDU = await adminTokenMainnet.transfer(dataUnion.getAddress(), amount) + await txTokenToDU.wait() + + const balance1 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) + log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance1)} (${balance1.toString()})`) + + log(`Transferred ${formatEther(amount)} tokens, next sending to bridge`) + const tx2 = await contract.sendTokensToBridge() + await tx2.wait() + + const balance2 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) + log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance2)} (${balance2.toString()})`) + + expect(formatEther(balance2.sub(balance1))).toEqual('0.2') + }, 150000) + +}) diff --git a/test/integration/dataunion/calculate.test.ts b/test/integration/dataunion/calculate.test.ts index 17fc03929..57d7002d6 100644 --- a/test/integration/dataunion/calculate.test.ts +++ b/test/integration/dataunion/calculate.test.ts @@ -3,9 +3,9 @@ import debug from 'debug' import StreamrClient from '../../../src/StreamrClient' import config from '../config' +import { createClient, expectInvalidAddress } from '../../utils' -const log = debug('StreamrClient::DataUnionEndpoints::integration-test-calculate') -// const { log } = console +const log = debug('StreamrClient::DataUnion::integration-test-calculate') // @ts-expect-error const providerSidechain = new providers.JsonRpcProvider(config.clientOptions.sidechain) @@ -16,28 +16,36 @@ const adminWalletMainnet = new Wallet(config.clientOptions.auth.privateKey, prov // This test will fail when new docker images are pushed with updated DU smart contracts // -> generate new codehashes for getDataUnionMainnetAddress() and getDataUnionSidechainAddress() -it('DataUnionEndPoints: calculate DU address before deployment', async () => { - log(`Connecting to Ethereum networks, config = ${JSON.stringify(config)}`) - const network = await providerMainnet.getNetwork() - log('Connected to "mainnet" network: ', JSON.stringify(network)) - const network2 = await providerSidechain.getNetwork() - log('Connected to sidechain network: ', JSON.stringify(network2)) +describe('DataUnion calculate', () => { - const adminClient = new StreamrClient(config.clientOptions as any) - await adminClient.ensureConnected() + afterAll(() => { + providerMainnet.removeAllListeners() + providerSidechain.removeAllListeners() + }) - const dataUnionName = 'test-' + Date.now() - // eslint-disable-next-line no-underscore-dangle - const dataUnionPredicted = adminClient._getDataUnionFromName({ dataUnionName, deployerAddress: adminWalletMainnet.address }) + it('calculate DU address before deployment', async () => { + log(`Connecting to Ethereum networks, config = ${JSON.stringify(config)}`) + const network = await providerMainnet.getNetwork() + log('Connected to "mainnet" network: ', JSON.stringify(network)) + const network2 = await providerSidechain.getNetwork() + log('Connected to sidechain network: ', JSON.stringify(network2)) - const dataUnionDeployed = await adminClient.deployDataUnion({ dataUnionName }) - const version = await dataUnionDeployed.getVersion() + const adminClient = new StreamrClient(config.clientOptions as any) - await providerMainnet.removeAllListeners() - await providerSidechain.removeAllListeners() - await adminClient.ensureDisconnected() + const dataUnionName = 'test-' + Date.now() + // eslint-disable-next-line no-underscore-dangle + const dataUnionPredicted = adminClient._getDataUnionFromName({ dataUnionName, deployerAddress: adminWalletMainnet.address }) - expect(dataUnionPredicted.getAddress()).toBe(dataUnionDeployed.getAddress()) - expect(dataUnionPredicted.getSidechainAddress()).toBe(dataUnionDeployed.getSidechainAddress()) - expect(version).toBe(2) -}, 60000) + const dataUnionDeployed = await adminClient.deployDataUnion({ dataUnionName }) + const version = await dataUnionDeployed.getVersion() + + expect(dataUnionPredicted.getAddress()).toBe(dataUnionDeployed.getAddress()) + expect(dataUnionPredicted.getSidechainAddress()).toBe(dataUnionDeployed.getSidechainAddress()) + expect(version).toBe(2) + }, 60000) + + it('get DataUnion: invalid address', () => { + const client = createClient(providerSidechain) + return expectInvalidAddress(async () => client.getDataUnion('invalid-address')) + }) +}) diff --git a/test/integration/dataunion/deploy.test.ts b/test/integration/dataunion/deploy.test.ts index aad865cca..6caca2e77 100644 --- a/test/integration/dataunion/deploy.test.ts +++ b/test/integration/dataunion/deploy.test.ts @@ -3,17 +3,16 @@ import debug from 'debug' import StreamrClient from '../../../src/StreamrClient' import config from '../config' +import { createMockAddress } from '../../utils' -const log = debug('StreamrClient::DataUnionEndpoints::integration-test-deploy') +const log = debug('StreamrClient::DataUnion::integration-test-deploy') // @ts-expect-error const providerSidechain = new providers.JsonRpcProvider(config.clientOptions.sidechain) // @ts-expect-error const providerMainnet = new providers.JsonRpcProvider(config.clientOptions.mainnet) -const createMockAddress = () => '0x000000000000000000000000000' + Date.now() - -describe('DataUnion deployment', () => { +describe('DataUnion deploy', () => { let adminClient: StreamrClient @@ -24,9 +23,13 @@ describe('DataUnion deployment', () => { const network2 = await providerSidechain.getNetwork() log('Connected to sidechain network: ', JSON.stringify(network2)) adminClient = new StreamrClient(config.clientOptions as any) - await adminClient.ensureConnected() }, 60000) + afterAll(() => { + providerMainnet.removeAllListeners() + providerSidechain.removeAllListeners() + }) + describe('owner', () => { it('not specified: defaults to deployer', async () => { diff --git a/test/integration/dataunion/member.test.ts b/test/integration/dataunion/member.test.ts index ca90b4f5c..68adf9a51 100644 --- a/test/integration/dataunion/member.test.ts +++ b/test/integration/dataunion/member.test.ts @@ -4,19 +4,17 @@ import debug from 'debug' import StreamrClient from '../../../src/StreamrClient' import config from '../config' import { DataUnion, JoinRequestState } from '../../../src/dataunion/DataUnion' -import { fakePrivateKey } from '../../utils' +import { createMockAddress, expectInvalidAddress, fakePrivateKey } from '../../utils' import authFetch from '../../../src/rest/authFetch' import { getEndpointUrl } from '../../../src/utils' -const log = debug('StreamrClient::DataUnionEndpoints::integration-test-member') +const log = debug('StreamrClient::DataUnion::integration-test-member') // @ts-expect-error const providerSidechain = new providers.JsonRpcProvider(config.clientOptions.sidechain) // @ts-expect-error const providerMainnet = new providers.JsonRpcProvider(config.clientOptions.mainnet) -const createMockAddress = () => '0x000000000000000000000000000' + Date.now() - const joinMember = async (memberWallet: Wallet, secret: string|undefined, dataUnionAddress: string) => { const memberClient = new StreamrClient({ ...config.clientOptions, @@ -24,7 +22,6 @@ const joinMember = async (memberWallet: Wallet, secret: string|undefined, dataUn privateKey: memberWallet.privateKey, } } as any) - await memberClient.ensureConnected() return memberClient.getDataUnion(dataUnionAddress).join(secret) } @@ -40,7 +37,6 @@ describe('DataUnion member', () => { const network2 = await providerSidechain.getNetwork() log('Connected to sidechain network: ', JSON.stringify(network2)) const adminClient = new StreamrClient(config.clientOptions as any) - await adminClient.ensureConnected() dataUnion = await adminClient.deployDataUnion() // product is needed for join requests to analyze the DU version const createProductUrl = getEndpointUrl(config.clientOptions.restUrl, 'products') @@ -59,6 +55,11 @@ describe('DataUnion member', () => { secret = await dataUnion.createSecret() }, 60000) + afterAll(() => { + providerMainnet.removeAllListeners() + providerSidechain.removeAllListeners() + }) + it('random user is not a member', async () => { const userAddress = createMockAddress() const isMember = await dataUnion.isMember(userAddress) @@ -99,4 +100,11 @@ describe('DataUnion member', () => { expect(isMember).toBe(false) }, 60000) + it('invalid address', () => { + return Promise.all([ + expectInvalidAddress(() => dataUnion.addMembers(['invalid-address'])), + expectInvalidAddress(() => dataUnion.removeMembers(['invalid-address'])), + expectInvalidAddress(() => dataUnion.isMember('invalid-address')) + ]) + }) }) diff --git a/test/integration/dataunion/signature.test.ts b/test/integration/dataunion/signature.test.ts index 000befdea..d90a24fda 100644 --- a/test/integration/dataunion/signature.test.ts +++ b/test/integration/dataunion/signature.test.ts @@ -9,66 +9,70 @@ import * as DataUnionSidechain from '../../../contracts/DataUnionSidechain.json' import config from '../config' import authFetch from '../../../src/rest/authFetch' -const log = debug('StreamrClient::DataUnionEndpoints::integration-test-signature') +const log = debug('StreamrClient::DataUnion::integration-test-signature') // @ts-expect-error const providerSidechain = new providers.JsonRpcProvider(config.clientOptions.sidechain) const adminWalletSidechain = new Wallet(config.clientOptions.auth.privateKey, providerSidechain) -it('DataUnion signature', async () => { +describe('DataUnion signature', () => { - const adminClient = new StreamrClient(config.clientOptions as any) - await adminClient.ensureConnected() - const dataUnion = await adminClient.deployDataUnion() - const secret = await dataUnion.createSecret('DataUnionEndpoints test secret') - log(`DataUnion ${dataUnion.getAddress()} is ready to roll`) + afterAll(() => { + providerSidechain.removeAllListeners() + }) + + it('check validity', async () => { + const adminClient = new StreamrClient(config.clientOptions as any) + const dataUnion = await adminClient.deployDataUnion() + const secret = await dataUnion.createSecret('test secret') + log(`DataUnion ${dataUnion.getAddress()} is ready to roll`) - const memberWallet = new Wallet(`0x100000000000000000000000000000000000000012300000001${Date.now()}`, providerSidechain) - const member2Wallet = new Wallet(`0x100000000000000000000000000000000000000012300000002${Date.now()}`, providerSidechain) + const memberWallet = new Wallet(`0x100000000000000000000000000000000000000012300000001${Date.now()}`, providerSidechain) + const member2Wallet = new Wallet(`0x100000000000000000000000000000000000000012300000002${Date.now()}`, providerSidechain) - const memberClient = new StreamrClient({ - ...config.clientOptions, - auth: { - privateKey: memberWallet.privateKey - } - } as any) - await memberClient.ensureConnected() + const memberClient = new StreamrClient({ + ...config.clientOptions, + auth: { + privateKey: memberWallet.privateKey + } + } as any) - // product is needed for join requests to analyze the DU version - const createProductUrl = getEndpointUrl(config.clientOptions.restUrl, 'products') - await authFetch(createProductUrl, adminClient.session, { - method: 'POST', - body: JSON.stringify({ - beneficiaryAddress: dataUnion.getAddress(), - type: 'DATAUNION', - dataUnionVersion: 2 + // product is needed for join requests to analyze the DU version + const createProductUrl = getEndpointUrl(config.clientOptions.restUrl, 'products') + await authFetch(createProductUrl, adminClient.session, { + method: 'POST', + body: JSON.stringify({ + beneficiaryAddress: dataUnion.getAddress(), + type: 'DATAUNION', + dataUnionVersion: 2 + }) }) - }) - await memberClient.getDataUnion(dataUnion.getAddress()).join(secret) + await memberClient.getDataUnion(dataUnion.getAddress()).join(secret) - // eslint-disable-next-line no-underscore-dangle - const contract = await dataUnion._getContract() - const sidechainContract = new Contract(contract.sidechain.address, DataUnionSidechain.abi, adminWalletSidechain) - const tokenSidechain = new Contract(config.clientOptions.tokenAddressSidechain, Token.abi, adminWalletSidechain) + // eslint-disable-next-line no-underscore-dangle + const contract = await dataUnion._getContract() + const sidechainContract = new Contract(contract.sidechain.address, DataUnionSidechain.abi, adminWalletSidechain) + const tokenSidechain = new Contract(config.clientOptions.tokenAddressSidechain, Token.abi, adminWalletSidechain) - const signature = await memberClient.getDataUnion(dataUnion.getAddress()).signWithdrawAllTo(member2Wallet.address) - const signature2 = await memberClient - .getDataUnion(dataUnion.getAddress()) - .signWithdrawAmountTo(member2Wallet.address, parseEther('1')) - const signature3 = await memberClient - .getDataUnion(dataUnion.getAddress()) - .signWithdrawAmountTo(member2Wallet.address, 3000000000000000) // 0.003 tokens + const signature = await memberClient.getDataUnion(dataUnion.getAddress()).signWithdrawAllTo(member2Wallet.address) + const signature2 = await memberClient + .getDataUnion(dataUnion.getAddress()) + .signWithdrawAmountTo(member2Wallet.address, parseEther('1')) + const signature3 = await memberClient + .getDataUnion(dataUnion.getAddress()) + .signWithdrawAmountTo(member2Wallet.address, 3000000000000000) // 0.003 tokens - const isValid = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, '0', signature) // '0' = all earnings - const isValid2 = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, parseEther('1'), signature2) - const isValid3 = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, '3000000000000000', signature3) - log(`Signature for all tokens ${memberWallet.address} -> ${member2Wallet.address}: ${signature}, checked ${isValid ? 'OK' : '!!!BROKEN!!!'}`) - log(`Signature for 1 token ${memberWallet.address} -> ${member2Wallet.address}: ${signature2}, checked ${isValid2 ? 'OK' : '!!!BROKEN!!!'}`) - log(`Signature for 0.003 tokens ${memberWallet.address} -> ${member2Wallet.address}: ${signature3}, checked ${isValid3 ? 'OK' : '!!!BROKEN!!!'}`) - log(`sidechainDU(${sidechainContract.address}) token bal ${await tokenSidechain.balanceOf(sidechainContract.address)}`) + const isValid = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, '0', signature) // '0' = all earnings + const isValid2 = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, parseEther('1'), signature2) + const isValid3 = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, '3000000000000000', signature3) + log(`Signature for all tokens ${memberWallet.address} -> ${member2Wallet.address}: ${signature}, checked ${isValid ? 'OK' : '!!!BROKEN!!!'}`) + log(`Signature for 1 token ${memberWallet.address} -> ${member2Wallet.address}: ${signature2}, checked ${isValid2 ? 'OK' : '!!!BROKEN!!!'}`) + log(`Signature for 0.003 tokens ${memberWallet.address} -> ${member2Wallet.address}: ${signature3}, checked ${isValid3 ? 'OK' : '!!!BROKEN!!!'}`) + log(`sidechainDU(${sidechainContract.address}) token bal ${await tokenSidechain.balanceOf(sidechainContract.address)}`) - expect(isValid).toBe(true) - expect(isValid2).toBe(true) - expect(isValid3).toBe(true) + expect(isValid).toBe(true) + expect(isValid2).toBe(true) + expect(isValid3).toBe(true) + }, 100000) -}, 100000) +}) diff --git a/test/integration/dataunion/stats.test.ts b/test/integration/dataunion/stats.test.ts new file mode 100644 index 000000000..7ea3a36ae --- /dev/null +++ b/test/integration/dataunion/stats.test.ts @@ -0,0 +1,98 @@ +import { providers } from 'ethers' +import debug from 'debug' + +import StreamrClient from '../../../src/StreamrClient' +import config from '../config' +import { DataUnion, MemberStatus } from '../../../src/dataunion/DataUnion' +import { createClient, createMockAddress, expectInvalidAddress } from '../../utils' +import { BigNumber } from '@ethersproject/bignumber' + +const log = debug('StreamrClient::DataUnion::integration-test-stats') + +// @ts-expect-error +const providerSidechain = new providers.JsonRpcProvider(config.clientOptions.sidechain) +// @ts-expect-error +const providerMainnet = new providers.JsonRpcProvider(config.clientOptions.mainnet) + +describe('DataUnion stats', () => { + + let adminClient: StreamrClient + let dataUnion: DataUnion + let queryClient: StreamrClient + const nonce = Date.now() + const activeMemberAddressList = [ + `0x100000000000000000000000000${nonce}`, + `0x200000000000000000000000000${nonce}`, + `0x300000000000000000000000000${nonce}`, + ] + const inactiveMember = createMockAddress() + + beforeAll(async () => { + log(`Connecting to Ethereum networks, config = ${JSON.stringify(config)}`) + const network = await providerMainnet.getNetwork() + log('Connected to "mainnet" network: ', JSON.stringify(network)) + const network2 = await providerSidechain.getNetwork() + log('Connected to sidechain network: ', JSON.stringify(network2)) + adminClient = new StreamrClient(config.clientOptions as any) + dataUnion = await adminClient.deployDataUnion() + await dataUnion.addMembers(activeMemberAddressList.concat([inactiveMember])) + await dataUnion.removeMembers([inactiveMember]) + queryClient = createClient(providerSidechain) + }, 60000) + + afterAll(() => { + providerMainnet.removeAllListeners() + providerSidechain.removeAllListeners() + }) + + it('DataUnion stats', async () => { + const stats = await queryClient.getDataUnion(dataUnion.getAddress()).getStats() + expect(stats.activeMemberCount).toEqual(BigNumber.from(3)) + expect(stats.inactiveMemberCount).toEqual(BigNumber.from(1)) + expect(stats.joinPartAgentCount).toEqual(BigNumber.from(2)) + expect(stats.totalEarnings).toEqual(BigNumber.from(0)) + expect(stats.totalWithdrawable).toEqual(BigNumber.from(0)) + expect(stats.lifetimeMemberEarnings).toEqual(BigNumber.from(0)) + }, 150000) + + it('member stats', async () => { + const memberStats = await Promise.all(activeMemberAddressList.concat([inactiveMember]).map((m) => queryClient.getDataUnion(dataUnion.getAddress()).getMemberStats(m))) + const ZERO = BigNumber.from(0) + expect(memberStats).toMatchObject([{ + status: MemberStatus.ACTIVE, + earningsBeforeLastJoin: ZERO, + totalEarnings: ZERO, + withdrawableEarnings: ZERO, + }, { + status: MemberStatus.ACTIVE, + earningsBeforeLastJoin: ZERO, + totalEarnings: ZERO, + withdrawableEarnings: ZERO, + }, { + status: MemberStatus.ACTIVE, + earningsBeforeLastJoin: ZERO, + totalEarnings: ZERO, + withdrawableEarnings: ZERO, + }, { + status: MemberStatus.INACTIVE, + earningsBeforeLastJoin: ZERO, + totalEarnings: ZERO, + withdrawableEarnings: ZERO, + }]) + }, 150000) + + it('member stats: no member', async () => { + const memberStats = await queryClient.getDataUnion(dataUnion.getAddress()).getMemberStats(createMockAddress()) + const ZERO = BigNumber.from(0) + expect(memberStats).toMatchObject({ + status: MemberStatus.NONE, + earningsBeforeLastJoin: ZERO, + totalEarnings: ZERO, + withdrawableEarnings: ZERO + }) + }) + + it('member stats: invalid address', () => { + return expectInvalidAddress(() => dataUnion.getMemberStats('invalid-address')) + }) +}) diff --git a/test/integration/dataunion/withdraw.test.ts b/test/integration/dataunion/withdraw.test.ts index dd8ce036c..cba025574 100644 --- a/test/integration/dataunion/withdraw.test.ts +++ b/test/integration/dataunion/withdraw.test.ts @@ -9,9 +9,10 @@ import * as Token from '../../../contracts/TestToken.json' import * as DataUnionSidechain from '../../../contracts/DataUnionSidechain.json' import config from '../config' import authFetch from '../../../src/rest/authFetch' +import { createClient, createMockAddress, expectInvalidAddress } from '../../utils' +import { MemberStatus } from '../../../src/dataunion/DataUnion' -const log = debug('StreamrClient::DataUnionEndpoints::integration-test-withdraw') -// const { log } = console +const log = debug('StreamrClient::DataUnion::integration-test-withdraw') // @ts-expect-error const providerSidechain = new providers.JsonRpcProvider(config.clientOptions.sidechain) @@ -45,10 +46,9 @@ const testWithdraw = async ( await tx1.wait() const adminClient = new StreamrClient(config.clientOptions as any) - await adminClient.ensureConnected() const dataUnion = await adminClient.deployDataUnion() - const secret = await dataUnion.createSecret('DataUnionEndpoints test secret') + const secret = await dataUnion.createSecret('test secret') log(`DataUnion ${dataUnion.getAddress()} is ready to roll`) // dataUnion = await adminClient.getDataUnionContract({dataUnion: "0xd778CfA9BB1d5F36E42526B2BAFD07B74b4066c0"}) @@ -69,7 +69,6 @@ const testWithdraw = async ( privateKey: memberWallet.privateKey } } as any) - await memberClient.ensureConnected() // product is needed for join requests to analyze the DU version const createProductUrl = getEndpointUrl(config.clientOptions.restUrl, 'products') @@ -142,17 +141,11 @@ const testWithdraw = async ( const balanceAfter = await getBalanceAfter(memberWallet, adminTokenMainnet) const balanceIncrease = balanceAfter.sub(balanceBefore) - await providerMainnet.removeAllListeners() - await providerSidechain.removeAllListeners() - await memberClient.ensureDisconnected() - await adminClient.ensureDisconnected() - expect(stats).toMatchObject({ - status: 'active', - earningsBeforeLastJoin: '0', - lmeAtJoin: '0', - totalEarnings: '1000000000000000000', - withdrawableEarnings: '1000000000000000000', + status: MemberStatus.ACTIVE, + earningsBeforeLastJoin: BigNumber.from(0), + totalEarnings: BigNumber.from('1000000000000000000'), + withdrawableEarnings: BigNumber.from('1000000000000000000') }) expect(withdrawTr.logs[0].address).toBe(config.clientOptions.tokenAddressSidechain) expect(balanceIncrease.toString()).toBe(amount.toString()) @@ -160,6 +153,11 @@ const testWithdraw = async ( describe('DataUnion withdraw', () => { + afterAll(() => { + providerMainnet.removeAllListeners() + providerSidechain.removeAllListeners() + }) + describe('Member', () => { it('by member itself', () => { @@ -208,4 +206,16 @@ describe('DataUnion withdraw', () => { }, 300000) }) + it('Validate address', async () => { + const client = createClient(providerSidechain) + const dataUnion = client.getDataUnion(createMockAddress()) + return Promise.all([ + expectInvalidAddress(() => dataUnion.getWithdrawableEarnings('invalid-address')), + expectInvalidAddress(() => dataUnion.withdrawAllTo('invalid-address')), + expectInvalidAddress(() => dataUnion.signWithdrawAllTo('invalid-address')), + expectInvalidAddress(() => dataUnion.signWithdrawAmountTo('invalid-address', '123')), + expectInvalidAddress(() => dataUnion.withdrawAllToMember('invalid-address')), + expectInvalidAddress(() => dataUnion.withdrawAllToSigned('invalid-address', 'invalid-address', 'mock-signature')) + ]) + }) }) diff --git a/test/utils.js b/test/utils.ts similarity index 75% rename from test/utils.js rename to test/utils.ts index 15dbd7632..7d2e92ce9 100644 --- a/test/utils.js +++ b/test/utils.ts @@ -1,21 +1,22 @@ import { inspect } from 'util' - import { wait } from 'streamr-test-utils' - +import { providers, Wallet } from 'ethers' import { pTimeout, counterId, AggregatedError } from '../src/utils' import { validateOptions } from '../src/stream/utils' +import StreamrClient from '../src/StreamrClient' const crypto = require('crypto') +const config = require('./integration/config') -export const uid = (prefix) => counterId(`p${process.pid}${prefix ? '-' + prefix : ''}`) +export const uid = (prefix?: string) => counterId(`p${process.pid}${prefix ? '-' + prefix : ''}`) export function fakePrivateKey() { return crypto.randomBytes(32).toString('hex') } -const TEST_REPEATS = parseInt(process.env.TEST_REPEATS, 10) || 1 +const TEST_REPEATS = (process.env.TEST_REPEATS) ? parseInt(process.env.TEST_REPEATS, 10) : 1 -export function describeRepeats(msg, fn, describeFn = describe) { +export function describeRepeats(msg: any, fn: any, describeFn = describe) { for (let k = 0; k < TEST_REPEATS; k++) { // eslint-disable-next-line no-loop-func describe(msg, () => { @@ -24,16 +25,16 @@ export function describeRepeats(msg, fn, describeFn = describe) { } } -describeRepeats.skip = (msg, fn) => { +describeRepeats.skip = (msg: any, fn: any) => { describe.skip(`${msg} – test repeat ALL of ${TEST_REPEATS}`, fn) } -describeRepeats.only = (msg, fn) => { +describeRepeats.only = (msg: any, fn: any) => { describeRepeats(msg, fn, describe.only) } -export async function collect(iterator, fn = async () => {}) { - const received = [] +export async function collect(iterator: any, fn: (item: any) => void = async () => {}) { + const received: any[] = [] for await (const msg of iterator) { received.push(msg.getParsedContent()) await fn({ @@ -45,24 +46,25 @@ export async function collect(iterator, fn = async () => {}) { } export function addAfterFn() { - const afterFns = [] + const afterFns: any[] = [] afterEach(async () => { const fns = afterFns.slice() afterFns.length = 0 + // @ts-expect-error AggregatedError.throwAllSettled(await Promise.allSettled(fns.map((fn) => fn()))) }) - return (fn) => { + return (fn: any) => { afterFns.push(fn) } } -export const Msg = (opts) => ({ +export const Msg = (opts: any) => ({ value: uid('msg'), ...opts, }) -function defaultMessageMatchFn(msgTarget, msgGot) { +function defaultMessageMatchFn(msgTarget: any, msgGot: any) { if (msgTarget.streamMessage.signature) { // compare signatures by default return msgTarget.streamMessage.signature === msgGot.signature @@ -70,9 +72,9 @@ function defaultMessageMatchFn(msgTarget, msgGot) { return JSON.stringify(msgGot.content) === JSON.stringify(msgTarget.streamMessage.getParsedContent()) } -export function getWaitForStorage(client, defaultOpts = {}) { +export function getWaitForStorage(client: StreamrClient, defaultOpts = {}) { /* eslint-disable no-await-in-loop */ - return async (publishRequest, opts = {}) => { + return async (publishRequest: any, opts = {}) => { const { streamId, streamPartition = 0, interval = 500, timeout = 5000, count = 100, messageMatchFn = defaultMessageMatchFn } = validateOptions({ @@ -96,14 +98,15 @@ export function getWaitForStorage(client, defaultOpts = {}) { duration }, { publishRequest, - last: last.map((l) => l.content), + last: last.map((l: any) => l.content), }) - const err = new Error(`timed out after ${duration}ms waiting for message`) + const err: any = new Error(`timed out after ${duration}ms waiting for message`) err.publishRequest = publishRequest throw err } last = await client.getStreamLast({ + // @ts-expect-error streamId, streamPartition, count, @@ -118,7 +121,7 @@ export function getWaitForStorage(client, defaultOpts = {}) { client.debug('message not found, retrying... %o', { msg: publishRequest.streamMessage.getParsedContent(), - last: last.map(({ content }) => content) + last: last.map(({ content }: any) => content) }) await wait(interval) @@ -127,7 +130,7 @@ export function getWaitForStorage(client, defaultOpts = {}) { /* eslint-enable no-await-in-loop */ } -export function getPublishTestMessages(client, defaultOpts = {}) { +export function getPublishTestMessages(client: StreamrClient, defaultOpts = {}) { // second argument could also be streamId if (typeof defaultOpts === 'string') { // eslint-disable-next-line no-param-reassign @@ -147,7 +150,7 @@ export function getPublishTestMessages(client, defaultOpts = {}) { waitForLast = false, // wait for message to hit storage waitForLastCount, waitForLastTimeout, - beforeEach = (m) => m, + beforeEach = (m: any) => m, afterEach = () => {}, timestamp, partitionKey, @@ -210,7 +213,7 @@ export function getPublishTestMessages(client, defaultOpts = {}) { streamPartition, timeout: waitForLastTimeout, count: waitForLastCount, - messageMatchFn(m, b) { + messageMatchFn(m: any, b: any) { checkDone() return m.streamMessage.signature === b.signature } @@ -223,7 +226,7 @@ export function getPublishTestMessages(client, defaultOpts = {}) { } } - const publishTestMessages = async (...args) => { + const publishTestMessages = async (...args: any[]) => { const published = await publishTestMessagesRaw(...args) return published.map(([msg]) => msg) } @@ -231,3 +234,19 @@ export function getPublishTestMessages(client, defaultOpts = {}) { publishTestMessages.raw = publishTestMessagesRaw return publishTestMessages } + +export const createMockAddress = () => '0x000000000000000000000000000' + Date.now() + +export const createClient = (providerSidechain: providers.JsonRpcProvider) => { + const wallet = new Wallet(`0x100000000000000000000000000000000000000012300000001${Date.now()}`, providerSidechain) + return new StreamrClient({ + ...config.clientOptions, + auth: { + privateKey: wallet.privateKey + } + }) +} + +export const expectInvalidAddress = (operation: () => Promise) => { + return expect(() => operation()).rejects.toThrow('invalid address') +}