Skip to content

Commit 9e8a41f

Browse files
committed
Withdraw helper contract to support collecting funds from channels (#454)
Introduces a contract called GRTWithdrawHelper. The main goal of this contract is to encode custom logic in the process of withdrawing funds from the Vector Channel Multisigs. The contract receives funds from a Channel Multisig, then it forwards them to the Staking contract related to an Allocation. - Add a withdraw helper contract to support collecting funds from channels. - Try catch revert on the Staking contract and return funds if that happens. - Add an explicit parties defined return address and wrap approve call to return funds in case of revert.
1 parent a91f08d commit 9e8a41f

File tree

6 files changed

+323
-3
lines changed

6 files changed

+323
-3
lines changed

cli/defaults.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ export const local = {
99
accountNumber: '0',
1010
}
1111
export const defaultOverrides: Overrides = {
12-
// gasPrice: utils.parseUnits('25', 'gwei'), // auto
13-
// gasLimit: 2000000, // auto
12+
// gasPrice: utils.parseUnits('25', 'gwei'), // auto
13+
// gasLimit: 2000000, // auto
1414
}
1515

1616
export const cliOpts = {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.7.3;
4+
pragma experimental ABIEncoderV2;
5+
6+
import "../token/IGraphToken.sol";
7+
8+
import "./WithdrawHelper.sol";
9+
10+
/**
11+
* @title WithdrawHelper contract for GRT tokens
12+
* @notice This contract encodes the logic that connects the transfer of funds from a
13+
* Channel Multisig to the protocol in the context of a withdrawal.
14+
* A Channel Multisig will atomically transfer the tokens to the WithdrawHelper and then
15+
* these tokens will get pulled from the Staking contract using the `allocationID` passed
16+
* in the `callData` of the WithdrawCommitment.
17+
* Tokens transferred are associated to a particular allocation in the Staking contract.
18+
* This contract is not meant to hold funds, as they can be stolen by presenting a
19+
* handcrafted WithdrawalCommitment.
20+
*/
21+
contract GRTWithdrawHelper is WithdrawHelper {
22+
struct CollectData {
23+
address staking;
24+
address allocationID;
25+
address returnAddress;
26+
}
27+
28+
bytes4 private constant APPROVE_SELECTOR = bytes4(keccak256("approve(address,uint256)"));
29+
bytes4 private constant COLLECT_SELECTOR = bytes4(keccak256("collect(uint256,address)"));
30+
31+
// -- State --
32+
33+
address public immutable tokenAddress;
34+
35+
/**
36+
* @notice Contract constructor.
37+
* @param _tokenAddress Token address to use for transfers
38+
*/
39+
constructor(address _tokenAddress) {
40+
tokenAddress = _tokenAddress;
41+
}
42+
43+
/**
44+
* @notice Returns the ABI encoded representation of a CollectData struct.
45+
* @param _collectData CollectData struct with information about how to collect funds
46+
*/
47+
function getCallData(CollectData calldata _collectData) public pure returns (bytes memory) {
48+
return abi.encode(_collectData);
49+
}
50+
51+
/**
52+
* @notice Execute hook used by a channel to send funds to the protocol.
53+
* @param _wd WithdrawData struct for the withdrawal commitment
54+
* @param _actualAmount Amount to transfer to the Staking contract
55+
*/
56+
function execute(WithdrawData calldata _wd, uint256 _actualAmount) external override {
57+
require(_wd.assetId == tokenAddress, "GRTWithdrawHelper: !token");
58+
59+
// Decode and validate collect data
60+
CollectData memory collectData = abi.decode(_wd.callData, (CollectData));
61+
require(collectData.staking != address(0), "GRTWithdrawHelper: !staking");
62+
require(collectData.allocationID != address(0), "GRTWithdrawHelper: !allocationID");
63+
require(collectData.returnAddress != address(0), "GRTWithdrawHelper: !returnAddress");
64+
65+
// Approve the staking contract to pull the transfer amount
66+
(bool success1, ) =
67+
tokenAddress.call(
68+
abi.encodeWithSelector(APPROVE_SELECTOR, collectData.staking, _actualAmount)
69+
);
70+
71+
// If the call fails return the funds to the return address and bail
72+
if (!success1) {
73+
_sendTokens(collectData.returnAddress, _actualAmount);
74+
return;
75+
}
76+
77+
// Call the Staking contract to collect funds from this contract
78+
(bool success2, ) =
79+
collectData.staking.call(
80+
abi.encodeWithSelector(COLLECT_SELECTOR, _actualAmount, collectData.allocationID)
81+
);
82+
83+
// If the call fails return the funds to the return address
84+
if (!success2) {
85+
_sendTokens(collectData.returnAddress, _actualAmount);
86+
}
87+
}
88+
89+
/**
90+
* @notice Send tokens out of the contract.
91+
* @param _to Destination address
92+
* @param _amount Amount to transfer
93+
*/
94+
function _sendTokens(address _to, uint256 _amount) private {
95+
IGraphToken(tokenAddress).transfer(_to, _amount);
96+
}
97+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
3+
pragma solidity ^0.7.3;
4+
pragma experimental ABIEncoderV2;
5+
6+
struct WithdrawData {
7+
address channelAddress;
8+
address assetId;
9+
address payable recipient;
10+
uint256 amount;
11+
uint256 nonce;
12+
address callTo;
13+
bytes callData;
14+
}
15+
16+
interface ICMCWithdraw {
17+
function getWithdrawalTransactionRecord(WithdrawData calldata wd) external view returns (bool);
18+
19+
function withdraw(
20+
WithdrawData calldata wd,
21+
bytes calldata aliceSignature,
22+
bytes calldata bobSignature
23+
) external;
24+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
3+
pragma solidity ^0.7.3;
4+
pragma experimental ABIEncoderV2;
5+
6+
import "./ICMCWithdraw.sol";
7+
8+
interface WithdrawHelper {
9+
function execute(WithdrawData calldata wd, uint256 actualAmount) external;
10+
}

test/lib/testHelpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import hre from 'hardhat'
22
import { providers, utils, BigNumber, Signer, Wallet } from 'ethers'
3-
import { formatUnits } from 'ethers/lib/utils'
3+
import { formatUnits, getAddress } from 'ethers/lib/utils'
44

55
import { EpochManager } from '../../build/typechain/contracts/EpochManager'
66

@@ -17,6 +17,7 @@ export const logStake = (stakes: any): void => {
1717
console.log(k, ':', parseEther(v as string))
1818
})
1919
}
20+
export const randomAddress = (): string => getAddress(randomHexBytes(20))
2021

2122
// Network
2223

test/payments/withdrawHelper.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { expect } from 'chai'
2+
import { constants } from 'ethers'
3+
4+
import { GRTWithdrawHelper } from '../../build/typechain/contracts/GRTWithdrawHelper'
5+
import { GraphToken } from '../../build/typechain/contracts/GraphToken'
6+
import { Staking } from '../../build/typechain/contracts/Staking'
7+
8+
import { NetworkFixture } from '../lib/fixtures'
9+
import * as deployment from '../lib/deployment'
10+
import {
11+
deriveChannelKey,
12+
getAccounts,
13+
randomAddress,
14+
randomHexBytes,
15+
toGRT,
16+
Account,
17+
} from '../lib/testHelpers'
18+
19+
const { AddressZero } = constants
20+
21+
describe('WithdrawHelper', () => {
22+
let cmc: Account
23+
let governor: Account
24+
let indexer: Account
25+
26+
let fixture: NetworkFixture
27+
28+
let grt: GraphToken
29+
let staking: Staking
30+
let withdrawHelper: GRTWithdrawHelper
31+
32+
function createWithdrawData(callData: string) {
33+
return {
34+
amount: 0,
35+
assetId: grt.address,
36+
callData,
37+
callTo: withdrawHelper.address,
38+
channelAddress: randomAddress(),
39+
nonce: 1,
40+
recipient: withdrawHelper.address,
41+
}
42+
}
43+
44+
before(async function () {
45+
;[cmc, governor, indexer] = await getAccounts()
46+
47+
fixture = new NetworkFixture()
48+
;({ grt, staking } = await fixture.load(governor.signer))
49+
withdrawHelper = ((await deployment.deployContract(
50+
'GRTWithdrawHelper',
51+
governor.signer,
52+
grt.address,
53+
)) as unknown) as GRTWithdrawHelper
54+
55+
// Give some funds to the indexer and approve staking contract to use funds on indexer behalf
56+
const indexerTokens = toGRT('100000')
57+
await grt.connect(governor.signer).mint(indexer.address, indexerTokens)
58+
await grt.connect(indexer.signer).approve(staking.address, indexerTokens)
59+
60+
// Give some funds to the CMC multisig fake account
61+
const cmcTokens = toGRT('2000')
62+
await grt.connect(governor.signer).mint(cmc.address, cmcTokens)
63+
64+
// Allow WithdrawHelper to call the Staking contract
65+
await staking.connect(governor.signer).setAssetHolder(withdrawHelper.address, true)
66+
})
67+
68+
beforeEach(async function () {
69+
await fixture.setUp()
70+
})
71+
72+
afterEach(async function () {
73+
await fixture.tearDown()
74+
})
75+
76+
describe('execute withdrawal', function () {
77+
it('withdraw tokens from the CMC to staking contract through WithdrawHelper', async function () {
78+
// Generate test data
79+
const channelKey = deriveChannelKey()
80+
const allocationID = channelKey.address
81+
const subgraphDeploymentID = randomHexBytes(32)
82+
const metadata = randomHexBytes(32)
83+
84+
// Setup staking
85+
const stakeTokens = toGRT('100000')
86+
await staking.connect(indexer.signer).stake(stakeTokens)
87+
await staking
88+
.connect(indexer.signer)
89+
.allocate(
90+
subgraphDeploymentID,
91+
stakeTokens,
92+
allocationID,
93+
metadata,
94+
await channelKey.generateProof(indexer.address),
95+
)
96+
97+
// Initiate a withdrawal
98+
// For the purpose of the test we skip the CMC and call WithdrawHelper
99+
// directly. Transfer tokens from the CMC -> WithdrawHelper being the recipient
100+
const actualAmount = toGRT('2000') // <- withdraw amount
101+
await grt.connect(cmc.signer).transfer(withdrawHelper.address, actualAmount)
102+
103+
// Simulate callTo from the CMC to the WithdrawHelper
104+
const callData = await withdrawHelper.getCallData({
105+
staking: staking.address,
106+
allocationID,
107+
returnAddress: randomAddress(),
108+
})
109+
const withdrawData = {
110+
amount: 0,
111+
assetId: grt.address,
112+
callData,
113+
callTo: withdrawHelper.address,
114+
channelAddress: cmc.address,
115+
nonce: 1,
116+
recipient: withdrawHelper.address,
117+
}
118+
await withdrawHelper.connect(cmc.signer).execute(withdrawData, actualAmount)
119+
120+
// Allocation must have collected the withdrawn tokens
121+
const allocation = await staking.allocations(allocationID)
122+
expect(allocation.collectedFees).eq(actualAmount)
123+
124+
// CMC should not have more funds
125+
expect(await grt.balanceOf(cmc.address)).eq(0)
126+
})
127+
128+
it('withdraw tokens from the CMC to staking contract through WithdrawHelper (invalid allocation)', async function () {
129+
// Use an invalid allocation
130+
const allocationID = '0xfefefefefefefefefefefefefefefefefefefefe'
131+
const returnAddress = randomAddress()
132+
133+
// Initiate a withdrawal
134+
// For the purpose of the test we skip the CMC and call WithdrawHelper
135+
// directly. Transfer tokens from the CMC -> WithdrawHelper being the recipient
136+
const actualAmount = toGRT('2000') // <- withdraw amount
137+
await grt.connect(cmc.signer).transfer(withdrawHelper.address, actualAmount)
138+
139+
// Simulate callTo from the CMC to the WithdrawHelper
140+
const callData = await withdrawHelper.getCallData({
141+
staking: staking.address,
142+
allocationID,
143+
returnAddress,
144+
})
145+
const withdrawData = {
146+
amount: 0,
147+
assetId: grt.address,
148+
callData,
149+
callTo: withdrawHelper.address,
150+
channelAddress: cmc.address,
151+
nonce: 1,
152+
recipient: withdrawHelper.address,
153+
}
154+
155+
// This reverts!
156+
await withdrawHelper.connect(cmc.signer).execute(withdrawData, actualAmount)
157+
158+
// There should not be collected fees
159+
const allocation = await staking.allocations(allocationID)
160+
expect(allocation.collectedFees).eq(0)
161+
162+
// CMC should have the funds returned
163+
expect(await grt.balanceOf(returnAddress)).eq(actualAmount)
164+
})
165+
166+
it('reject collect data with no staking address', async function () {
167+
// Simulate callTo from the CMC to the WithdrawHelper
168+
const callData = await withdrawHelper.getCallData({
169+
staking: AddressZero,
170+
allocationID: randomAddress(),
171+
returnAddress: randomAddress(),
172+
})
173+
const tx = withdrawHelper.execute(createWithdrawData(callData), toGRT('100'))
174+
await expect(tx).revertedWith('GRTWithdrawHelper: !staking')
175+
})
176+
177+
it('reject collect data with no allocation', async function () {
178+
// Simulate callTo from the CMC to the WithdrawHelper
179+
const callData = await withdrawHelper.getCallData({
180+
staking: randomAddress(),
181+
allocationID: AddressZero,
182+
returnAddress: randomAddress(),
183+
})
184+
const tx = withdrawHelper.execute(createWithdrawData(callData), toGRT('100'))
185+
await expect(tx).revertedWith('GRTWithdrawHelper: !allocationID')
186+
})
187+
})
188+
})

0 commit comments

Comments
 (0)