(Fully updated 10/24/2018)
Use the scripts in the package.json to ensure the proper test environment:
# Node and npm versions:
# node >= v10
# npm >= v6
# install the dev dependencies
# run ganache in the background (or separate shell) ensuring the proper mnemonic
# run the primary test suite using truffle network=ganache settings
$ npm i
$ npm run ganache &
$ npm test
Canonical links: https://paper.dropbox.com/doc/SpankPay-BOOTY-Drop-2-CANONICAL-URLs--AP7jZj1zm4J7XSVcw0Ifk_fBAg-Qpw2NAWgCIdg0Z5G9lpSu
Hub/Wallet API spec:
Contract: https://github.com/ConnextProject/contracts/blob/master/contracts/ChannelManager.sol
Flowcharts: https://github.com/ConnextProject/contracts/tree/master/docs/diagrams
The ChannelManager.sol contract manages bidirectional ETH/ERC20 channels between a single payment channel hub and its users. It also allows users who have channels with the hub to open P2P unidirectional ETH/ERC20 subchannels that we call threads to pay each other directly, without the hub ever having custody of the transferred funds. The ChannelManager can also be used to secure ETH/ERC20 exchange.
The contract is designed to secure offchain updates - that is, it offers the hub and users the ability to, at any time, decide to exit their channels and withdraw all their funds. At minimum, the contract must be able to handle these unilaterally initiated exits.
To increase security, ChannelManager.sol can only support one ERC20 token. The address of the ERC20 token is set at contract construction time and cannot be modified later. This prevents malicious ERC20 smart contracts from exploiting the Channel Manager, and drastically simplifies its implementation.
Instead of storing channels onchain by a random ID generated at the time the channel is opened, we have moved to storing channels onchain by the user's address. This means that users can only ever have one channel open on this contract. This has several implications:
- Users no longer need to open channels, because channels are assumed to be open for all users as soon as the contract is deployed.
- When users want to fully withdraw their balances from the contract, the
txCount
(nonce) of the channel will be saved onchain, even as the balances are zeroed out. - When users want to deposit additional funds into the contract after they have fully withdrawn, they will need to increment the
txCount
that was previously saved onchain, picking up from where they left off.
There are many cases, however, when the hub or a user may want to deposit into, withdraw from, checkpoint, or close a channel where the counterparty provides their consent in advance. We realized that all of these cases could be combined into two contract functions:
hubAuthorizedUpdate
userAuthorizedUpdate
These functions can be used by either party to update the onchain channel state to reflect the latest mutually signed (authorized) state update, as well as execute any authorized deposits or withdrawals.
Updates to channel balances (ie, deposits and withdrawals) are performed via a two-phase commit.
In the first phase, parties sign an offchain update adding the amount to be deposited and/or withdrawn to the pending
state fields:
pendingWeiUpdates: [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingTokenUpdates: [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
The "pending" portion of the txCount
(which is now a uint256[2]
, explained below) is incremented and, for a withdrawal, the parties would remove the net (withdraw - deposit) pending value to be taken out of the channel. This ensures that the offchain balance always tracks the amount of value in the channel that could be transacted without risking a double spend. For more information, see: https://github.com/ConnextProject/contracts/blob/master/docs/aggregateUpdates.md
In the second phase, this signed state is broadcast onchain (via the hubAuthorizedUpdate
or userAuthorizedUpdate
smart contract methods), and the pending transactions are executed (ie, ETH and tokens are transferred). Note that this allows a single onchain transaction to perform deposits, withdrawals, and transfers, facilitating single-transaction ETH/ERC20 swaps.
Finally, when one party or the other notices the onchain transaction, they propose an offchain update removing the pending
fields, and transferring any pending deposits into the useable balances:
weiBalances
tokenBalances
The counterparty will validate this state update by checking that a DidUpdateChannel
event has been emitted where the following fields match the most recent state:
pendingWeiUpdates
pendingTokenUpdates
txCount[1] // the pending tx count
(in practice, the Hub will include the transaction hash of the transaction which contains this event to make it easier for the client to find)
TODO: define what happens if the client rejects. Proposal: return an error along with an invalidating state N + 1.
(in practice, for the first version, only the hub will be watching the blockchain for transactions)
Note: pending
values cannot be added or updated if the current state already has pending
values. For example, if the current state includes a pending withdrawal, subsequent states may not modify the pending withdrawal (except to remove it), and they also may not add a pending deposit. They may, however, modify the balances
(this allows offchain transactions to continue as normal while a deposit or withdrawal is pending).
For example:
State 1: initial state:
tokenBalances: [10, 20] // [hub, user]
txCount: [1, 1] // [global, pending]
State 2: after adding a pending balance:
tokenBalances: [10, 20]
//[hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingWeiUpdates: [11, 0, 22, 0]
txCount: [2, 2]
State 2.5: an offchain transaction takes place (this is an example):
tokenBalances: [5, 25]
pendingWeiUpdates: [11, 0, 22, 0]
txCount: [3, 2]
State 3: after removing the pending balances:
tokenBalances: [16, 47]
txCount: [4, 2]
For more, see:
- The deposit and withdrawal examples, below.
- The implementation of
hubAuthorizedUpdate
anduserAuthorizedUpdate
for an example of how pending states are executed.
Nonces have been replaced with a txCount
tuple. txCount[0]
represents the global nonce, and txCount[1]
represents the pending updates nonce. Whenever a state update is applied offchain, txCount[0]
is incremented. Whenever a state containing a pending update is generated, txCount[1]
is incremented. In normal channel operation, txCount[1]
will only be incremented on deposits and withdrawals. The goal of tracking offchain and pending updates separately is to facilitate the two-phase commit described above, and allow a withdrawal from a channel without completely zeroing it out. For example, a previously-disputed channel may be re-used as long as txCount[1]
continually increases from the point of dispute.
There are two kinds of timeouts to be considered: onchain timeouts and offchain timeouts.
Onchain Timeouts
Any state update including a timeout
must be submitted to chain before the timeout expires, otherwise they are considered invalid and will be rejected.
Onchain timeouts are used for two pending operations:
-
User deposits.
A timeout is included with user deposits to simplify situations where the user's transaction could never succeed (ex, the deposit is for 1 ETH, but the user only has 0.5 ETH in their wallet), or situations where a transaction gets stuck in the mempool.
Consider, for example, a situation where a user deposit is submitted onchain, but it gets stuck in the mempool. It would be possible to invent a protocol wherein the user asks the hub to sign a new state removing the pending deposit. However, if the first onchain deposit eventually succeeds, the hub and user will need to reconcile this new balance, which could be especially difficult if a subsequent deposit has been submitted.
Because a timeout is included, however, no edge cases need to be considered: either the onchain transaction is confirmed within the timeout, or it is discarded. After the timeout has expired, it can be invalidated
Please see this User Deposits Flowchart for more information.
-
Onchain exchanges.
A timeout is included any time a Token <> ETH exchange is made to protect both parties against market fluctuations. If an onchain transaction includes an exchange (for example, a user withdrawal), a
timeout
will be included.
Note: when there's a state with a timeout
, no offchain updates can be made until it has been resolved (because those updates could be rendered invalid if the state with the timeout
does not get successfully submitted to chain).
Offchain Timeouts
Because Token <> ETH exchanges can happen offchain, they also require a timeout to protect against market fluctuations. Unfortunately there is no straightforward timeout mechanism which can protect both parties in offchain transactions, so we have opted protect the hub. Note, however, this is not done with an explicit timeout; see below.
Please see this Offchain Exchange Flowchart for more information.
When performing offchain Token <> ETH swaps:
- The user calls an API endpoint on the hub to request an exchange with a certain amount of ETH/Token.
- The hub returns an unsigned state update at
txCount = N
to the user, with a reasonable exchange rate. - The user verifies the exchange rate, signs the state update and returns it to the hub. The user also starts an internal timeout. Note that this timeout is not included in the state signature.
- The hub checks that the exchange rate in the signed state update is still valid (i.e. that the current market rate is still reasonably close to the exchange rate), then countersigns the update and returns it to the user.
- Note here that the hub has a half-signed update, which it could use to maliciously exchange with the user at a more favorable rate. To protect against this, the hub generates a state update at
txCount = N + 1
which reverts the exchange, signs it and returns it to the user if the original exchange update becomes invalid due to the price check in step 3.- By doing this, the user has the ability to negate the outcome where the hub maliciously holds on to the exchange state and signs it at a favorable rate. We expect that simply the threat of negation here is enough for both parties to operate cooperatively.
- The user waits until the end of the timeout for the hub to respond with either the double signed original exchange state, or the hubs single signed exchange negation update. If the user does not receive a response by the end of the timeout, they assume that the hub is malicious and dispute the channel with their last known state before the exchange.
Note: this is still vulnerable, because the user's last known state could be "trumped" by the exchange state that the hub still holds in a dispute. The benefit of starting the exit process after a timer is that it places an upper bound time limit on the potential downside to the user (due to price swings in the hub's favor).
The hub collateralizes channels via a 'reserve balance' that exists within the Channel Manager contract. The purpose of the reserve balance is to reduce the number of onchain transactions required to collateralize user channels. Previously, recollateralization blocked usage of a particular channel until the hub deposited funds via a smart contract call. Now, recollateralization can happen as part of any other channel balance update since the act of depositing funds is decoupled from the act of collateralization.
Unlike the previous smart contract, ChannelManager.sol only supports a single round of disputes - that is, after a dispute is initiated then the other party has only one opportunity to present a challenge rather than each challenge resetting the challenge timer. This dramatically simplifies the dispute process. Notably, however, msg.sender
is checked in each dispute method to ensure that only the non-disputing party can enter a challenge. This temporarily prevents the use of watchtowers. Future iterations of the contract will modify this behavior to allow watchtowers.
User Deposit
Note: the flow is the same regardless of whether or not there is a balance in the channel.
- User decides how much they want to deposit
- User requests the hub to send a state update with the deposit amount included as a pending deposit.
- The Hub may also chose to include ETH or tokens as part of the deposit, which could later be exchanged offchain. For example, if the user is depositing 1 ETH, the hub may chose to deposit 69 BOOTY.
// [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingWeiUpdates: [0, 0, 1 eth, 0]
pendingTokenUpdates: [69 booty, 0, 0, 0]
weiBalances: [0, 0]
tokenBalances: [0, 0]
txCount: [1, 1]
timeout: currentBlockTime + 5 minutes
Note that a timeout is included in all user deposits - regardless of whether or not the hub is making a deposit - to ensure that the channel isn't left in limbo if the onchain transaction can't succeed. For more details, see the "Time-Sensitive Operations and Timeouts" heading.
- The user counter-signs the state update from the hub, then publishes to chain, along with the requisite payment (ie, the value of
pendingWeiUpdates[3]
. - Once the onchain transaction has been confirmed, either party may propose a state update moving the pending deposits into balances.
Note: offchain updates may take place between the time the update is published to chain and the time onchain confirmation is received.
For example, if the state published to the chain was:
// State published to chain:
pendingWeiUpdates: [0, 0, 1 eth, 0]
pendingTokenUpdates: [69 booty, 0, 0, 0]
weiBalances: [0, 0]
tokenBalances: [0, 0]
txCount: [1, 1]
timeout: currentBlockTime + 5 minutes
Then, after the transaction has been confirmed, an offchain update would be proposed that moves the deposits to the balances:
// Offchain update
weiBalances: [0, 1 eth]
tokenBalances: [69 booty, 0]
txCount: [2, 1]
- The counterparty will validate that the transaction has been confirmed onchain, then countersign and return the update.
In practice, only the hub will be watching for the onchain transaction (step 4), although at some point this functionality may also be built into the wallet.
Please see this User Deposits Flowchart for more information.
Hub Deposit
The Hub would deposit into a channel when either a user or performer channel needs to perform an in-channel swap, or when a performer channel is undercollateralized. Note that the Hub operator does not need to transfer value to the contract, as the contract would be "preloaded" with collateral (see 3.5 above). For either case, the hub deposit flow would be the following:
-
User or performer wallet initiates a request that the hub deposit. For a swap, this can be a conscious choice on the wallet that is bundled with the in-channel swap UX. For an undercollateralized performer channel, the deposit request should automatically be triggered by a low channel balance on the Hub side.
-
For example, if the performer channel contains 200 BOOTY total, but only 50 BOOTY is still held by the hub (the rest has been used), the performer may need another 100 BOOTY to ensure that payments from viewers remain uninterrupted. The hub would generate a state with the following:
// [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal] pendingWeiUpdates: [0, 0, 0, 0] pendingTokenUpdates: [100 booty, 0, 0, 0] weiBalances: [0, 0] tokenBalances: [50, 150] txCount: [currentOffchainCount, onChainCount++] //note: timeout not needed for hub functions
- Note: For the purpose of simplicity, other fields such as
threadCount
are not shown but must be included as a part of this state.
- Note: For the purpose of simplicity, other fields such as
-
-
User or performer wallet receives the state, verifies/signs it and returns it to the hub.
- Reconstructing the payload from the performers knowledge of state and recovering signer on the signature yields the hub's key
-
The Hub receives the state and verifies the following:
- Reconstructing the payload from the same data that the hub provided to performer yields the performer's key
-
If the state is valid, the Hub submits it to
hubAuthorizedUpdate
onchain and waits for the transaction to complete. -
Upon completion, the Hub signs a new state update acknowledging the deposit (i.e. moving the deposit from
pendingTokenUpdates
intotokenBalances
as per section 4.1 above and sends it to the counterparty. -
The user or performer validates that the onchain transaction was completed, countersigns, and updates local storage to base further state updates off this state.
For more information, see the Hub Deposit Flowchart and the Hub Collateralize Flowchart.
Offchain Token <> ETH Swap
Please see this Offchain Exchange Flowchart for more information.
Note: some of this information overlaps with (but is consistent with) the Offchain Timeouts section.
-
The user tells the hub what they would like to exchange (ex, "69 BOOTY")
-
The hub proposes an exchange rate, and returns a state update which would perform the exchange. For example, if the current exchange rate is 1 ETH = 69 BOOTY, and the balances before the exchange were:
// Before exchange weiBalances: [1 ETH, 0] tokenBalances: [0, 69 BOOTY] txCount: [1,1]
Then the hub's proposed update might be:
// After exchange weiBalances: [0, 1 ETH] tokenBalances: [69 BOOTY, 0] txCount: [2,1]
-
The wallet checks the exchange rate, then signs the update and returns it to the hub. It also starts a timer.
-
When the hub receives the half-signed update, it double checks the exchange rate, then counter-signs and returns the fully-signed state update to the user. If the hub does not accept the half-signed update (for example, because it doesn't like the proposed exchange rate), it will respond with a different half-signed state invalidating the proposed exchange (specifically, if the proposed exchange has
txCount = [2,1]
, then the invalidating state returned by the hub will be identical to state wheretxCount = [1,1]
except it will havetxCount = [3,1]
). -
If the user does not hear back from the hub with either a countersigned exchange update or a half-signed negation update by the end of the timeout, they dispute onchain with their latest available state.
Note: if the user has an up-to-date exchange rate, steps 1, 2, and 3 could theoretically be avoided (ie, the user could use the up-to-date exchange rate to generate and sign an exchange that it knows the hub will accept). In practice, however, the hub will also need to check the user's BOOTY limit (in addition to the exchange rate), which the wallet may or may not know.
Withdrawal with Token <> ETH Swap
Performers can withdraw from channels using the same mechanism regardless of whether they are doing a partial or full withdraw (the latter was previously called consensusClose in our system). Users can use the same mechanism to withdraw funds as well, though we expect that it will mostly be performers using this functionality.
- Performer begins by deciding how much they want to withdraw on the wallet
- Wallet requests the hub to send a state update with the withdraw amount included as a pending withdraw.
- The Hub may also chose to include ETH or tokens as part of the withdraw if the performer wanted to do a swap. For a more complex (but commonplace) example, suppose the performer has 100 BOOTY in their channel, wants to cash out in ETH but the Hub does not have that ETH already collateralized in the channel. The Hub could send over the following update:
// [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingWeiUpdates: [0, 0, 0.5 eth, 0.5 eth]
pendingTokenUpdates: [0, 100 booty, 0, 0]
weiBalances: [0, 0]
tokenBalances: [100, 0]
txCount: [currentOffchainCount, onChainCount++]
//Note: timeout not needed for hub functions
Note that the deposit and withdraw are both happening on the performer's side of the channel and that the weiBalances remain zero. This is because setting weiBalances[1]
to 0.5
would violate the "wei must be conserved"
requirement on the contract. By depositing and withdrawing from the same side, the channel's pending balance is first incremented by 0.5 ETH and then reduced by 0.5 ETH for the performer, allowing them to withdraw ETH directly from the Hub's in-contract balance if they have permission. Dope.
For more info on calculating balances for deposit/withdraw states, see: https://github.com/ConnextProject/contracts/blob/master/docs/aggregateUpdates.md
- Upon receiving the state update, the performer's wallet needs to validate the following:
- The withdrawal amount matches the user's request
- The exchange rate is correct
- Reconstructing the payload from the user's knowledge of state and recovering signer on the signature yields the hub's public key
- If correct, the performer signs and returns the state to the Hub.
- Hub validates that reconstructing the payload from the previously sent state and recovering signature yields the performer's public key
- If correct, the Hub countersigns and calls
hubAuthorizedUpdate
- Once the onchain transaction has been confirmed, either party may propose a state update moving the pending deposits into balances similar to 4.1 above.
- The counterparty will validate that the transaction has been confirmed onchain, then countersign and return the update.
address public hub;
uint256 public challengePeriod;
ERC20 public approvedToken;`
There is a single privileged hub
address set at contract deployment which can store ETH/ERC20 reserves on the contract, deposit those reserves into channels, and withdraw any unallocated reserves.
There is a single challengePeriod
set at contract deployment and is used for all channel and thread disputes.
There is a single approvedToken
ERC20 token set at contract deployment which is the only token that can be used in channels for the contract. This prevents reentrancy attacks from user-provided malicious token contracts.
constructor(
address _hub,
uint256 _challengePeriod,
address _tokenAddress
) public {
hub = _hub;
challengePeriod = _challengePeriod;
approvedToken = ERC20(_tokenAddress);
}
These global constants are all set by the contract constructor at deployment.
uint256 public totalChannelWei;
uint256 public totalChannelToken;
The totalChannelWei
and totalChannelToken
track the total wei and tokens that has been deposited in channels by the hub and all users. Any wei or tokens balance on the contract above the totalChannelWei
and totalChannelToken
is assumed to be hub reserves.
Prevents the modified method from being called except by the hub registered during contract construction.
modifier onlyHub() {
require(msg.sender == hub);
_;
}
Creates a mutex around modified methods such that any reentrant calls to modified methods will fail. The mutex is released after the modified method returns.
modifier noReentrancy() {
require(!locked, "Reentrant call.");
locked = true;
_;
locked = false;
}
Called by the hub to release deposited ETH or ERC20s. Checks to ensure that the hub cannot withdraw more funds than are currently un-allocated to channels. Note: Reserve amount = contract balance minus total channel balance. This is why we don't need to reduce/zero out onchain balances.
function hubContractWithdraw(uint256 weiAmount, uint256 tokenAmount) public noReentrancy onlyHub {
require(
getHubReserveWei() >= weiAmount,
"hubContractWithdraw: Contract wei funds not sufficient to withdraw"
);
require(
getHubReserveTokens() >= tokenAmount,
"hubContractWithdraw: Contract token funds not sufficient to withdraw"
);
hub.transfer(weiAmount);
require(
approvedToken.transfer(hub, tokenAmount),
"hubContractWithdraw: Token transfer failure"
);
emit DidHubContractWithdraw(weiAmount, tokenAmount);
}
Returns the amount of ETH that the hub can withdraw.
function getHubReserveWei() public view returns (uint256) {
return address(this).balance.sub(totalChannelWei);
}
Returns the amount of ERC20 tokens that the hub can withdraw.
function getHubReserveTokens() public view returns (uint256) {
return approvedToken.balanceOf(address(this)).sub(totalChannelToken);
}
hubAuthorizedUpdate
is called by the hub to update the onchain channel state to reflect the latest mutually signed state update and execute any authorized deposits or withdrawals. It works as follows:
- It verifies the authorized update using the
_verifyAuthorizedUpdate
function. - It verifies the signature provided using
_verifySig
note that we skip hub sig verification here because this is ahubOnly
function. - It updates the channel balances, taking pending updates into account using
_updateChannelBalances
. - It transfers the pending withdrawals to the provided
recipient
- It stores the new
txCount
,threadRoot
, andthreadCount
. - It emits a
DidUpdateChannel
event.
function hubAuthorizedUpdate(
address user,
address recipient,
uint256[2] weiBalances, // [hub, user]
uint256[2] tokenBalances, // [hub, user]
uint256[4] pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[4] pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[2] txCount, // [global, onchain] persisted onchain even when empty
bytes32 threadRoot,
uint256 threadCount,
uint256 timeout,
string sigUser
) public noReentrancy onlyHub {
Channel storage channel = channels[user];
_verifyAuthorizedUpdate(
channel,
txCount,
weiBalances,
tokenBalances,
pendingWeiUpdates,
pendingTokenUpdates,
timeout,
true
);
_verifySig(
[user, recipient],
weiBalances,
tokenBalances,
pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
txCount,
threadRoot,
threadCount,
timeout,
"", // skip hub sig verification
sigUser
);
_updateChannelBalances(channel, weiBalances, tokenBalances, pendingWeiUpdates, pendingTokenUpdates);
// transfer wei and token to recipient
recipient.transfer(pendingWeiUpdates[3]);
require(approvedToken.transfer(recipient, pendingTokenUpdates[3]), "user token withdrawal transfer failed");
// update state variables
channel.txCount = txCount;
channel.threadRoot = threadRoot;
channel.threadCount = threadCount;
emit DidUpdateChannel(
user,
0, // senderIdx
weiBalances,
tokenBalances,
pendingWeiUpdates,
pendingTokenUpdates,
txCount,
threadRoot,
threadCount,
timeout
);
}
Similar to hubAuthorizedUpdate
, userAuthorizedUpdate
is called by the user to update the onchain channel state to reflect the latest mutually signed state update and execute any authorized deposits or withdrawals. The mechanism is very similar to hubAuthorizedUpdate
, but the function verifies the hub's sig instead.
Note: we do not need to verify user's sig because we are searching channel by msg.sender
.
function userAuthorizedUpdate(
address recipient,
uint256[2] weiBalances, // [hub, user]
uint256[2] tokenBalances, // [hub, user]
uint256[4] pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[4] pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[2] txCount, // persisted onchain even when empty
bytes32 threadRoot,
uint256 threadCount,
uint256 timeout,
string sigHub
) public payable noReentrancy {
require(msg.value == pendingWeiUpdates[2], "msg.value is not equal to pending user deposit");
Channel storage channel = channels[msg.sender];
_verifyAuthorizedUpdate(
channel,
txCount,
weiBalances,
tokenBalances,
pendingWeiUpdates,
pendingTokenUpdates,
timeout,
false
);
_verifySig(
[msg.sender, recipient],
weiBalances,
tokenBalances,
pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
txCount,
threadRoot,
threadCount,
timeout,
sigHub,
"" // skip user sig verification
);
// transfer user token deposit to this contract
require(approvedToken.transferFrom(msg.sender, address(this), pendingTokenUpdates[2]), "user token deposit failed");
_updateChannelBalances(channel, weiBalances, tokenBalances, pendingWeiUpdates, pendingTokenUpdates);
// transfer wei and token to recipient
recipient.transfer(pendingWeiUpdates[3]);
require(approvedToken.transfer(recipient, pendingTokenUpdates[3]), "user token withdrawal transfer failed");
// update state variables
channel.txCount = txCount;
channel.threadRoot = threadRoot;
channel.threadCount = threadCount;
emit DidUpdateChannel(
msg.sender,
1, // senderIdx
weiBalances,
tokenBalances,
pendingWeiUpdates,
pendingTokenUpdates,
txCount,
threadRoot,
threadCount,
timeout
);
}
Begins the unilateral channel withdrawal process for the currently-stored onchain state. In other words, if the onchain recorded state (from a deposit or withdraw) is the latest recorded state, this allows a disputer to start the timer to exit using that state rather than passing in their own. The process starts as follows:
- The channel's state is verified to be
Status.Open
. msg.sender
is verified to be either the hub or the user.- The
exitInitiator
field is set tomsg.sender
. - The
channelClosingTime
field is set tonow
+challengePeriod
. - The status is set to
Status.ChannelDispute
. - Emits
DidStartExitChannel
event.
function startExit(
address user
) public noReentrancy {
Channel storage channel = channels[user];
require(channel.status == Status.Open, "channel must be open");
require(msg.sender == hub || msg.sender == user, "exit initiator must be user or hub");
channel.exitInitiator = msg.sender;
channel.channelClosingTime = now.add(challengePeriod);
channel.status = Status.ChannelDispute;
emit DidStartExitChannel(
user,
msg.sender == hub ? 0 : 1,
[channel.weiBalances[0], channel.weiBalances[1]],
[channel.tokenBalances[0], channel.tokenBalances[1]],
channel.txCount,
channel.threadCount,
channel.exitInitiator
);
}
Begins the unilateral channel withdrawal process with the provided offchain state. In other words, this is called when a disputer wants to exit with a mutually signed offchain state that is at a higher txCount
than the onchain state. The process works as follows:
- The channel's state is verified to be
Status.Open
. msg.sender
is verified to be either the hub or the user- The provided state's
timeout
is verified to be zero. Note that no time-sensitive states can be disputed. - Hub and user signatures are verified.
- The
txCount
field is verified as per the rules described in_verifyAuthorizedUpdate
. - The balances are verified to not exceed the channel's total balances
- In the case where the onchain
txCount
equals the provided onchaintxCount
(i.e. a deposit/withdraw has happened onchain), the provided offchain state is force-updated using_applyPendingUpdates
. Otherwise, pending withdrawals are rolled back into the offchain balances using_revertPendingUpdates
. txCount
,threadRoot
andthreadCount
are updated.- The
exitInitiator
field is set tomsg.sender
. - The
channelClosingTime
field is set tonow
+challengePeriod
. - The status is set to
Status.ChannelDispute
. - Emits
DidStartExitChannel
event.
function startExitWithUpdate(
address[2] user, // [user, recipient]
uint256[2] weiBalances, // [hub, user]
uint256[2] tokenBalances, // [hub, user]
uint256[4] pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[4] pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[2] txCount, // [global, onchain] persisted onchain even when empty
bytes32 threadRoot,
uint256 threadCount,
uint256 timeout,
string sigHub,
string sigUser
) public noReentrancy {
Channel storage channel = channels[user[0]];
require(channel.status == Status.Open, "channel must be open");
require(msg.sender == hub || msg.sender == user[0], "exit initiator must be user or hub");
require(timeout == 0, "can't start exit with time-sensitive states");
_verifySig(
user,
weiBalances,
tokenBalances,
pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
txCount,
threadRoot,
threadCount,
timeout,
sigHub,
sigUser
);
require(txCount[0] > channel.txCount[0], "global txCount must be higher than the current global txCount");
require(txCount[1] >= channel.txCount[1], "onchain txCount must be higher or equal to the current onchain txCount");
// offchain wei/token balances do not exceed onchain total wei/token
require(weiBalances[0].add(weiBalances[1]) <= channel.weiBalances[2], "wei must be conserved");
require(tokenBalances[0].add(tokenBalances[1]) <= channel.tokenBalances[2], "tokens must be conserved");
// pending onchain txs have been executed - force update offchain state to reflect this
if (txCount[1] == channel.txCount[1]) {
_applyPendingUpdates(channel.weiBalances, weiBalances, pendingWeiUpdates);
_applyPendingUpdates(channel.tokenBalances, tokenBalances, pendingTokenUpdates);
// pending onchain txs have *not* been executed - revert pending deposits and withdrawals back into offchain balances
} else { //txCount[1] > channel.txCount[1]
_revertPendingUpdates(channel.weiBalances, weiBalances, pendingWeiUpdates);
_revertPendingUpdates(channel.tokenBalances, tokenBalances, pendingTokenUpdates);
}
// update state variables
channel.txCount = txCount;
channel.threadRoot = threadRoot;
channel.threadCount = threadCount;
channel.exitInitiator = msg.sender;
channel.channelClosingTime = now.add(challengePeriod);
channel.status == Status.ChannelDispute;
emit DidStartExitChannel(
user[0],
msg.sender == hub ? 0 : 1,
[channel.weiBalances[0], channel.weiBalances[1]],
[channel.tokenBalances[0], channel.tokenBalances[1]],
channel.txCount,
channel.threadCount,
channel.exitInitiator
);
}
emptyChannelWithChallenge
performs the second round in the the unilateral withdrawal game. In this case, the challenging user presents a later authorized state than was presented in startExitWithUpdate
. Only the user who did not start the exit may call this method.
- Verifies that the channel is in dispute and that the closing time has not yet expired.
- Verifies that the
msg.sender
is not the initiator of the dispute and that it is either the hub or the user. - Verifies that the caller is not attempting to exit with a time-sensitive state (user deposit, exchange).
- Verifies both signers from the state and sigs.
- Verifies that the
txCount
s must be accurate (subject to the logic presented inhubAuthorizedUpdate
above. - Verifies that balances are conserved.
- In the case where the onchain
txCount
equals the provided onchaintxCount
(i.e. a deposit/withdraw has happened onchain), the provided offchain state is force-updated using_applyPendingUpdates
. Otherwise, pending withdrawals are rolled back into the offchain balances using_revertPendingUpdates
. - Deducts balances from the total onchain recorded balances for the channel.
- Transfers balances to both parties respectively.
- Updates
txCount
,threadRoot
andthreadCount
state variables. - If there are no threads open, zeroes out the thread closing time and reopens the channel so that it can be used again. We don't have to zero
threadRoot
here because it is assumed to be empty if there are no threads open. - Otherwise, sets the thread dispute time and changes the channel's state to
ThreadDispute
. - Reinitializes the channel closing time and exit initiator variables since the channel dispute process has been completed.
- Emits the
DidEmptyChannelWithChallenge
event.
function emptyChannelWithChallenge(
address[2] user,
uint256[2] weiBalances, // [hub, user]
uint256[2] tokenBalances, // [hub, user]
uint256[4] pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[4] pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[2] txCount, // persisted onchain even when empty
bytes32 threadRoot,
uint256 threadCount,
uint256 timeout,
string sigHub,
string sigUser
) public noReentrancy {
Channel storage channel = channels[user[0]];
require(channel.status == Status.ChannelDispute, "channel must be in dispute");
require(now < channel.channelClosingTime, "channel closing time must not have passed");
require(msg.sender != channel.exitInitiator, "challenger can not be exit initiator");
require(msg.sender == hub || msg.sender == user[0], "challenger must be either user or hub");
require(timeout == 0, "can't start exit with time-sensitive states");
_verifySig(
user,
weiBalances,
tokenBalances,
pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
txCount,
threadRoot,
threadCount,
timeout,
sigHub,
sigUser
);
require(txCount[0] > channel.txCount[0], "global txCount must be higher than the current global txCount");
require(txCount[1] >= channel.txCount[1], "onchain txCount must be higher or equal to the current onchain txCount");
// offchain wei/token balances do not exceed onchain total wei/token
require(weiBalances[0].add(weiBalances[1]) <= channel.weiBalances[2], "wei must be conserved");
require(tokenBalances[0].add(tokenBalances[1]) <= channel.tokenBalances[2], "tokens must be conserved");
// pending onchain txs have been executed - force update offchain state to reflect this
if (txCount[1] == channel.txCount[1]) {
_applyPendingUpdates(channel.weiBalances, weiBalances, pendingWeiUpdates);
_applyPendingUpdates(channel.tokenBalances, tokenBalances, pendingTokenUpdates);
// pending onchain txs have *not* been executed - revert pending deposits and withdrawals back into offchain balances
} else { //txCount[1] > channel.txCount[1]
_revertPendingUpdates(channel.weiBalances, weiBalances, pendingWeiUpdates);
_revertPendingUpdates(channel.tokenBalances, tokenBalances, pendingTokenUpdates);
}
// deduct hub/user wei/tokens from total channel balances
channel.weiBalances[2] = channel.weiBalances[2].sub(weiBalances[0]).sub(weiBalances[1]);
channel.tokenBalances[2] = channel.tokenBalances[2].sub(tokenBalances[0]).sub(tokenBalances[1]);
// transfer hub wei balance from channel to reserves
totalChannelWei = totalChannelWei.sub(channel.weiBalances[0]);
channel.weiBalances[0] = 0;
// transfer user wei balance to user
totalChannelWei = totalChannelWei.sub(channel.weiBalances[1]);
user[0].transfer(channel.weiBalances[1]);
channel.weiBalances[1] = 0;
// transfer hub token balance from channel to reserves
totalChannelToken = totalChannelToken.sub(channel.tokenBalances[0]);
channel.tokenBalances[0] = 0;
// transfer user token balance to user
totalChannelToken = totalChannelToken.sub(channel.tokenBalances[1]);
require(approvedToken.transfer(user[0], channel.tokenBalances[1]), "user token withdrawal transfer failed");
channel.tokenBalances[1] = 0;
// update state variables
channel.txCount = txCount;
channel.threadRoot = threadRoot;
channel.threadCount = threadCount;
if (channel.threadCount > 0) {
channel.threadClosingTime = now.add(challengePeriod);
channel.status == Status.ThreadDispute;
} else {
channel.threadClosingTime = 0;
channel.status == Status.Open;
//TODO Should we zero out the threadRoot here?
}
channel.exitInitiator = address(0x0);
channel.channelClosingTime = 0;
emit DidEmptyChannelWithChallenge(
user[0],
msg.sender == hub ? 0 : 1,
[channel.weiBalances[0], channel.weiBalances[1]],
[channel.tokenBalances[0], channel.tokenBalances[1]],
channel.txCount,
channel.threadRoot,
channel.threadCount
);
}
Called by any party when the channel dispute timer expires. Uses the latest available onchain state to transfer values.
- Verifies that the channel is in dispute and that the closing time has expired.
- Deducts the onchain balances from the total recorded balance of the channel.
- Transfers the onchain balances to their respective parties.
- If there are no threads open, zeroes out the thread closing time and reopens the channel so that it can be used again. We don't have to zero
threadRoot
here because it is assumed to not contain anything if there were no open threads. - Otherwise, sets the thread dispute time and changes the channel's state to
ThreadDispute
. - Reinitializes the channel closing time and exit initiator variables since the channel dispute process has been completed.
- Emits the
DidEmptyChannel
event.
function emptyChannel(
address user
) public noReentrancy {
Channel storage channel = channels[user];
require(channel.status == Status.ChannelDispute, "channel must be in dispute");
require(channel.channelClosingTime < now, "channel closing time must have passed");
// deduct hub/user wei/tokens from total channel balances
channel.weiBalances[2] = channel.weiBalances[2].sub(channel.weiBalances[0]).sub(channel.weiBalances[1]);
channel.tokenBalances[2] = channel.tokenBalances[2].sub(channel.tokenBalances[0]).sub(channel.tokenBalances[1]);
// transfer hub wei balance from channel to reserves
totalChannelWei = totalChannelWei.sub(channel.weiBalances[0]);
channel.weiBalances[0] = 0;
// transfer user wei balance to user
totalChannelWei = totalChannelWei.sub(channel.weiBalances[1]);
user.transfer(channel.weiBalances[1]);
channel.weiBalances[1] = 0;
// transfer hub token balance from channel to reserves
totalChannelToken = totalChannelToken.sub(channel.tokenBalances[0]);
channel.tokenBalances[0] = 0;
// transfer user token balance to user
totalChannelToken = totalChannelToken.sub(channel.tokenBalances[1]);
require(approvedToken.transfer(user, channel.tokenBalances[1]), "user token withdrawal transfer failed");
channel.tokenBalances[1] = 0;
if (channel.threadCount > 0) {
channel.threadClosingTime = now.add(challengePeriod);
channel.status == Status.ThreadDispute;
} else {
channel.threadClosingTime = 0;
channel.status == Status.Open;
}
channel.exitInitiator = address(0x0);
channel.channelClosingTime = 0;
emit DidEmptyChannel(
user,
msg.sender == hub ? 0 : 1,
[channel.weiBalances[0], channel.weiBalances[1]],
[channel.tokenBalances[0], channel.tokenBalances[1]],
channel.txCount,
channel.threadCount,
channel.exitInitiator
);
}
Initializes the thread onchain to prep it for dispute (called when no newer state update is available). This is the thread corollary to startExit
for channel.
- Verifies that the channel is in the
ThreadDispute
state and thatthreadClosingTime
has not expired. - Verifies that it is being called by either the hub or the user.
- Verifies that the thread is not already in dispute.
- Verifies that the thread transaction count is greater than onchain recorded txCount. Note, this means that the initial thread state for the first time a thread is opened needs to be at
thread.txCount = 1
because the thread txCount initializes to 0. - Verifies the signature that is submitted to ensure that it belongs to the sender and verifies that the initial state of this thread is contained in the recorded
threadRoot
using_verifyThread.
- Updates the thread state onchain and sets
inDispute
to true. - Emits the
DidStartExitThread
event.
function startExitThread(
address user,
address sender,
address receiver,
uint256[2] weiBalances, // [sender, receiver]
uint256[2] tokenBalances, // [sender, receiver]
uint256 txCount,
bytes proof,
string sig
) public noReentrancy {
Channel storage channel = channels[user];
require(channel.status == Status.ThreadDispute, "channel must be in thread dispute phase");
require(now < channel.threadClosingTime, "channel thread closing time must not have passed");
require(msg.sender == hub || msg.sender == user, "thread exit initiator must be user or hub");
Thread storage thread = channel.threads[sender][receiver];
require(!thread.inDispute, "thread must not already be in dispute");
require(txCount > thread.txCount, "thread txCount must be higher than the current thread txCount");
_verifyThread(user, sender, receiver, weiBalances, tokenBalances, txCount, proof, sig, channel.threadRoot);
thread.weiBalances = weiBalances;
thread.tokenBalances = tokenBalances;
thread.txCount = txCount;
thread.inDispute = true;
emit DidStartExitThread(
user,
sender,
receiver,
msg.sender == hub ? 0 : 1,
thread.weiBalances,
thread.tokenBalances,
thread.txCount
);
}
Initializes thread state onchain and immediately updates it. This is called when a party wants to dispute a thread and also has a state beyond just the initial state. The channel corollary is startExitWithUpdate
- Verifies that the channel is in the
ThreadDispute
status and that the thread closing time has not expired. - Verifies that the message sender is either the hub or the user.
- Verifies that the thread is in dispute and that the submitted transaction count is greater than
thread.txCount
. - Verifies the thread using the
_verifyThread
method: recreates the signature and recovers signer, then checks that the initial state is part of thethreadRoot
. - Verifies that the transaction count for the updated state is greater than the
txCount
of the initial thread state. - Verifies that the total wei and token balances must be equal to the previously recorded total wei and token balances (i.e. value is conserved).
- Verifies that the update only increases the value of the receiver. This is because threads are unidirectional: value can only move from sender→receiver. Doing this removes the need for a signature from the receiver.
- Verifies that the signature of the updated thread state using the
_verifyThread
method. Note that thethreadRoot
is set tobytes32(0x0)
because a merkle proof is not needed for the not-initial state. - Updates the thread state onchain.
- Emits the
DidStartExitThread
event.
function startExitThreadWithUpdate(
address user,
address[2] threadMembers, //[sender, receiver]
uint256[2] weiBalances, // [sender, receiver]
uint256[2] tokenBalances, // [sender, receiver]
uint256 txCount,
bytes proof,
string sig,
uint256[2] updatedWeiBalances, // [sender, receiver]
uint256[2] updatedTokenBalances, // [sender, receiver]
uint256 updatedTxCount,
string updateSig
) public noReentrancy {
Channel storage channel = channels[user];
require(channel.status == Status.ThreadDispute, "channel must be in thread dispute phase");
require(now < channel.threadClosingTime, "channel thread closing time must not have passed");
require(msg.sender == hub || msg.sender == user, "thread exit initiator must be user or hub");
Thread storage thread = channel.threads[threadMembers[0]][threadMembers[1]];
require(!thread.inDispute, "thread must not already be in dispute");
require(txCount > thread.txCount, "thread txCount must be higher than the current thread txCount");
_verifyThread(user, threadMembers[0], threadMembers[1], weiBalances, tokenBalances, txCount, proof, sig, channel.threadRoot);
// *********************
// PROCESS THREAD UPDATE
// *********************
require(updatedTxCount > txCount, "updated thread txCount must be higher than the initial thread txCount");
require(updatedWeiBalances[0].add(updatedWeiBalances[1]) == weiBalances[0].add(weiBalances[1]), "updated wei balances must match sum of initial wei balances");
require(updatedTokenBalances[0].add(updatedTokenBalances[1]) == tokenBalances[0].add(tokenBalances[1]), "updated token balances must match sum of initial token balances");
require(updatedWeiBalances[1] > weiBalances[1], "receiver wei balance must always increase");
require(updatedTokenBalances[1] > tokenBalances[1], "receiver token balance must always increase");
// Note: explicitly set threadRoot == 0x0 because then it doesn't get checked by _isContained (updated state is not part of root)
_verifyThread(user, threadMembers[0], threadMembers[1], updatedWeiBalances, updatedTokenBalances, updatedTxCount, proof, sig, bytes32(0x0));
thread.weiBalances = updatedWeiBalances;
thread.tokenBalances = updatedTokenBalances;
thread.txCount = updatedTxCount;
thread.inDispute = true;
emit DidStartExitThread(
user,
threadMembers[0],
threadMembers[1],
msg.sender == hub ? 0 : 1,
thread.weiBalances,
thread.tokenBalances,
thread.txCount
);
}
Let's the receiver side of the thread (hub for user→hub, performer for hub→performer) update and immediately close the thread. Corollary is emptyChannelWithChallenge
. The receiver will either call fastEmptyThread
immediately after they themselves call startExitThread
in order to quickly exit or call it if they see the sender call startExitThread
or startExitThreadWithUpdate
with a previous thread state where they have earned less money. To protect against the sender calling startExitThread/WithUpdate
immediately before the channel.threadClosingTime
expires, the receiver should start the thread exit process themselves if they don't see the sender do so within a reasonable time window.
- Verifies that the channel is in the
ThreadDispute
state and that the thread closing time has not expired. - Verifies that either this refers to the sender-hub channel and the
msg.sender
is the hub OR that this refers to the hub-performer channel andmsg.sender
is the performer. In other words, the receiver of funds is always the one calling this update. - Verifies that the thread is in dispute and that the transaction count provided is greater than the onchain
txCount
for the thread. - Verifies that the total submitted balances are equal to the total onchain recorded balances from the initial state.
- Verifies the signature using
_verifyThread
. - Verifies that the update only increases the value of the receiver. This is because threads are unidirectional: value can only move from sender→receiver. Doing this removes the need for a signature from the receiver.
- Deducts the submitted balances from the total onchain recorded channel balances (finalized during the channel unilateral exit functions).
- Transfers the submitted balances to their respective recipients and zeroes the onchain versions of those values.
- Updates the thread's
txCount
and setsinDispute
to false. This allows the thread to now be reused. - Decrements the thread count and if the thread count is zero, reopens the channel (similar to above step), reinitializes
threadRoot
, and resets dispute fields. - Emits
DidEmptyThread
event.
function fastEmptyThread(
address user,
address sender,
address receiver,
uint256[2] weiBalances,
uint256[2] tokenBalances,
uint256 txCount,
bytes proof,
string sig
) public noReentrancy {
Channel storage channel = channels[user];
require(channel.status == Status.ThreadDispute, "channel must be in thread dispute phase");
require(now < channel.threadClosingTime, "channel thread closing time must not have passed");
require((msg.sender == hub && sender == user) || (msg.sender == user && receiver == user), "only hub or user, as the non-sender, can call this function");
Thread storage thread = channel.threads[sender][receiver];
require(thread.inDispute, "thread must be in dispute");
// assumes that the non-sender has a later thread state than what was being proposed when the thread exit started
require(txCount > thread.txCount, "thread txCount must be higher than the current thread txCount");
require(weiBalances[0].add(weiBalances[1]) == thread.weiBalances[0].add(thread.weiBalances[1]), "updated wei balances must match sum of thread wei balances");
require(tokenBalances[0].add(tokenBalances[1]) == thread.tokenBalances[0].add(thread.tokenBalances[1]), "updated token balances must match sum of thread token balances");
// Note: explicitly set threadRoot == 0x0 because then it doesn't get checked by _isContained (updated state is not part of root)
_verifyThread(user, sender, receiver, weiBalances, tokenBalances, txCount, proof, sig, bytes32(0x0));
//Unidirectional thread
require(WeiBalances[1] > thread.weiBalances[1], "receiver wei balance must always increase");
require(TokenBalances[1] > thread.tokenBalances[1], "receiver token balance must always increase");
// deduct sender/receiver wei/tokens about to be emptied from the thread from the total channel balances
channel.weiBalances[2] = channel.weiBalances[2].sub(weiBalances[0]).sub(weiBalances[1]);
channel.tokenBalances[2] = channel.tokenBalances[2].sub(tokenBalances[0]).sub(tokenBalances[1]);
// deduct wei balances from total channel wei and reset thread balances
totalChannelWei = totalChannelWei.sub(weiBalances[0]).sub(weiBalances[1]);
thread.weiBalances[0] = 0;
thread.weiBalances[1] = 0;
// transfer wei to user if they are receiver (otherwise gets added to reserves implicitly)
if (user == receiver) {
user.transfer(weiBalances[1]);
}
// deduct token balances from channel total balances and reset thread balances
totalChannelToken = totalChannelToken.sub(tokenBalances[0]).sub(tokenBalances[1]);
thread.tokenBalances[0] = 0;
thread.tokenBalances[1] = 0;
// transfer token to user if they are receiver (otherwise gets added to reserves implicitly)
if (user == receiver) {
require(approvedToken.transfer(user, tokenBalances[1]), "user token withdrawal transfer failed");
}
thread.txCount = txCount;
thread.inDispute = false;
// decrement the channel threadCount
channel.threadCount = channel.threadCount.sub(1);
// if this is the last thread being emptied, re-open the channel
if (channel.threadCount == 0) {
channel.threadRoot = bytes32(0x0);
channel.threadClosingTime = 0;
channel.status = Status.Open;
}
emit DidEmptyThread(
user,
sender,
receiver,
msg.sender == hub ? 0 : 1,
[channel.weiBalances[0], channel.weiBalances[1]],
[channel.tokenBalances[0], channel.tokenBalances[1]],
channel.txCount,
channel.threadRoot,
channel.threadCount
);
}
Called by any party when the thread dispute timer expires. Uses the latest available onchain state to transfer values. Corollary is emptyChannel
.
- Verifies that the channel state is in
ThreadDispute
and that the thread closing time has expired. - Verifies that the thread is in dispute. No need to verify anything else since we just use already verified onchain state.
- Deducts the onchain thread balances from the onchain total channel balances.
- Transfers onchain thread balances to their respective owners and zeroes out the state.
- Updates the thread's
txCount
and setsinDispute
to false. This allows the thread to now be reused. - Decrements the thread count and if the thread count is zero, reopens the channel (similar to above step), reinitializes
threadRoot
, and resets dispute fields.
function emptyThread(
address user,
address sender,
address receiver
) public noReentrancy {
Channel storage channel = channels[user];
require(channel.status == Status.ThreadDispute, "channel must be in thread dispute");
require(channel.threadClosingTime < now, "thread closing time must have passed");
Thread storage thread = channel.threads[sender][receiver];
require(thread.inDispute, "thread must be in dispute");
// deduct sender/receiver wei/tokens about to be emptied from the thread from the total channel balances
channel.weiBalances[2] = channel.weiBalances[2].sub(thread.weiBalances[0]).sub(thread.weiBalances[1]);
channel.tokenBalances[2] = channel.tokenBalances[2].sub(thread.tokenBalances[0]).sub(thread.tokenBalances[1]);
// deduct wei balances from total channel wei and reset thread balances
totalChannelWei = totalChannelWei.sub(thread.weiBalances[0]).sub(thread.weiBalances[1]);
// transfer wei to user if they are receiver (otherwise gets added to reserves implicitly)
if (user == receiver) {
user.transfer(thread.weiBalances[1]);
}
thread.weiBalances[0] = 0;
thread.weiBalances[1] = 0;
// deduct token balances from channel total balances and reset thread balances
totalChannelToken = totalChannelToken.sub(thread.tokenBalances[0]).sub(thread.tokenBalances[1]);
// transfer token to user if they are receiver (otherwise gets added to reserves implicitly)
if (user == receiver) {
require(approvedToken.transfer(user, thread.tokenBalances[1]), "user token withdrawal transfer failed");
}
thread.tokenBalances[0] = 0;
thread.tokenBalances[1] = 0;
thread.inDispute = false;
// decrement the channel threadCount
channel.threadCount = channel.threadCount.sub(1);
// if this is the last thread being emptied, re-open the channel
if (channel.threadCount == 0) {
channel.threadRoot = bytes32(0x0);
channel.threadClosingTime = 0;
channel.status = Status.Open;
}
emit DidEmptyThread(
user,
sender,
receiver,
msg.sender == hub ? 0 : 1,
[channel.weiBalances[0], channel.weiBalances[1]],
[channel.tokenBalances[0], channel.tokenBalances[1]],
channel.txCount,
channel.threadRoot,
channel.threadCount
);
}
Called in the event that threads reach an unsettleable state because they were not disputed in time. After 10 challenge periods, hard resets the channel state to being open (causes the user to lose access to the funds in any remaining open threads).
- Verifies that the channel is in
ThreadDispute
and that 10 challenge periods have passed since thethreadClosingTime
. - Transfers any remaining channel balances recorded onchain to the user.
- Zeroes out the total channel balances. Note: there is no need to zero out the other elements of those balances because they will always have been zeroed in other functions.
- Resets all other channel state params and sets the channel status to
Open
. - Emits the
DidNukeThreads
event.
function nukeThreads(
address user
) public noReentrancy {
Channel storage channel = channels[user];
require(channel.status == Status.ThreadDispute, "channel must be in thread dispute");
require(channel.threadClosingTime.add(challengePeriod.mul(10)) < now, "thread closing time must have passed by 10 challenge periods");
// transfer any remaining channel wei to user
totalChannelWei = totalChannelWei.sub(channel.weiBalances[2]);
user.transfer(channel.weiBalances[2]);
uint256 weiAmount = channel.weiBalances[2];
channel.weiBalances[2] = 0;
// transfer any remaining channel tokens to user
totalChannelToken = totalChannelToken.sub(channel.tokenBalances[2]);
require(approvedToken.transfer(user, channel.tokenBalances[2]), "user token withdrawal transfer failed");
uint256 tokenAmount = channel.tokenBalances[2];
channel.tokenBalances[2] = 0;
// reset channel params
channel.threadCount = 0;
channel.threadRoot = bytes32(0x0);
channel.threadClosingTime = 0;
channel.status = Status.Open;
emit DidNukeThreads(
user,
msg.sender,
weiAmount,
tokenAmount,
[channel.weiBalances[0], channel.weiBalances[1]],
[channel.tokenBalances[0], channel.tokenBalances[1]],
channel.txCount,
channel.threadRoot,
channel.threadCount
);
}
Note: we believe there is an attack vector with this method:
- User deposits 0.5 ETH into the channel.
- User open 5 threads with 0.1 ETH each.
- User disputes 2 of them, and lets 3 expire. The hub doesn't have these states since they have not been disputed.
- User calls nukeThreads.
- User deposit 0.5 ETH into the channel again.
- User opens the 3 same expired threads again.
- User replay attacks the expired threads with their state that the hub doesn't know about.
In practice, this would be tough to do because the hub should at the very least have the initial agreed-upon thread state, so the hub would be able to call startExitThread
with the initial state and then emptyThread
after the threadClosingTime
has passed. If the hub loses its thread states, that's the hub's fault.
Internal view function that verifies the authorized update. Called by hub and user authorized update functions.
- It verifies that the channel is open.
- It verifies that the timeout is either 0 or has not yet expired.
- It verifies that the incoming
txCount
variables conform to the following rules:- The provided global
txCount
must always be strictly higher than the stored globaltxCount
. This is because the globaltxCount
is expected to increment for every state update. - The provided onchain
txCount
must be greater than or equal to the stored onchaintxCount
. This is because the onchain count only increases in the event of an onchain transaction, and the vast majority of updates will be handled offchain.
- The provided global
- Verifies that the submitted balances do not exceed onchain recorded balances.
- It verifies that the contract holds enough Ether or tokens to collateralize the state update.
- It verifies that the proposed balance updates less withdrawals do not exceed the onchain balances + deposits.
function _verifyAuthorizedUpdate(
Channel storage channel,
uint256[2] txCount,
uint256[2] weiBalances,
uint256[2] tokenBalances, // [hub, user]
uint256[4] pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[4] pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256 timeout,
bool isHub
) internal view {
require(channel.status == Status.Open, "channel must be open");
// Usage:
// 1. exchange operations to protect user from exchange rate fluctuations
require(timeout == 0 || now < timeout, "the timeout must be zero or not have passed");
require(txCount[0] > channel.txCount[0], "global txCount must be higher than the current global txCount");
require(txCount[1] >= channel.txCount[1], "onchain txCount must be higher or equal to the current onchain txCount");
// offchain wei/token balances do not exceed onchain total wei/token
require(weiBalances[0].add(weiBalances[1]) <= channel.weiBalances[2], "wei must be conserved");
require(tokenBalances[0].add(tokenBalances[1]) <= channel.tokenBalances[2], "tokens must be conserved");
// hub has enough reserves for wei/token deposits for both the user and itself (if isHub, user deposit comes from hub)
if (isHub) {
require(pendingWeiUpdates[0].add(pendingWeiUpdates[2]) <= getHubReserveWei(), "insufficient reserve wei for deposits");
require(pendingTokenUpdates[0].add(pendingTokenUpdates[2]) <= getHubReserveTokens(), "insufficient reserve tokens for deposits");
// hub has enough reserves for only its own wei/token deposits
} else {
require(pendingWeiUpdates[0] <= getHubReserveWei(), "insufficient reserve wei for deposits");
require(pendingTokenUpdates[0] <= getHubReserveTokens(), "insufficient reserve tokens for deposits");
}
// wei is conserved - the current total channel wei + both deposits > final balances + both withdrawals
require(channel.weiBalances[2].add(pendingWeiUpdates[0]).add(pendingWeiUpdates[2]) >=
weiBalances[0].add(weiBalances[1]).add(pendingWeiUpdates[1]).add(pendingWeiUpdates[3]), "insufficient wei");
// token is conserved - the current total channel token + both deposits > final balances + both withdrawals
require(channel.tokenBalances[2].add(pendingTokenUpdates[0]).add(pendingTokenUpdates[2]) >=
Internal function that merges any unmerged updates (i.e. deposits) into the proposed balance and updates the onchain balances.
- If the deposit is greater than the withdrawal, adds the net of deposit minus withdrawal to the balances. (Assumes the net has not yet been added to the balances.
- Otherwise, if the deposit is less than or equal to the withdrawal, leaves balances as is. (Assumes the net has already been added to the balances.
More info: https://github.com/ConnextProject/contracts/blob/master/docs/aggregateUpdates.md
function _applyPendingUpdates(
uint256[3] storage channelBalances,
uint256[2] balances,
uint256[4] pendingUpdates
) internal {
// update hub balance
// If the deposit is greater than the withdrawal, add the net of deposit minus withdrawal to the balances.
// Assumes the net has *not yet* been added to the balances.
if (pendingUpdates[0] > pendingUpdates[1]) {
channelBalances[0] = balances[0].add(pendingUpdates[0].sub(pendingUpdates[1]));
// Otherwise, if the deposit is less than or equal to the withdrawal,
// Assumes the net has *already* been added to the balances.
} else {
channelBalances[0] = balances[0];
}
// update user balance
// If the deposit is greater than the withdrawal, add the net of deposit minus withdrawal to the balances.
// Assumes the net has *not yet* been added to the balances.
if (pendingUpdates[2] > pendingUpdates[3]) {
channelBalances[1] = balances[1].add(pendingUpdates[2].sub(pendingUpdates[3]));
// Otherwise, if the deposit is less than or equal to the withdrawal,
// Assumes the net has *already* been added to the balances.
} else {
channelBalances[1] = balances[1];
}
}
Internal function that does the exact opposite of _applyPendingUpdates
to revert a withdrawal that was already introduced to balances on state submission.
function _revertPendingUpdates(
uint256[3] storage channelBalances,
uint256[2] balances,
uint256[4] pendingUpdates
) internal {
// If the pending update has NOT been executed AND deposits > withdrawals, offchain state was NOT updated with delta, and is thus correct
if (pendingUpdates[0] > pendingUpdates[1]) {
channelBalances[0] = balances[0];
// If the pending update has NOT been executed AND deposits < withdrawals, offchain state should have been updated with delta, and must be reverted
} else {
channelBalances[0] = balances[0].add(pendingUpdates[1].sub(pendingUpdates[0])); // <- add withdrawal, sub deposit (opposite order as _applyPendingUpdates)
}
// If the pending update has NOT been executed AND deposits > withdrawals, offchain state was NOT updated with delta, and is thus correct
if (pendingUpdates[2] > pendingUpdates[3]) {
channelBalances[1] = balances[1];
// If the pending update has NOT been executed AND deposits > withdrawals, offchain state should have been updated with delta, and must be reverted
} else {
channelBalances[1] = balances[1].add(pendingUpdates[3].sub(pendingUpdates[2])); // <- add withdrawal, sub deposit (opposite order as _applyPendingUpdates)
}
}
Internal function that applies pending updates and updates the onchain balance for the channel and for the totalChannelWei
/ totalChannelToken
.
function _updateChannelBalances(
Channel storage channel,
uint256[2] weiBalances,
uint256[2] tokenBalances,
uint256[4] pendingWeiUpdates,
uint256[4] pendingTokenUpdates
) internal {
_applyPendingUpdates(channel.weiBalances, weiBalances, pendingWeiUpdates);
_applyPendingUpdates(channel.tokenBalances, tokenBalances, pendingTokenUpdates);
totalChannelWei = totalChannelWei.add(pendingWeiUpdates[0]).add(pendingWeiUpdates[2]).sub(pendingWeiUpdates[1]).sub(pendingWeiUpdates[3]);
totalChannelToken = totalChannelToken.add(pendingTokenUpdates[0]).add(pendingTokenUpdates[2]).sub(pendingTokenUpdates[1]).sub(pendingTokenUpdates[3]);
// update channel total balances
channel.weiBalances[2] = channel.weiBalances[2].add(pendingWeiUpdates[0]).add(pendingWeiUpdates[1]).sub(pendingWeiUpdates[2]).sub(pendingWeiUpdates[3]);
channel.tokenBalances[2] = channel.tokenBalances[2].add(pendingTokenUpdates[0]).add(pendingTokenUpdates[1]).sub(pendingTokenUpdates[2]).sub(pendingTokenUpdates[3]);
}
Internal view function that recovers signer from the sig(s) provided and verifies. Note that, if a one or both signatures is to be not provided, the corresponding sig input param should be a blank string.
function _verifySig (
address[2] user,
uint256[2] weiBalances, // [hub, user]
uint256[2] tokenBalances, // [hub, user]
uint256[4] pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[4] pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
uint256[2] txCount, // [global, onchain] persisted onchain even when empty
bytes32 threadRoot,
uint256 threadCount,
uint256 timeout,
string sigHub,
string sigUser
) internal view {
// prepare state hash to check hub sig
bytes32 state = keccak256(
abi.encodePacked(
address(this),
user, // [user, recipient]
weiBalances, // [hub, user]
tokenBalances, // [hub, user]
pendingWeiUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
pendingTokenUpdates, // [hubDeposit, hubWithdrawal, userDeposit, userWithdrawal]
txCount, // persisted onchain even when empty
threadRoot,
threadCount,
timeout
)
);
if (keccak256(sigUser) != keccak256("")) {
require(user[0] == ECTools.recoverSigner(state, sigUser));
}
if (keccak256(sigHub) != keccak256("")) {
require(hub == ECTools.recoverSigner(state, sigHub));
}
}
Internal view function that recovers signer from the provided sig and verifies.
function _verifyThread(
address user,
address sender,
address receiver,
uint256[2] weiBalances,
uint256[2] tokenBalances,
uint256 txCount,
bytes proof,
string sig,
bytes32 threadRoot
) internal view {
bytes32 state = keccak256(
abi.encodePacked(
address(this),
user,
sender,
receiver,
weiBalances, // [hub, user]
tokenBalances, // [hub, user]
txCount // persisted onchain even when empty
)
);
require(sender == ECTools.recoverSigner(state, sig));
if (threadRoot != bytes32(0x0)) {
require(_isContained(state, proof, threadRoot) == true, "initial thread state is not contained in threadRoot");
}
}
Internal, pure Merkle root inclusion check.
function _isContained(bytes32 _hash, bytes _proof, bytes32 _root) internal pure returns (bool) {
bytes32 cursor = _hash;
bytes32 proofElem;
for (uint256 i = 64; i <= _proof.length; i += 32) {
assembly { proofElem := mload(add(_proof, i)) }
if (cursor < proofElem) {
cursor = keccak256(abi.encodePacked(cursor, proofElem));
} else {
cursor = keccak256(abi.encodePacked(proofElem, cursor));
}
}
return cursor == _root;
}
If the hub == user, the hub/userAuthorizedUpdate
functions would not allow the hub to drain funds or otherwise break proper operation.
- The channel would be looked up by the user, which would fetch the hub's channel with itself.
- The call to
_verifyAuthorizedUpdate
would haveisHub = true
and would expect the hub and user balances to come from the hub's contract reserves, which would be fine. - The call to
_verifySig
would check thesigUser
, which would be expected to be the hub's sig, which would be fine.