diff --git a/.github/workflows/test-code.yml b/.github/workflows/test-code.yml index 240e1ac88..e7a3added 100644 --- a/.github/workflows/test-code.yml +++ b/.github/workflows/test-code.yml @@ -109,7 +109,7 @@ jobs: - name: Start Streamr Docker Stack uses: streamr-dev/streamr-docker-dev-action@v1.0.0-alpha.3 with: - services-to-start: "mysql redis engine-and-editor cassandra parity-node0 parity-sidechain-node0 bridge broker-node-no-storage-1 broker-node-no-storage-2 broker-node-storage-1 nginx smtp" + services-to-start: "mysql redis core-api cassandra parity-node0 parity-sidechain-node0 bridge broker-node-no-storage-1 broker-node-no-storage-2 broker-node-storage-1 nginx smtp" - name: Run Test run: npm run $TEST_NAME @@ -131,7 +131,7 @@ jobs: - name: Start Streamr Docker Stack uses: streamr-dev/streamr-docker-dev-action@v1.0.0-alpha.3 with: - services-to-start: "mysql redis engine-and-editor cassandra parity-node0 parity-sidechain-node0 bridge broker-node-no-storage-1 broker-node-no-storage-2 broker-node-storage-1 nginx smtp" + services-to-start: "mysql redis core-api cassandra parity-node0 parity-sidechain-node0 bridge broker-node-no-storage-1 broker-node-no-storage-2 broker-node-storage-1 nginx smtp" - uses: nick-invision/retry@v2 name: Run Test with: diff --git a/README.md b/README.md index e103ac5a7..6ea63d4ca 100644 --- a/README.md +++ b/README.md @@ -360,11 +360,11 @@ const dataUnion = client.getDataUnion(dataUnionAddress) ### Admin Functions @@ -375,8 +375,10 @@ These DataUnion-specific options can be given to `new StreamrClient` options: | setAdminFee(newFeeFraction) | Transaction receipt | `newFeeFraction` is a `Number` between 0.0 and 1.0 (inclusive) | | addMembers(memberAddressList) | Transaction receipt | Add members | | removeMembers(memberAddressList) | Transaction receipt | Remove members from Data Union | -| withdrawAllToMember(memberAddress\[, [options](#withdraw-options)\]) | Transaction receipt | Send all withdrawable earnings to the member's address | -| withdrawAllToSigned(memberAddress, recipientAddress, signature\[, [options](#withdraw-options)\]) | Transaction receipt | Send all withdrawable earnings to the address signed off by the member (see [example below](#member-functions)) | +| withdrawAllToMember(memberAddress\[, [options](#withdraw-options)\]) | Transaction receipt `*` | Send all withdrawable earnings to the member's address | +| withdrawAllToSigned(memberAddress, recipientAddress, signature\[, [options](#withdraw-options)\]) | Transaction receipt `*` | Send all withdrawable earnings to the address signed off by the member (see [example below](#member-functions)) | + +`*` The return value type may vary depending on [the given options](#withdraw-options) that describe the use case. Here's how to deploy a Data Union contract with 30% Admin fee and add some members: @@ -399,14 +401,17 @@ const receipt = await dataUnion.addMembers([ ### Member functions -| Name | Returns | Description | -| :---------------------------------------------------------------- | :------------------ | :-------------------------------------------------------------------------- | -| join(\[secret]) | JoinRequest | Join the Data Union (if a valid secret is given, the promise waits until the automatic join request has been processed) | -| isMember(memberAddress) | boolean | | -| withdrawAll(\[[options](#withdraw-options)\]) | Transaction receipt | Withdraw funds from Data Union | -| withdrawAllTo(recipientAddress\[, [options](#withdraw-options)\]) | Transaction receipt | Donate/move your earnings to recipientAddress instead of your memberAddress | -| signWithdrawAllTo(recipientAddress) | Signature (string) | Signature that can be used to withdraw all available tokens to given recipientAddress | -| signWithdrawAmountTo(recipientAddress, amountTokenWei) | Signature (string) | Signature that can be used to withdraw a specific amount of tokens to given recipientAddress | +| Name | Returns | Description | +| :-------------------------------------------------------------------- | :------------------------ | :-------------------------------------------------------------------------- | +| join(\[secret]) | JoinRequest | Join the Data Union (if a valid secret is given, the promise waits until the automatic join request has been processed) | +| isMember(memberAddress) | boolean | | +| withdrawAll(\[[options](#withdraw-options)\]) | Transaction receipt `*` | Withdraw funds from Data Union | +| withdrawAllTo(recipientAddress\[, [options](#withdraw-options)\]) | Transaction receipt `*` | Donate/move your earnings to recipientAddress instead of your memberAddress | +| signWithdrawAllTo(recipientAddress) | Signature (string) | Signature that can be used to withdraw all available tokens to given recipientAddress | +| signWithdrawAmountTo(recipientAddress, amountTokenWei) | Signature (string) | Signature that can be used to withdraw a specific amount of tokens to given recipientAddress | +| transportMessage(messageHash[, pollingIntervalMs[, retryTimeoutMs]]) | Transaction receipt | Send the mainnet transaction to withdraw tokens from the sidechain | + +`*` The return value type may vary depending on [the given options](#withdraw-options) that describe the use case. Here's an example on how to sign off on a withdraw to (any) recipientAddress (NOTE: this requires no gas!) @@ -434,6 +439,15 @@ const dataUnion = client.getDataUnion(dataUnionAddress) const receipt = await dataUnion.withdrawAllToSigned(memberAddress, recipientAddress, signature) ``` +The `messageHash` argument to `transportMessage` will come from the withdraw function with the specific options. The following is equivalent to the above withdraw line: +```js +const messageHash = await dataUnion.withdrawAllToSigned(memberAddress, recipientAddress, signature, { + payForTransport: false, + waitUntilTransportIsComplete: false, +}) // only pay for sidechain gas +const receipt = await dataUnion.transportMessage(messageHash) // only pay for mainnet gas +``` + ### Query functions These are available for everyone and anyone, to query publicly available info from a Data Union: @@ -460,13 +474,29 @@ const withdrawableWei = await dataUnion.getWithdrawableEarnings(memberAddress) The functions `withdrawAll`, `withdrawAllTo`, `withdrawAllToMember`, `withdrawAllToSigned` all can take an extra "options" argument. It's an object that can contain the following parameters: -| Name | Default | Description | -| :---------------- | :-------------------- | :---------------------------------------------------------------------------------- | -| sendToMainnet | true | Whether to send the withdrawn DATA tokens to mainnet address (or sidechain address) | -| pollingIntervalMs | 1000 (1 second) | How often requests are sent to find out if the withdraw has completed | -| retryTimeoutMs | 60000 (1 minute) | When to give up when waiting for the withdraw to complete | +| Name | Default | Description | +| :---------------- | :-------------------- | :---------------------------------------------------------------------------------- | +| sendToMainnet | true | Whether to send the withdrawn DATA tokens to mainnet address (or sidechain address) | +| payForTransport | true | Whether to pay for the withdraw transaction signature transport to mainnet over the bridge | +| waitUntilTransportIsComplete | true | Whether to wait until the withdrawn DATA tokens are visible in mainnet | +| pollingIntervalMs | 1000 (1 second) | How often requests are sent to find out if the withdraw has completed | +| retryTimeoutMs | 60000 (1 minute) | When to give up when waiting for the withdraw to complete | + +These withdraw transactions are sent to the sidechain, so gas price shouldn't be manually set (fees will hopefully stay very low), +but a little bit of [sidechain native token](https://www.xdaichain.com/for-users/get-xdai-tokens) is nonetheless required. + +The return values from the withdraw functions also depend on the options. + +If `sendToMainnet: false`, other options don't apply at all, and **sidechain transaction receipt** is returned as soon as the withdraw transaction is done. This should be fairly quick in the sidechain. + +The use cases corresponding to the different combinations of the boolean flags: -These withdraw transactions are sent to the sidechain, so gas price shouldn't be manually set (fees will hopefully stay very low), but a little bit of [sidechain native token](https://www.xdaichain.com/for-users/get-xdai-tokens) is nonetheless required. +| `transport` | `wait` | Returns | Effect | +| :---------- | :------ | :------ | :----- | +| `true` | `true` | Transaction receipt | *(default)* Self-service bridge to mainnet, client pays for mainnet gas | +| `true` | `false` | Transaction receipt | Self-service bridge to mainnet (but **skip** the wait that double-checks the withdraw succeeded and tokens arrived to destination) | +| `false` | `true` | `null` | Someone else pays for the mainnet gas automatically, e.g. the bridge operator (in this case the transaction receipt can't be returned) | +| `false` | `false` | AMB message hash | Someone else pays for the mainnet gas, but we need to give them the message hash first | ### Deployment options diff --git a/src/Config.ts b/src/Config.ts index 7ba4bdf5b..6d48b1430 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -65,7 +65,7 @@ export type StrictStreamrClientOptions = { * otherwise the client does the transport as self-service and pays the mainnet gas costs */ minimumWithdrawTokenWei: BigNumber|number|string - freeWithdraw: boolean + payForTransport: boolean factoryMainnetAddress: EthereumAddress factorySidechainAddress: EthereumAddress templateMainnetAddress: EthereumAddress @@ -137,7 +137,7 @@ export const STREAM_CLIENT_DEFAULTS: StrictStreamrClientOptions = { tokenSidechainAddress: '0xE4a2620edE1058D61BEe5F45F6414314fdf10548', dataUnion: { minimumWithdrawTokenWei: '1000000', - freeWithdraw: false, + payForTransport: true, factoryMainnetAddress: '0x7d55f9981d4E10A193314E001b96f72FCc901e40', factorySidechainAddress: '0x1b55587Beea0b5Bc96Bb2ADa56bD692870522e9f', templateMainnetAddress: '0x5FE790E3751dd775Cb92e9086Acd34a2adeB8C7b', diff --git a/src/dataunion/Contracts.ts b/src/dataunion/Contracts.ts index ebb97dc8c..9eed1352e 100644 --- a/src/dataunion/Contracts.ts +++ b/src/dataunion/Contracts.ts @@ -12,7 +12,7 @@ import { BigNumber } from '@ethersproject/bignumber' import StreamrEthereum from '../Ethereum' import { StreamrClient } from '../StreamrClient' -const log = debug('StreamrClient::DataUnion') +const log = debug('StreamrClient::Contracts') function validateAddress(name: string, address: EthereumAddress) { if (!isAddress(address)) { @@ -137,7 +137,7 @@ export class Contracts { } // move signatures from sidechain to mainnet - async transportSignaturesForMessage(messageHash: string) { + async transportSignaturesForMessage(messageHash: string): Promise { const sidechainAmb = await this.getSidechainAmb() const message = await sidechainAmb.message(messageHash) const messageId = '0x' + message.substr(2, 64) @@ -149,7 +149,7 @@ export class Contracts { const [vArray, rArray, sArray]: Todo = [[], [], []] signatures.forEach((signature: string, i) => { - log(` Signature ${i}: ${signature} (len=${signature.length}=${signature.length / 2 - 1} bytes)`) + 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)) @@ -171,7 +171,7 @@ export class Contracts { 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 freeWithdraw=false (attempt self-service), but bridge actually paid before your client') + log('This could happen if bridge paid for transport before your client.') return null } @@ -218,45 +218,6 @@ export class Contracts { return trAMB } - async transportSignaturesForTransaction(tr: ContractReceipt, 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) - - // zero address means no failed messages - if (alreadySent || failAddress !== '0x0000000000000000000000000000000000000000') { - log(`WARNING: Mainnet bridge has already processed withdraw messageId=${messageId}`) - log('This could happen if freeWithdraw=false (attempt self-service), but bridge actually paid before your client') - continue - } - - log(`Transporting signatures for hash=${messageHash}`) - await this.transportSignaturesForMessage(messageHash) - } - /* eslint-enable no-await-in-loop */ - } - async deployDataUnion({ ownerAddress, agentAddressList, diff --git a/src/dataunion/DataUnion.ts b/src/dataunion/DataUnion.ts index 7ebc8aa05..f256c1679 100644 --- a/src/dataunion/DataUnion.ts +++ b/src/dataunion/DataUnion.ts @@ -1,16 +1,19 @@ import { getAddress } from '@ethersproject/address' import { BigNumber } from '@ethersproject/bignumber' import { arrayify, hexZeroPad } from '@ethersproject/bytes' -import { Contract } from '@ethersproject/contracts' +import { Contract, ContractReceipt, ContractTransaction } from '@ethersproject/contracts' +import { keccak256 } from '@ethersproject/keccak256' import { Wallet } from '@ethersproject/wallet' -import { JsonRpcSigner, TransactionReceipt, TransactionResponse } from '@ethersproject/providers' +import { JsonRpcSigner } from '@ethersproject/providers' import debug from 'debug' -import { Contracts } from './Contracts' + import { StreamrClient } from '../StreamrClient' import { EthereumAddress } from '../types' import { until, getEndpointUrl } from '../utils' import authFetch from '../rest/authFetch' +import { Contracts } from './Contracts' + export interface DataUnionDeployOptions { owner?: EthereumAddress, joinPartAgents?: EthereumAddress[], @@ -36,7 +39,8 @@ export interface JoinResponse { export interface DataUnionWithdrawOptions { pollingIntervalMs?: number retryTimeoutMs?: number - freeWithdraw?: boolean + payForTransport?: boolean + waitUntilTransportIsComplete?: boolean sendToMainnet?: boolean } @@ -62,8 +66,17 @@ export interface MemberStats { withdrawableEarnings: BigNumber } +export type AmbMessageHash = string + const log = debug('StreamrClient::DataUnion') +function getMessageHashes(tr: ContractReceipt): AmbMessageHash[] { + // event UserRequestForSignature(bytes32 indexed messageId, bytes encodedData); + const sigEventArgsArray = tr.events!.filter((e) => e.event === 'UserRequestForSignature').map((e) => e.args) + const hashes = sigEventArgsArray.map((eventArgs) => keccak256(eventArgs![1])) + return hashes +} + /** * @category Important */ @@ -122,17 +135,20 @@ export class DataUnion { 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] + const ACTIVE = 1 // memberData[0] is enum ActiveStatus {None, Active, Inactive} return (state === ACTIVE) } /** * Withdraw all your earnings - * @returns receipt once withdraw is complete (tokens are seen in mainnet) + * @returns the sidechain withdraw transaction receipt IF called with sendToMainnet=false, + * ELSE the message hash IF called with payForTransport=false and waitUntilTransportIsComplete=false, + * ELSE the mainnet AMB signature execution transaction receipt IF we did the transport ourselves, + * ELSE null IF transport to mainnet was done by someone else (in which case the receipt is lost) */ - async withdrawAll(options?: DataUnionWithdrawOptions): Promise { + async withdrawAll(options?: DataUnionWithdrawOptions) { const recipientAddress = await this.client.getAddress() return this._executeWithdraw( () => this.getWithdrawAllTx(options?.sendToMainnet), @@ -145,7 +161,7 @@ export class DataUnion { * Get the tx promise for withdrawing all your earnings * @returns await on call .wait to actually send the tx */ - private async getWithdrawAllTx(sendToMainnet: boolean = true): Promise { + private async getWithdrawAllTx(sendToMainnet: boolean = true): Promise { const signer = await this.client.ethereum.getSidechainSigner() const address = await signer.getAddress() const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) @@ -164,12 +180,15 @@ export class DataUnion { /** * Withdraw earnings and "donate" them to the given address - * @returns get receipt once withdraw is complete (tokens are seen in mainnet) + * @returns the sidechain withdraw transaction receipt IF called with sendToMainnet=false, + * ELSE the message hash IF called with payForTransport=false and waitUntilTransportIsComplete=false, + * ELSE the mainnet AMB signature execution transaction receipt IF we did the transport ourselves, + * ELSE null IF transport to mainnet was done by someone else (in which case the receipt is lost) */ async withdrawAllTo( recipientAddress: EthereumAddress, options?: DataUnionWithdrawOptions - ): Promise { + ) { const to = getAddress(recipientAddress) // throws if bad address return this._executeWithdraw( () => this.getWithdrawAllToTx(to, options?.sendToMainnet), @@ -183,7 +202,7 @@ export class DataUnion { * @param recipientAddress - the address to receive the tokens * @returns await on call .wait to actually send the tx */ - private async getWithdrawAllToTx(recipientAddress: EthereumAddress, sendToMainnet: boolean = true): Promise { + private async getWithdrawAllToTx(recipientAddress: EthereumAddress, sendToMainnet: boolean = true): Promise { const signer = await this.client.ethereum.getSidechainSigner() const address = await signer.getAddress() const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) @@ -279,14 +298,16 @@ export class DataUnion { // 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 [memberData, total] = await Promise.all([ + duSidechain.memberData(address), + duSidechain.getEarnings(address).catch(() => BigNumber.from(0)), + ]) + const withdrawnEarnings = memberData[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], + status: STATUSES[memberData[0]], + earningsBeforeLastJoin: memberData[1], totalEarnings: total, withdrawableEarnings: withdrawable, } @@ -365,11 +386,11 @@ export class DataUnion { */ async addMembers( memberAddressList: EthereumAddress[], - ): 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) + // TODO ETH-93: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) return tx.wait() } @@ -378,11 +399,11 @@ export class DataUnion { */ async removeMembers( memberAddressList: EthereumAddress[], - ): 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) + // TODO ETH-93: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) return tx.wait() } @@ -390,12 +411,15 @@ export class DataUnion { * 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 + * @returns the sidechain withdraw transaction receipt IF called with sendToMainnet=false, + * ELSE the message hash IF called with payForTransport=false and waitUntilTransportIsComplete=false, + * ELSE the mainnet AMB signature execution transaction receipt IF we did the transport ourselves, + * ELSE null IF transport to mainnet was done by someone else (in which case the receipt is lost) */ async withdrawAllToMember( memberAddress: EthereumAddress, options?: DataUnionWithdrawOptions - ): Promise { + ) { const address = getAddress(memberAddress) // throws if bad address return this._executeWithdraw( () => this.getWithdrawAllToMemberTx(address, options?.sendToMainnet), @@ -409,7 +433,7 @@ export class DataUnion { * @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, sendToMainnet: boolean = true): Promise { + private async getWithdrawAllToMemberTx(memberAddress: EthereumAddress, sendToMainnet: boolean = true): Promise { const a = getAddress(memberAddress) // throws if bad address const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) return duSidechain.withdrawAll(a, sendToMainnet) @@ -420,14 +444,17 @@ export class DataUnion { * @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 + * @returns the sidechain withdraw transaction receipt IF called with sendToMainnet=false, + * ELSE the message hash IF called with payForTransport=false and waitUntilTransportIsComplete=false, + * ELSE the mainnet AMB signature execution transaction receipt IF we did the transport ourselves, + * ELSE null IF transport to mainnet was done by someone else (in which case the receipt is lost) */ 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( @@ -449,7 +476,7 @@ export class DataUnion { recipientAddress: EthereumAddress, signature: string, sendToMainnet: boolean = true, - ): Promise { + ) { const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) return duSidechain.withdrawAllToSigned(memberAddress, recipientAddress, sendToMainnet, signature) } @@ -457,7 +484,7 @@ export class DataUnion { /** * Admin: set admin fee (between 0.0 and 1.0) for the data union */ - async setAdminFee(newFeeFraction: number): Promise { + async setAdminFee(newFeeFraction: number) { if (newFeeFraction < 0 || newFeeFraction > 1) { throw new Error('newFeeFraction argument must be a number between 0...1, got: ' + newFeeFraction) } @@ -476,10 +503,11 @@ export class DataUnion { async transferToMemberInContract( memberAddress: EthereumAddress, amountTokenWei: BigNumber|number|string - ): Promise { + ): Promise { const address = getAddress(memberAddress) // throws if bad address const duSidechain = await this.getContracts().getSidechainContract(this.contractAddress) - return duSidechain.transferToMemberInContract(address, amountTokenWei) + const tx = await duSidechain.transferToMemberInContract(address, amountTokenWei) + return tx.wait() } /** @@ -489,7 +517,6 @@ export class DataUnion { * @internal */ static async _deploy(options: DataUnionDeployOptions = {}, client: StreamrClient): Promise { - const deployerAddress = await client.getAddress() const { owner, joinPartAgents, @@ -500,6 +527,7 @@ export class DataUnion { confirmations = 1, gasPrice } = options + const deployerAddress = await client.getAddress() let duName = dataUnionName if (!duName) { @@ -564,30 +592,113 @@ export class DataUnion { return new Contracts(this.client) } - // template for withdraw functions - // client could be replaced with AMB (mainnet and sidechain) + /** + * Template for withdraw functions + * @private + * @returns the sidechain withdraw transaction receipt IF called with sendToMainnet=false, + * ELSE the message hash IF called with payForTransport=false and waitUntilTransportIsComplete=false, + * ELSE the mainnet AMB signature execution transaction receipt IF we did the transport ourselves, + * ELSE null IF transport to mainnet was done by someone else (in which case the receipt is lost) + */ private async _executeWithdraw( - getWithdrawTxFunc: () => Promise, + getWithdrawTxFunc: () => Promise, recipientAddress: EthereumAddress, options: DataUnionWithdrawOptions = {} - ): Promise { + ): Promise { const { pollingIntervalMs = 1000, - retryTimeoutMs = 60000, - freeWithdraw = this.client.options.dataUnion.freeWithdraw, + retryTimeoutMs = 300000, + // by default, transport the signatures if payForTransport=false isn't supported by the sidechain + payForTransport = this.client.options.dataUnion.payForTransport, + waitUntilTransportIsComplete = true, sendToMainnet = true, } = options + const getBalanceFunc = sendToMainnet ? () => this.client.getTokenBalance(recipientAddress) : () => this.client.getSidechainTokenBalance(recipientAddress) - const balanceBefore = await getBalanceFunc() + const balanceBefore = waitUntilTransportIsComplete ? await getBalanceFunc() : 0 + + log('Executing DataUnionSidechain withdraw function') const tx = await getWithdrawTxFunc() const tr = await tx.wait() - if (!freeWithdraw && sendToMainnet) { - await this.getContracts().transportSignaturesForTransaction(tr, options) + + // keep tokens in the sidechain => just return the sidechain tx receipt + if (!sendToMainnet) { return tr } + + log(`Got receipt, filtering UserRequestForSignature from ${tr.events!.length} events...`) + const ambHashes = getMessageHashes(tr) + + if (ambHashes.length < 1) { + throw new Error("No UserRequestForSignature events emitted from withdraw transaction, can't transport withdraw to mainnet") + } + + if (ambHashes.length > 1) { + throw new Error('Expected only one UserRequestForSignature event') + } + + const messageHash = ambHashes[0] + + // expect someone else to do the transport for us + if (!payForTransport) { + if (waitUntilTransportIsComplete) { + log(`Waiting for balance=${balanceBefore.toString()} change (poll every ${pollingIntervalMs}ms, timeout after ${retryTimeoutMs}ms)`) + await until(async () => !(await getBalanceFunc()).eq(balanceBefore), retryTimeoutMs, pollingIntervalMs).catch((e) => { + const msg = `Timeout: Bridge did not transport withdraw message as expected. Fix: DataUnion.transportMessage("${messageHash}")` + throw e.message.startsWith('Timeout') ? new Error(msg) : e + }) + return null + } + + // instead of waiting, hand out the messageHash so that we can pass it on to that who does the transportMessage(messageHash) + return messageHash } - log(`Waiting for balance ${balanceBefore.toString()} to change`) - await until(async () => !(await getBalanceFunc()).eq(balanceBefore), retryTimeoutMs, pollingIntervalMs) - return tr + + const ambTr = await this.transportMessage(messageHash, pollingIntervalMs, retryTimeoutMs) + if (waitUntilTransportIsComplete) { + log(`Waiting for balance ${balanceBefore.toString()} to change`) + await until(async () => !(await getBalanceFunc()).eq(balanceBefore), retryTimeoutMs, pollingIntervalMs) + } + return ambTr + } + + // TODO: this doesn't belong here. Transporting a message is NOT dataunion-specific and needs nothing from DataUnion.ts. + // It shouldn't be required to create a DataUnion object to call this. + // This belongs to the StreamrClient, and if the code is too DU-specific then please shove it back to Contracts.ts. + // Division to transportMessage and Contracts.transportSignaturesForMessage is spurious, they should be one long function probably. + /** + * @returns null if message was already transported, ELSE the mainnet AMB signature execution transaction receipt + */ + async transportMessage(messageHash: AmbMessageHash, pollingIntervalMs: number = 1000, retryTimeoutMs: number = 300000) { + const helper = this.getContracts() + const [sidechainAmb, mainnetAmb] = await Promise.all([ + helper.getSidechainAmb(), + helper.getMainnetAmb(), + ]) + + log(`Waiting until sidechain AMB has collected required signatures for hash=${messageHash}...`) + await until(async () => helper.requiredSignaturesHaveBeenCollected(messageHash), pollingIntervalMs, retryTimeoutMs) + + const message = await sidechainAmb.message(messageHash) + if (message === '0x') { + throw new Error(`Message with hash=${messageHash} not found`) + } + const messageId = '0x' + message.substr(2, 64) + + log(`Checking mainnet AMB hasn't already processed messageId=${messageId}`) + const [alreadySent, failAddress] = await Promise.all([ + mainnetAmb.messageCallStatus(messageId), + mainnetAmb.failedMessageSender(messageId), + ]) + + // zero address means no failed messages + if (alreadySent || failAddress !== '0x0000000000000000000000000000000000000000') { + log(`WARNING: Tried to transport signatures but they have already been transported (Message ${messageId} has already been processed)`) + log('This could happen if bridge paid for transport before your client.') + return null + } + + log(`Transporting signatures for hash=${messageHash}`) + return helper.transportSignaturesForMessage(messageHash) } } diff --git a/src/index.ts b/src/index.ts index 1185e557c..82cd3e739 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ export { BigNumber } from '@ethersproject/bignumber' export { ConnectionInfo } from '@ethersproject/web' export { Contract } from '@ethersproject/contracts' export { BytesLike, Bytes } from '@ethersproject/bytes' -export { TransactionReceipt, TransactionResponse } from '@ethersproject/providers' +export { ContractReceipt, ContractTransaction } from '@ethersproject/contracts' export default StreamrClient diff --git a/test/integration/dataunion/transfer.test.ts b/test/integration/dataunion/transfer.test.ts index 726c2b14f..6d8878e13 100644 --- a/test/integration/dataunion/transfer.test.ts +++ b/test/integration/dataunion/transfer.test.ts @@ -122,8 +122,7 @@ describe('DataUnion transfer within contract', () => { await approve.wait() log(`Approve DU ${dataUnion.getSidechainAddress()} to access 1 token from ${adminWalletSidechain.address}`) - const tx = await dataUnion.transferToMemberInContract(memberWallet.address, parseEther('1')) - await tx.wait() + await dataUnion.transferToMemberInContract(memberWallet.address, parseEther('1')) log(`Transfer 1 token with transferWithinContract to ${memberWallet.address}`) const newStats = await memberClient.getDataUnion(dataUnion.getAddress()).getMemberStats(memberWallet.address) diff --git a/test/integration/dataunion/withdraw.test.ts b/test/integration/dataunion/withdraw.test.ts index 672b3fd55..972f48c86 100644 --- a/test/integration/dataunion/withdraw.test.ts +++ b/test/integration/dataunion/withdraw.test.ts @@ -1,6 +1,8 @@ import { BigNumber, Contract, providers, Wallet } from 'ethers' -import { formatEther, parseEther } from 'ethers/lib/utils' -import { TransactionReceipt } from '@ethersproject/providers' +import { formatEther, parseEther, defaultAbiCoder } from 'ethers/lib/utils' +import { ContractReceipt } from '@ethersproject/contracts' +import { keccak256 } from '@ethersproject/keccak256' + import debug from 'debug' import { getEndpointUrl, until } from '../../../src/utils' @@ -10,7 +12,8 @@ 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' +import { AmbMessageHash, DataUnionWithdrawOptions, MemberStatus } from '../../../src/dataunion/DataUnion' +import { EthereumAddress } from '../../../src' const log = debug('StreamrClient::DataUnion::integration-test-withdraw') @@ -24,42 +27,47 @@ const tokenMainnet = new Contract(config.clientOptions.tokenAddress, Token.abi, const tokenSidechain = new Contract(config.clientOptions.tokenSidechainAddress, Token.abi, adminWalletSidechain) -const testWithdraw = async ( - getBalance: (memberWallet: Wallet) => Promise, +let testWalletId = 1000000 // ensure fixed length as string + +async function testWithdraw( withdraw: ( - dataUnionAddress: string, + dataUnionAddress: EthereumAddress, memberClient: StreamrClient, memberWallet: Wallet, adminClient: StreamrClient - ) => Promise, + ) => Promise, + recipientAddress: EthereumAddress | null, // null means memberWallet.address requiresMainnetETH: boolean, -) => { - log(`Connecting to Ethereum networks, config = ${JSON.stringify(config)}`) + options: DataUnionWithdrawOptions, +) { + log('Connecting to Ethereum networks, config = %o', config) const network = await providerMainnet.getNetwork() - log('Connected to "mainnet" network: ', JSON.stringify(network)) + log('Connected to "mainnet" network: %o', network) const network2 = await providerSidechain.getNetwork() - log('Connected to sidechain network: ', JSON.stringify(network2)) + log('Connected to sidechain network: %o', network2) - log(`Minting 100 tokens to ${adminWalletMainnet.address}`) + log('Minting 100 tokens to %s', adminWalletMainnet.address) const tx1 = await tokenMainnet.mint(adminWalletMainnet.address, parseEther('100')) await tx1.wait() - const adminClient = new StreamrClient(config.clientOptions as any) + const adminClient = new StreamrClient(config.clientOptions) const dataUnion = await adminClient.deployDataUnion() const secret = await dataUnion.createSecret('test secret') - log(`DataUnion ${dataUnion.getAddress()} is ready to roll`) + log('DataUnion %s is ready to roll', dataUnion.getAddress()) // dataUnion = await adminClient.getDataUnionContract({dataUnion: "0xd778CfA9BB1d5F36E42526B2BAFD07B74b4066c0"}) - const memberWallet = new Wallet(`0x100000000000000000000000000000000000000012300000001${Date.now()}`, providerSidechain) + testWalletId += 1 + const memberWallet = new Wallet(`0x100000000000000000000000000000000000000012300000000000001${testWalletId}`, providerSidechain) + const recipient = recipientAddress || memberWallet.address const sendTx = await adminWalletSidechain.sendTransaction({ to: memberWallet.address, value: parseEther('0.1') }) await sendTx.wait() - log(`Sent 0.1 sidechain-ETH to ${memberWallet.address}`) + log('Sent 0.1 sidechain-ETH to %s', memberWallet.address) if (requiresMainnetETH) { const send2Tx = await adminWalletMainnet.sendTransaction({ to: memberWallet.address, value: parseEther('0.1') }) await send2Tx.wait() - log(`Sent 0.1 mainnet-ETH to ${memberWallet.address}`) + log('Sent 0.1 mainnet-ETH to %s', memberWallet.address) } const memberClient = new StreamrClient({ @@ -67,7 +75,7 @@ const testWithdraw = async ( auth: { privateKey: memberWallet.privateKey } - } as any) + }) // product is needed for join requests to analyze the DU version const createProductUrl = getEndpointUrl(config.clientOptions.restUrl, 'products') @@ -81,63 +89,88 @@ const testWithdraw = async ( }) const res = await memberClient.getDataUnion(dataUnion.getAddress()).join(secret) // await adminClient.addMembers([memberWallet.address], { dataUnion }) - log(`Member joined data union: ${JSON.stringify(res)}`) + log('Member joined data union %o', res) // eslint-disable-next-line no-underscore-dangle const contract = await dataUnion._getContract() const tokenAddress = await contract.token() - log(`Token address: ${tokenAddress}`) + log('Token address: %s', tokenAddress) const adminTokenMainnet = new Contract(tokenAddress, Token.abi, adminWalletMainnet) + async function logBalance(owner: string, address: EthereumAddress) { + const balance = await adminTokenMainnet.balanceOf(address) + log('%s (%s) mainnet token balance: %s (%s)', owner, address, formatEther(balance), balance.toString()) + } const amount = parseEther('1') const duSidechainEarningsBefore = await contract.sidechain.totalEarnings() - const duBalance1 = await adminTokenMainnet.balanceOf(dataUnion.getAddress()) - log(`Token balance of ${dataUnion.getAddress()}: ${formatEther(duBalance1)} (${duBalance1.toString()})`) - const balance1 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) - log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance1)} (${balance1.toString()})`) + await logBalance('Data union', dataUnion.getAddress()) + await logBalance('Admin', adminWalletMainnet.address) - log(`Transferring ${amount} token-wei ${adminWalletMainnet.address}->${dataUnion.getAddress()}`) + log('Transferring %s token-wei %s->%s', amount, adminWalletMainnet.address, dataUnion.getAddress()) const txTokenToDU = await adminTokenMainnet.transfer(dataUnion.getAddress(), amount) await txTokenToDU.wait() - const duBalance2 = await adminTokenMainnet.balanceOf(dataUnion.getAddress()) - log(`Token balance of ${dataUnion.getAddress()}: ${formatEther(duBalance2)} (${duBalance2.toString()})`) - const balance2 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) - log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance2)} (${balance2.toString()})`) + await logBalance('Data union', dataUnion.getAddress()) + await logBalance('Admin', adminWalletMainnet.address) - log(`DU member count: ${await contract.sidechain.activeMemberCount()}`) + log('DU member count: %d', await contract.sidechain.activeMemberCount()) - log(`Transferred ${formatEther(amount)} tokens, next sending to bridge`) + log('Transferred %s tokens, next sending to bridge', formatEther(amount)) const tx2 = await contract.sendTokensToBridge() - await tx2.wait() + const tr2 = await tx2.wait() + log('sendTokensToBridge returned %o', tr2) - log(`Sent to bridge, waiting for the tokens to appear at ${contract.sidechain.address} in sidechain`) + log('Waiting for the tokens to appear at sidechain %s', contract.sidechain.address) await until(async () => !(await tokenSidechain.balanceOf(contract.sidechain.address)).eq('0'), 300000, 3000) - log(`Confirmed tokens arrived, DU balance: ${duSidechainEarningsBefore} -> ${await contract.sidechain.totalEarnings()}`) + log('Confirmed tokens arrived, DU balance: %s -> %s', duSidechainEarningsBefore, await contract.sidechain.totalEarnings()) // make a "full" sidechain contract object that has all functions, not just those required by StreamrClient const sidechainContract = new Contract(contract.sidechain.address, DataUnionSidechain.abi, adminWalletSidechain) const tx3 = await sidechainContract.refreshRevenue() const tr3 = await tx3.wait() - log(`refreshRevenue returned ${JSON.stringify(tr3)}`) - log(`DU balance: ${await contract.sidechain.totalEarnings()}`) + log('refreshRevenue returned %o', tr3) + log('DU sidechain totalEarnings: %o', await contract.sidechain.totalEarnings()) - const duBalance3 = await adminTokenMainnet.balanceOf(dataUnion.getAddress()) - log(`Token balance of ${dataUnion.getAddress()}: ${formatEther(duBalance3)} (${duBalance3.toString()})`) - const balance3 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) - log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance3)} (${balance3.toString()})`) + await logBalance('Data union', dataUnion.getAddress()) + await logBalance('Admin', adminWalletMainnet.address) const stats = await memberClient.getDataUnion(dataUnion.getAddress()).getMemberStats(memberWallet.address) - log(`Stats: ${JSON.stringify(stats)}`) + log('Stats: %o', stats) + + const getRecipientBalance = async () => ( + options.sendToMainnet + ? memberClient.getTokenBalance(recipient) + : memberClient.getSidechainTokenBalance(recipient) + ) + + const balanceBefore = await getRecipientBalance() + log('Balance before: %s. Withdrawing tokens...', balanceBefore) + + // "bridge-sponsored mainnet withdraw" case + if (!options.payForTransport && options.waitUntilTransportIsComplete) { + log('Adding %s to bridge-sponsored withdraw whitelist', recipient) + bridgeWhitelist.push(recipient) + } // test setup done, do the withdraw - const balanceBefore = await getBalance(memberWallet) - log(`Balance before: ${balanceBefore}. Withdrawing tokens...`) + let ret = await withdraw(dataUnion.getAddress(), memberClient, memberWallet, adminClient) + + // "other-sponsored mainnet withdraw" case + if (typeof ret === 'string') { + log('Transporting message "%s"', ret) + ret = await dataUnion.transportMessage(String(ret)) + } + log('Tokens withdrawn, return value: %o', ret) - const withdrawTr = await withdraw(dataUnion.getAddress(), memberClient, memberWallet, adminClient) - log(`Tokens withdrawn, sidechain tx receipt: ${JSON.stringify(withdrawTr)}`) - const balanceAfter = await getBalance(memberWallet) + // "skip waiting" or "without checking the recipient account" case + // we need to wait nevertheless, to be able to assert that balance in fact changed + if (!options.waitUntilTransportIsComplete) { + log('Waiting until balance changes from %s', balanceBefore) + await until(async () => getRecipientBalance().then((b) => !b.eq(balanceBefore))) + } + + const balanceAfter = await getRecipientBalance() const balanceIncrease = balanceAfter.sub(balanceBefore) expect(stats).toMatchObject({ @@ -149,74 +182,85 @@ const testWithdraw = async ( expect(balanceIncrease.toString()).toBe(amount.toString()) } -describe('DataUnion withdraw', () => { - - const balanceClient = createClient() +log('Starting the simulated bridge-sponsored signature transport process') +// event UserRequestForSignature(bytes32 indexed messageId, bytes encodedData) +const signatureRequestEventSignature = '0x520d2afde79cbd5db58755ac9480f81bc658e5c517fcae7365a3d832590b0183' +const sidechainAmbAddress = '0xaFA0dc5Ad21796C9106a36D68f69aAD69994BB64' +const bridgeWhitelist: EthereumAddress[] = [] +providerSidechain.on({ + address: sidechainAmbAddress, + topics: [signatureRequestEventSignature] +}, async (event) => { + log('Observed signature request for message (id=%s)', event.topics[1]) // messageId is indexed so it's in topics... + const message = defaultAbiCoder.decode(['bytes'], event.data)[0] // ...only encodedData is in data + const recipient = '0x' + message.slice(200, 240) + if (!bridgeWhitelist.find((address) => address.toLowerCase() === recipient)) { + log('Recipient %s not whitelisted, ignoring', recipient) + return + } + const hash = keccak256(message) + const adminClient = new StreamrClient(config.clientOptions) + await adminClient.getDataUnion('0x0000000000000000000000000000000000000000').transportMessage(hash, 100, 120000) + log('Transported message (hash=%s)', hash) +}) +describe('DataUnion withdraw', () => { afterAll(() => { providerMainnet.removeAllListeners() providerSidechain.removeAllListeners() }) - for (const sendToMainnet of [true, false]) { + describe.each([ + [false, true, true], // sidechain withdraw + [true, true, true], // self-service mainnet withdraw + [true, true, false], // self-service mainnet withdraw without checking the recipient account + [true, false, true], // bridge-sponsored mainnet withdraw + [true, false, false], // other-sponsored mainnet withdraw + ])('Withdrawing with sendToMainnet=%p, payForTransport=%p, wait=%p', (sendToMainnet, payForTransport, waitUntilTransportIsComplete) => { - const getTokenBalance = async (wallet: Wallet) => { - return sendToMainnet ? balanceClient.getTokenBalance(wallet.address) : balanceClient.getSidechainTokenBalance(wallet.address) - } + // for test debugging: select only one case by uncommenting below, and comment out the above .each block + // const [sendToMainnet, payForTransport, waitUntilTransportIsComplete] = [true, false, true] // bridge-sponsored mainnet withdraw + + const options = { sendToMainnet, payForTransport, waitUntilTransportIsComplete } + + describe('by member', () => { + + it('to itself', () => { + return testWithdraw(async (dataUnionAddress, memberClient) => ( + memberClient.getDataUnion(dataUnionAddress).withdrawAll(options) + ), null, true, options) + }, 3600000) + + it('to any address', () => { + testWalletId += 1 + const outsiderWallet = new Wallet(`0x100000000000000000000000000000000000000012300000002${testWalletId}`, providerSidechain) + return testWithdraw(async (dataUnionAddress, memberClient) => ( + memberClient.getDataUnion(dataUnionAddress).withdrawAllTo(outsiderWallet.address, options) + ), outsiderWallet.address, true, options) + }, 3600000) - describe('Withdrawing to ' + (sendToMainnet ? 'mainnet' : 'sidechain'), () => { - - describe('Member', () => { - - it('by member itself', () => { - const getBalance = async (memberWallet: Wallet) => getTokenBalance(memberWallet) - const withdraw = async (dataUnionAddress: string, memberClient: StreamrClient) => ( - memberClient.getDataUnion(dataUnionAddress).withdrawAll({ sendToMainnet }) - ) - return testWithdraw(getBalance, withdraw, true) - }, 300000) - - it('from member to any address', () => { - const outsiderWallet = new Wallet(`0x100000000000000000000000000000000000000012300000002${Date.now()}`, providerSidechain) - const getBalance = async () => getTokenBalance(outsiderWallet) - const withdraw = (dataUnionAddress: string, memberClient: StreamrClient) => ( - memberClient.getDataUnion(dataUnionAddress).withdrawAllTo(outsiderWallet.address, { sendToMainnet }) - ) - return testWithdraw(getBalance, withdraw, true) - }, 300000) - - }) - - describe('Admin', () => { - - it('non-signed', async () => { - const getBalance = async (memberWallet: Wallet) => getTokenBalance(memberWallet) - const withdraw = (dataUnionAddress: string, _: StreamrClient, memberWallet: Wallet, adminClient: StreamrClient) => ( - adminClient.getDataUnion(dataUnionAddress).withdrawAllToMember(memberWallet.address, { sendToMainnet }) - ) - return testWithdraw(getBalance, withdraw, false) - }, 300000) - - it('signed', async () => { - const member2Wallet = new Wallet(`0x100000000000000000000000000040000000000012300000007${Date.now()}`, providerSidechain) - const getBalance = async () => getTokenBalance(member2Wallet) - const withdraw = async ( - dataUnionAddress: string, - memberClient: StreamrClient, - memberWallet: Wallet, - adminClient: StreamrClient - ) => { - const signature = await memberClient.getDataUnion(dataUnionAddress).signWithdrawAllTo(member2Wallet.address) - const withdrawTr = await adminClient - .getDataUnion(dataUnionAddress) - .withdrawAllToSigned(memberWallet.address, member2Wallet.address, signature, { sendToMainnet }) - return withdrawTr - } - return testWithdraw(getBalance, withdraw, false) - }, 300000) - }) }) - } + + describe('by admin', () => { + + it('to member without signature', async () => { + return testWithdraw(async (dataUnionAddress, memberClient, memberWallet, adminClient) => ( + adminClient.getDataUnion(dataUnionAddress).withdrawAllToMember(memberWallet.address, options) + ), null, false, options) + }, 3600000) + + it("to anyone with member's signature", async () => { + testWalletId += 1 + const member2Wallet = new Wallet(`0x100000000000000000000000000040000000000012300000007${testWalletId}`, providerSidechain) + return testWithdraw(async (dataUnionAddress, memberClient, memberWallet, adminClient) => { + const signature = await memberClient.getDataUnion(dataUnionAddress).signWithdrawAllTo(member2Wallet.address) + return adminClient + .getDataUnion(dataUnionAddress) + .withdrawAllToSigned(memberWallet.address, member2Wallet.address, signature, options) + }, member2Wallet.address, false, options) + }, 3600000) + }) + }) it('Validate address', async () => { const client = createClient(providerSidechain)