Skip to content

Commit 54b8b7b

Browse files
Process failed transfers (#71)
1 parent 59d8ca5 commit 54b8b7b

32 files changed

+1150
-158
lines changed

.github/workflows/check-pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
check-pr:
1414
name: Check PR
1515
runs-on: ubuntu-latest
16-
timeout-minutes: 30
16+
timeout-minutes: 45
1717
env:
1818
JAVA_OPTS: -Dfile.encoding=UTF-8
1919
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}

consensus-client-it/build.sbt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,11 @@ libraryDependencies ++= Seq(
2020
).map(_ % Test)
2121

2222
Test / sourceGenerators += Def.task {
23-
val generateSourcesFromContracts = Seq("Bridge", "StandardBridge", "ERC20")
23+
val generateSourcesFromContracts = Seq("Bridge", "StandardBridge", "ERC20", "TERC20")
2424
val contractSources = baseDirectory.value / ".." / "contracts" / "eth"
2525
val compiledDir = contractSources / "target"
2626
// --silent to bypass garbage "Counting objects" git logs
27-
s"forge build --silent --config-path ${contractSources / "foundry.toml"} --contracts " +
28-
s"${contractSources / "src" / "utils" / "TERC20.sol"} " +
29-
s"${contractSources / "src" / "StandardBridge.sol"} " +
30-
s"${contractSources / "src" / "Bridge.sol"} " +
31-
s"${contractSources / "src" / "UnitsMintableERC20.sol"}" !
27+
s"forge build --silent --config-path ${contractSources / "foundry.toml"} --contracts ${contractSources / "src"}" !
3228

3329
generateSourcesFromContracts.foreach { contract =>
3430
val json = Json.parse(new FileInputStream(compiledDir / s"$contract.sol" / s"$contract.json"))
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
package units
2+
3+
import com.wavesplatform.common.utils.EitherExt2.explicitGet
4+
import com.wavesplatform.state.IntegerDataEntry
5+
import com.wavesplatform.transaction.smart.InvokeScriptTransaction
6+
import com.wavesplatform.transaction.{Asset, TxHelpers}
7+
import monix.execution.atomic.AtomicInt
8+
import org.web3j.protocol.core.DefaultBlockParameterName
9+
import org.web3j.protocol.core.methods.response.{EthSendTransaction, TransactionReceipt}
10+
import org.web3j.tx.RawTransactionManager
11+
import org.web3j.tx.gas.DefaultGasProvider
12+
import units.client.contract.{ChainContractClient, ContractBlock}
13+
import units.docker.EcContainer
14+
import units.el.{BridgeMerkleTree, E2CTopics, Erc20Client, TERC20Client}
15+
import units.eth.EthAddress
16+
17+
import scala.annotation.tailrec
18+
import scala.jdk.OptionConverters.RichOptional
19+
20+
class MultipleFailedAssetTransfersTestSuite extends BaseDockerTestSuite {
21+
private val clRecipient = clRichAccount1
22+
private val elSender = elRichAccount1
23+
private val elSenderAddress = elRichAddress1
24+
25+
private val issueAssetDecimals = 8.toByte
26+
private lazy val issueAsset = chainContract.getRegisteredAsset(1) // 0 is WAVES
27+
28+
private val userAmount = BigDecimal("1")
29+
30+
private val gasProvider = new DefaultGasProvider
31+
private lazy val txnManager = new RawTransactionManager(ec1.web3j, elSender, EcContainer.ChainId, 20, 2000)
32+
private lazy val wwaves = new Erc20Client(ec1.web3j, WWavesAddress, txnManager, gasProvider)
33+
private lazy val terc20 = new Erc20Client(ec1.web3j, TErc20Address, txnManager, gasProvider)
34+
private lazy val terc20client = new TERC20Client(ec1.web3j, TErc20Address, txnManager, gasProvider)
35+
36+
private val issuedE2CAmount = UnitsConvert.toAtomic(userAmount * 3, TErc20Decimals)
37+
private val nativeE2CAmount = UnitsConvert.toAtomic(userAmount, NativeTokenElDecimals)
38+
private val burnE2CAmount = UnitsConvert.toAtomic(userAmount * 3, TErc20Decimals)
39+
private val leftoverE2CAmount = UnitsConvert.toAtomic(0, TErc20Decimals)
40+
private val wavesE2CAmount = UnitsConvert.toAtomic(userAmount, WwavesDecimals)
41+
42+
private lazy val currNonce =
43+
AtomicInt(ec1.web3j.ethGetTransactionCount(elSenderAddress.hex, DefaultBlockParameterName.PENDING).send().getTransactionCount.intValueExact())
44+
def nextNonce: Int = currNonce.getAndIncrement()
45+
46+
"Mining continues after 3 failed C2E transfers, 2 consecutive non-last and 1 last" in {
47+
withClue("Reduce Standard Bridge balance to make C2E transfer fail") {
48+
waitForTxn(terc20client.sendBurn(StandardBridgeAddress, burnE2CAmount.bigInteger, nextNonce))
49+
terc20.getBalance(StandardBridgeAddress) shouldBe leftoverE2CAmount
50+
}
51+
52+
step("Initiate C2E transfers")
53+
val c2eRecipientAddress = EthAddress.unsafeFrom("0xAAAA00000000000000000000000000000000AAAA")
54+
55+
val recipientAssetBalanceBeforeC2ETransfer = clRecipientAssetBalance
56+
def mkC2ETransferTxn(asset: Asset, decimals: Byte): InvokeScriptTransaction =
57+
ChainContract.transfer(
58+
clRecipient,
59+
c2eRecipientAddress,
60+
asset,
61+
UnitsConvert.toWavesAtomic(userAmount, decimals)
62+
)
63+
64+
val c2eTransferTxns = List(
65+
mkC2ETransferTxn(Asset.Waves, WavesDecimals),
66+
mkC2ETransferTxn(issueAsset, issueAssetDecimals),
67+
mkC2ETransferTxn(issueAsset, issueAssetDecimals),
68+
mkC2ETransferTxn(chainContract.nativeTokenId, NativeTokenClDecimals),
69+
mkC2ETransferTxn(issueAsset, issueAssetDecimals)
70+
)
71+
c2eTransferTxns.foreach(waves1.api.broadcast)
72+
c2eTransferTxns.map(txn => waves1.api.waitForSucceeded(txn.id()))
73+
74+
eventually {
75+
withClue("Issued asset: the sender balance has been reduced even though the transfer has failed") {
76+
val balanceAfter = clRecipientAssetBalance
77+
balanceAfter shouldBe (recipientAssetBalanceBeforeC2ETransfer - UnitsConvert.toWavesAtomic(userAmount * 3, issueAssetDecimals))
78+
}
79+
80+
withClue("Issued asset: the transfer has failed") {
81+
terc20.getBalance(c2eRecipientAddress) shouldBe leftoverE2CAmount
82+
}
83+
84+
withClue("Native token: the other transfers have succeeded") {
85+
val balanceAfter = ec1.web3j.ethGetBalance(c2eRecipientAddress.hex, DefaultBlockParameterName.PENDING).send().getBalance
86+
BigInt(balanceAfter) shouldBe nativeE2CAmount
87+
}
88+
89+
withClue("WAVES: the other transfers have succeeded") {
90+
wwaves.getBalance(c2eRecipientAddress) shouldBe wavesE2CAmount
91+
}
92+
}
93+
94+
step("Mining continues")
95+
val clHeightAfterTransfers = waves1.api.height()
96+
val elHeightAfterTransfers = ec1.web3j.ethBlockNumber().send().getBlockNumber.longValueExact()
97+
98+
withClue("CL height grows") {
99+
waves1.api.waitForHeight(clHeightAfterTransfers + 2)
100+
}
101+
withClue("EL height grows") {
102+
chainContract.waitForHeight(elHeightAfterTransfers + 2)
103+
}
104+
105+
step("Sender can get their funds back from a failed transfer using a chain contract method")
106+
val failedTransferIndexes = List(1L, 2L, 4L)
107+
val expectedFailedTransfersRoot = BridgeMerkleTree.getFailedTransfersRootHash(failedTransferIndexes)
108+
109+
@tailrec
110+
def loop(cb: ContractBlock): ContractBlock =
111+
if (java.util.Arrays.equals(cb.failedC2ETransfersRootHash, expectedFailedTransfersRoot)) cb
112+
else
113+
chainContract.getBlock(cb.parentHash) match {
114+
case Some(parent) => loop(parent)
115+
case None => fail("Failed to locate block with failed transfer data")
116+
}
117+
118+
val blockWithFailedTransfers = loop(chainContract.getLastBlockMeta(ChainContractClient.DefaultMainChainId).value)
119+
120+
withClue("Refund for transfer 1") {
121+
val failedTransferIndex = 1L
122+
val failedTransferIndexInBlock = 0
123+
val balanceBeforeRefund = clRecipientAssetBalance
124+
val refundAmount = UnitsConvert.toWavesAtomic(userAmount, issueAssetDecimals)
125+
val failedTransferProof = BridgeMerkleTree
126+
.mkFailedTransferProofs(failedTransferIndexes, transferIndex = failedTransferIndexInBlock)
127+
.reverse
128+
129+
val refundInvoke = ChainContract.refundFailedC2ETransfer(
130+
sender = clRecipient,
131+
blockHash = blockWithFailedTransfers.hash,
132+
merkleProof = failedTransferProof,
133+
failedTransferIndex = failedTransferIndex,
134+
transferIndexInBlock = failedTransferIndexInBlock
135+
)
136+
waves1.api.broadcastAndWait(refundInvoke)
137+
138+
withClue("Issued asset: balance after refund increased by the returned funds") {
139+
eventually {
140+
clRecipientAssetBalance shouldBe (balanceBeforeRefund + refundAmount)
141+
}
142+
}
143+
}
144+
145+
withClue("Refund for transfer 2") {
146+
val failedTransferIndex = 2L
147+
val failedTransferIndexInBlock = 1
148+
val balanceBeforeRefund = clRecipientAssetBalance
149+
val refundAmount = UnitsConvert.toWavesAtomic(userAmount, issueAssetDecimals)
150+
val failedTransferProof = BridgeMerkleTree
151+
.mkFailedTransferProofs(failedTransferIndexes, transferIndex = failedTransferIndexInBlock)
152+
.reverse
153+
154+
val refundInvoke = ChainContract.refundFailedC2ETransfer(
155+
sender = clRecipient,
156+
blockHash = blockWithFailedTransfers.hash,
157+
merkleProof = failedTransferProof,
158+
failedTransferIndex = failedTransferIndex,
159+
transferIndexInBlock = failedTransferIndexInBlock
160+
)
161+
waves1.api.broadcastAndWait(refundInvoke)
162+
163+
withClue("Issued asset: balance after refund increased by the returned funds") {
164+
eventually {
165+
clRecipientAssetBalance shouldBe (balanceBeforeRefund + refundAmount)
166+
}
167+
}
168+
}
169+
170+
withClue("Refund for transfer 4") {
171+
val failedTransferIndex = 4L
172+
val failedTransferIndexInBlock = 2
173+
val balanceBeforeRefund = clRecipientAssetBalance
174+
val refundAmount = UnitsConvert.toWavesAtomic(userAmount, issueAssetDecimals)
175+
val failedTransferProof = BridgeMerkleTree
176+
.mkFailedTransferProofs(failedTransferIndexes, transferIndex = failedTransferIndexInBlock)
177+
.reverse
178+
179+
val refundInvoke = ChainContract.refundFailedC2ETransfer(
180+
sender = clRecipient,
181+
blockHash = blockWithFailedTransfers.hash,
182+
merkleProof = failedTransferProof,
183+
failedTransferIndex = failedTransferIndex,
184+
transferIndexInBlock = failedTransferIndexInBlock
185+
)
186+
waves1.api.broadcastAndWait(refundInvoke)
187+
188+
withClue("Issued asset: balance after refund increased by the returned funds") {
189+
eventually {
190+
clRecipientAssetBalance shouldBe (balanceBeforeRefund + refundAmount)
191+
}
192+
}
193+
}
194+
195+
}
196+
197+
private def clRecipientAssetBalance: Long = waves1.api.balance(clRecipient.toAddress, issueAsset)
198+
199+
private def waitForTxn(txnResult: EthSendTransaction): TransactionReceipt = eventually {
200+
ec1.web3j.ethGetTransactionReceipt(txnResult.getTransactionHash).send().getTransactionReceipt.toScala.value
201+
}
202+
203+
override def beforeAll(): Unit = {
204+
super.beforeAll()
205+
deploySolidityContracts()
206+
207+
step("Enable token transfers")
208+
val activationEpoch = waves1.api.height() + 1
209+
waves1.api.broadcastAndWait(
210+
ChainContract.enableTokenTransfersWithWaves(
211+
StandardBridgeAddress,
212+
WWavesAddress,
213+
activationEpoch = activationEpoch
214+
)
215+
)
216+
217+
step("Set strict C2E transfers feature activation epoch")
218+
waves1.api.broadcastAndWait(
219+
TxHelpers.dataEntry(
220+
chainContractAccount,
221+
IntegerDataEntry("strictC2ETransfersActivationEpoch", activationEpoch.toInt)
222+
)
223+
)
224+
225+
step("Wait for features activation")
226+
waves1.api.waitForHeight(activationEpoch)
227+
228+
step("Register asset")
229+
waves1.api.broadcastAndWait(ChainContract.issueAndRegister(TErc20Address, TErc20Decimals, "TERC20", "Test ERC20 token", issueAssetDecimals))
230+
eventually {
231+
standardBridge.isRegistered(TErc20Address, ignoreExceptions = true) shouldBe true
232+
}
233+
234+
step("Send allowances")
235+
List(
236+
terc20.sendApprove(StandardBridgeAddress, issuedE2CAmount, nextNonce),
237+
wwaves.sendApprove(StandardBridgeAddress, wavesE2CAmount, nextNonce)
238+
).foreach(waitFor)
239+
240+
step("Initiate E2C transfers")
241+
val e2cNativeTxn = nativeBridge.sendSendNative(elSender, clRecipient.toAddress, nativeE2CAmount, nextNonce)
242+
val e2cIssuedTxn = standardBridge.sendBridgeErc20(elSender, TErc20Address, clRecipient.toAddress, issuedE2CAmount, nextNonce)
243+
val e2cWavesTxn = standardBridge.sendBridgeErc20(elSender, WWavesAddress, clRecipient.toAddress, wavesE2CAmount, nextNonce)
244+
245+
chainContract.waitForEpoch(waves1.api.height() + 1) // Bypass rollbacks
246+
val e2cReceipts = List(e2cNativeTxn, e2cIssuedTxn, e2cWavesTxn).map { txn =>
247+
eventually {
248+
val hash = txn.getTransactionHash
249+
withClue(s"$hash: ") {
250+
ec1.web3j.ethGetTransactionReceipt(hash).send().getTransactionReceipt.toScala.value
251+
}
252+
}
253+
}
254+
255+
withClue("E2C should be on same height, can't continue the test: ") {
256+
val e2cHeights = e2cReceipts.map(_.getBlockNumber.intValueExact()).toSet
257+
e2cHeights.size shouldBe 1
258+
}
259+
260+
val e2cBlockHash = BlockHash(e2cReceipts.head.getBlockHash)
261+
log.debug(s"Block with e2c transfers: $e2cBlockHash")
262+
263+
val e2cLogsInBlock = ec1.engineApi
264+
.getLogs(e2cBlockHash, List(NativeBridgeAddress, StandardBridgeAddress))
265+
.explicitGet()
266+
.filter(_.topics.intersect(E2CTopics).nonEmpty)
267+
268+
withClue("We have logs for all transactions: ") {
269+
e2cLogsInBlock.size shouldBe e2cReceipts.size
270+
}
271+
272+
step(s"Wait block $e2cBlockHash with transfers on contract")
273+
val e2cBlockConfirmationHeight = eventually {
274+
chainContract.getBlock(e2cBlockHash).value.height
275+
}
276+
277+
step(s"Wait for block $e2cBlockHash ($e2cBlockConfirmationHeight) finalization")
278+
eventually {
279+
val currFinalizedHeight = chainContract.getFinalizedBlock.height
280+
step(s"Current finalized height: $currFinalizedHeight")
281+
currFinalizedHeight should be >= e2cBlockConfirmationHeight
282+
}
283+
284+
step("Broadcast withdrawAsset transactions")
285+
val recipientAssetBalanceBefore = clRecipientAssetBalance
286+
287+
def mkE2CWithdrawTxn(transferIndex: Int, asset: Asset, amount: BigDecimal, decimals: Byte): InvokeScriptTransaction =
288+
ChainContract.withdrawAsset(
289+
sender = clRecipient,
290+
blockHash = e2cBlockHash,
291+
merkleProof = BridgeMerkleTree.mkTransferProofs(e2cLogsInBlock, transferIndex).explicitGet().reverse,
292+
transferIndexInBlock = transferIndex,
293+
amount = UnitsConvert.toWavesAtomic(amount, decimals),
294+
asset = asset
295+
)
296+
297+
val e2cWithdrawTxns = List(
298+
mkE2CWithdrawTxn(0, chainContract.nativeTokenId, userAmount, NativeTokenClDecimals),
299+
mkE2CWithdrawTxn(1, issueAsset, userAmount * 3, issueAssetDecimals),
300+
mkE2CWithdrawTxn(2, Asset.Waves, userAmount, WavesDecimals)
301+
)
302+
303+
e2cWithdrawTxns.foreach(waves1.api.broadcast)
304+
e2cWithdrawTxns.foreach(txn => waves1.api.waitForSucceeded(txn.id()))
305+
306+
withClue("Assets received after E2C: ") {
307+
withClue("Issued asset: the balance was initially sufficient on CL") {
308+
val balanceAfter = clRecipientAssetBalance
309+
balanceAfter shouldBe (recipientAssetBalanceBefore + UnitsConvert.toWavesAtomic(userAmount * 3, issueAssetDecimals))
310+
}
311+
withClue("Issued asset: the StandardBridge balance was initially sufficient on EL") {
312+
terc20.getBalance(StandardBridgeAddress) shouldBe issuedE2CAmount
313+
}
314+
}
315+
}
316+
}

0 commit comments

Comments
 (0)