Skip to content

Commit 89141df

Browse files
committed
test(integration): cover staging, quorum and foreclosure
1 parent 7342d84 commit 89141df

27 files changed

Lines changed: 5784 additions & 39 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ machine-snapshot/**
1515
/cartesi-rollups-claimer
1616
/cartesi-rollups-jsonrpc-api
1717
/cartesi-rollups-prt
18+
/cartesi-rollups-machine-tool
1819
/rollups-contracts
1920
/rollups-prt-contracts
2021
/applications

Makefile

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ DOCKER_PLATFORM=--platform $(BUILD_PLATFORM)
5959
endif
6060

6161
# Go artifacts
62-
GO_ARTIFACTS := $(addprefix cartesi-rollups-,node cli evm-reader advancer validator claimer jsonrpc-api prt)
62+
GO_ARTIFACTS := $(addprefix cartesi-rollups-,node cli evm-reader advancer validator claimer jsonrpc-api prt machine-tool)
6363

6464
# fixme(vfusco): path on all oses
6565
CGO_CFLAGS:= -I$(PREFIX)/include
@@ -129,6 +129,8 @@ env:
129129
@echo export CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS="0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483"
130130
@echo export CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS="0x6145C5996a71a379E030aEb0440df79D60833418"
131131
@echo export CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS="0x33FFf0b681c90664dD048a60400AE2D827a4c5bb"
132+
@echo export CARTESI_DEVNET_ERC20_PORTAL_ADDRESS="0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1"
133+
@echo export CARTESI_DEVNET_TEST_ERC20_ADDRESS="0x88A2120B7068E78692C8fd12E751d610B6377E4d"
132134
@echo export CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS="0x0745787835A019cd4dae8EDB541Fbc0647793d63"
133135
@echo export CARTESI_AUTH_MNEMONIC=\"test test test test test test test test test test test junk\"
134136
@echo export CARTESI_DATABASE_CONNECTION="postgres://postgres:password@localhost:5432/rollupsdb?sslmode=disable"
@@ -295,6 +297,8 @@ reject-loop-dapp: applications/reject-loop-dapp ## Reject loop dapp
295297

296298
exception-loop-dapp: applications/exception-loop-dapp ## Exception loop dapp
297299

300+
erc20-withdrawal-dapp: applications/erc20-withdrawal-dapp ## ERC-20 withdrawal test dapp
301+
298302
applications/reject-loop-dapp: ## Create reject-loop-dapp test application
299303
@echo "Creating reject-loop-dapp test application"
300304
@mkdir -p applications
@@ -305,6 +309,18 @@ applications/exception-loop-dapp: ## Create exception-loop-dapp test application
305309
@mkdir -p applications
306310
@cartesi-machine --ram-length=128Mi --store=applications/exception-loop-dapp --final-hash -- ioctl-echo-loop --vouchers=1 --notices=1 --reports=1 --exception=1 --verbose=1
307311

312+
applications/erc20-withdrawal-dapp: test/dapps/erc20-withdrawal/install.sh ## Create ERC-20 withdrawal test application
313+
@echo "Creating ERC-20 withdrawal test application"
314+
@mkdir -p applications
315+
@PORTAL=$${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1}; \
316+
TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x88A2120B7068E78692C8fd12E751d610B6377E4d}; \
317+
cartesi-machine --ram-length=128Mi \
318+
--flash-drive=label:accounts,length:4Mi,mke2fs:false,mount:false,user:dapp \
319+
--env=TRUSTED_ERC20_PORTAL=$$PORTAL \
320+
--env=TRUSTED_ERC20_TOKEN=$$TOKEN \
321+
--append-init-file=test/dapps/erc20-withdrawal/install.sh \
322+
--store=applications/erc20-withdrawal-dapp --final-hash -- /usr/local/bin/erc20-withdrawal-dapp
323+
308324
deploy-echo-dapp: applications/echo-dapp ## Deploy echo-dapp test application
309325
@echo "Deploying echo-dapp test application"
310326
@./cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/
@@ -321,6 +337,46 @@ deploy-prt-echo-dapp: applications/echo-dapp ## Deploy echo-dapp test applicatio
321337
@echo "Deploying echo-dapp test application"
322338
@./cartesi-rollups-cli deploy application prt-echo-dapp applications/echo-dapp/ --prt
323339

340+
deploy-erc20-withdrawal-dapp: applications/erc20-withdrawal-dapp ## Deploy ERC-20 withdrawal test application
341+
@set -e; \
342+
APP=$${APP:-erc20-withdrawal-dapp}; \
343+
GUARDIAN=$${GUARDIAN:-0x70997970C51812dc3A010C7d01b50e0d17dc79C8}; \
344+
BUILDER=$${CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS:-0x0745787835A019cd4dae8EDB541Fbc0647793d63}; \
345+
DRIVE_START_INDEX=$$(jq -r '.config.flash_drive[] | select(.length == 4194304) | (.start / 4194304 | floor)' \
346+
applications/erc20-withdrawal-dapp/config.json); \
347+
WITHDRAWAL_CONFIG=$$(jq -cn \
348+
--arg guardian "$$GUARDIAN" \
349+
--arg builder "$$BUILDER" \
350+
--argjson drive "$$DRIVE_START_INDEX" \
351+
'{guardian:$$guardian,log2_leaves_per_account:0,log2_max_num_of_accounts:17,accounts_drive_start_index:$$drive,withdrawal_output_builder:$$builder}'); \
352+
echo "Deploying $$APP with accounts-drive start index $$DRIVE_START_INDEX"; \
353+
./cartesi-rollups-cli deploy application "$$APP" applications/erc20-withdrawal-dapp \
354+
--salt "$$(openssl rand -hex 32)" \
355+
--withdrawal-config "$$WITHDRAWAL_CONFIG" \
356+
--enable=false; \
357+
./cartesi-rollups-cli app execution-parameters set "$$APP" snapshot_policy EVERY_EPOCH; \
358+
./cartesi-rollups-cli app status "$$APP" enabled --yes
359+
360+
fund-wallet: ## Fund the default Anvil wallet with ETH and test ERC-20
361+
@set -e; \
362+
RPC_URL=$${CARTESI_BLOCKCHAIN_HTTP_ENDPOINT:-http://localhost:8545}; \
363+
TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x88A2120B7068E78692C8fd12E751d610B6377E4d}; \
364+
WALLET=$${WALLET:-$$(cast rpc --rpc-url "$$RPC_URL" eth_accounts | jq -r '.[0]')}; \
365+
ETH_WEI=$${ETH_WEI:-0x8ac7230489e80000}; \
366+
TOKEN_AMOUNT=$${TOKEN_AMOUNT:-1000000}; \
367+
echo "Funding $$WALLET with $$ETH_WEI wei"; \
368+
cast rpc --rpc-url "$$RPC_URL" anvil_setBalance "$$WALLET" "$$ETH_WEI" >/dev/null; \
369+
echo "Minting $$TOKEN_AMOUNT test ERC-20 units to $$WALLET"; \
370+
cast send --rpc-url "$$RPC_URL" --from "$$WALLET" --unlocked "$$TOKEN" "mint(uint256)" "$$TOKEN_AMOUNT" >/dev/null
371+
372+
withdraw-wallet: cartesi-rollups-cli ## Send a test withdrawal request from the current signer
373+
@set -e; \
374+
APP=$${APP:-erc20-withdrawal-dapp}; \
375+
AMOUNT=$${AMOUNT:-25}; \
376+
PAYLOAD=$$(printf '0x01%016x' "$$AMOUNT"); \
377+
echo "Sending withdrawal request to $$APP: amount=$$AMOUNT payload=$$PAYLOAD"; \
378+
./cartesi-rollups-cli send "$$APP" "$$PAYLOAD" --hex --yes --json
379+
324380
# Temporary test dependencies target while we are not using distribution packages
325381
DOWNLOADS_DIR = test/downloads
326382
CARTESI_TEST_MACHINE_IMAGES = $(DOWNLOADS_DIR)/linux.bin
@@ -449,7 +505,7 @@ test-with-compose: ## Run all tests using docker compose with auto-shutdown
449505
@$(MAKE) unit-test-with-compose
450506
@$(MAKE) integration-test-with-compose
451507

452-
integration-test-local: build echo-dapp reject-loop-dapp exception-loop-dapp ## Run integration tests locally (requires: make start && eval $$(make env))
508+
integration-test-local: build cartesi-rollups-machine-tool echo-dapp reject-loop-dapp exception-loop-dapp erc20-withdrawal-dapp ## Run integration tests locally (requires: make start && eval $$(make env))
453509
@cartesi-rollups-cli db init
454510
@if lsof -ti:10000 >/dev/null 2>&1; then \
455511
echo "Killing stale node on port 10000..."; \
@@ -459,6 +515,7 @@ integration-test-local: build echo-dapp reject-loop-dapp exception-loop-dapp ##
459515
@export CARTESI_TEST_DAPP_PATH=$(CURDIR)/applications/echo-dapp; \
460516
export CARTESI_TEST_REJECT_DAPP_PATH=$(CURDIR)/applications/reject-loop-dapp; \
461517
export CARTESI_TEST_EXCEPTION_DAPP_PATH=$(CURDIR)/applications/exception-loop-dapp; \
518+
export CARTESI_TEST_ERC20_WITHDRAWAL_DAPP_PATH=$(CURDIR)/applications/erc20-withdrawal-dapp; \
462519
$(MAKE) integration-test
463520

464521
deploy-load-test-apps: applications/echo-dapp ## Deploy 3 echo-dapp instances for load testing
@@ -516,7 +573,7 @@ build-debian-package: install
516573
dpkg-deb -Zxz --root-owner-group --build $(DESTDIR) $(DEB_FILENAME)
517574

518575
.PHONY: \
519-
build build-go $(GO_ARTIFACTS) \
576+
build build-go $(GO_ARTIFACTS) cartesi-rollups-machine-tool \
520577
clean clean-go clean-contracts clean-docs clean-devnet-files clean-dapps clean-test-dependencies clean-debian-packages \
521578
test unit-test unit-test-with-compose integration-test integration-test-with-compose integration-test-local test-with-compose ci-test coverage-report \
522579
generate generate-contracts generate-config generate-inspect check-generate generate-db \
@@ -525,4 +582,5 @@ build-debian-package: install
525582
devnet image tester-image debian-packager run-with-compose shutdown-compose \
526583
start start-devnet start-postgres stop stop-devnet stop-postgres restart restart-devnet restart-postgres \
527584
install copy-debian-package build-debian-package \
585+
deploy-erc20-withdrawal-dapp fund-wallet withdraw-wallet \
528586
env help version
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// (c) Cartesi and individual authors (see AUTHORS)
2+
// SPDX-License-Identifier: Apache-2.0 (see LICENSE)
3+
4+
package accountdrive
5+
6+
import (
7+
"bytes"
8+
"encoding/binary"
9+
"errors"
10+
"fmt"
11+
"math"
12+
13+
"github.com/ethereum/go-ethereum/common"
14+
"github.com/ethereum/go-ethereum/crypto"
15+
)
16+
17+
const (
18+
Log2AccountSize = 5
19+
AccountSize = 1 << Log2AccountSize
20+
DefaultLog2MaxAccount = 17
21+
)
22+
23+
var (
24+
ErrUnsupportedLayout = errors.New("unsupported accounts-drive layout")
25+
ErrAccountNotFound = errors.New("account not found")
26+
)
27+
28+
type Proof struct {
29+
Account [AccountSize]byte
30+
AccountIndex uint64
31+
AccountRoot common.Hash
32+
DriveRoot common.Hash
33+
Siblings []common.Hash
34+
}
35+
36+
func DriveSize(log2MaxNumOfAccounts uint8, log2LeavesPerAccount uint8) (uint64, error) {
37+
if log2LeavesPerAccount != 0 {
38+
return 0, fmt.Errorf("%w: log2_leaves_per_account=%d", ErrUnsupportedLayout, log2LeavesPerAccount)
39+
}
40+
if log2MaxNumOfAccounts >= 58 { // 2^(5+58) still fits in uint64; keep a margin for int conversion.
41+
return 0, fmt.Errorf("log2_max_num_of_accounts %d is too large", log2MaxNumOfAccounts)
42+
}
43+
return 1 << (Log2AccountSize + log2MaxNumOfAccounts), nil
44+
}
45+
46+
func Encode(address common.Address, balance uint64) ([AccountSize]byte, error) {
47+
var account [AccountSize]byte
48+
if address == (common.Address{}) {
49+
return account, errors.New("account address must not be zero")
50+
}
51+
if balance == 0 {
52+
return account, errors.New("account balance must be positive")
53+
}
54+
if balance > math.MaxInt64 {
55+
return account, fmt.Errorf("account balance %d exceeds int64 accounts-drive limit", balance)
56+
}
57+
binary.LittleEndian.PutUint64(account[:8], balance)
58+
copy(account[8:28], address.Bytes())
59+
return account, nil
60+
}
61+
62+
func Decode(account []byte) (common.Address, uint64, bool, error) {
63+
var zero [AccountSize]byte
64+
if len(account) != AccountSize {
65+
return common.Address{}, 0, false, fmt.Errorf("account record must be %d bytes, got %d", AccountSize, len(account))
66+
}
67+
if bytes.Equal(account, zero[:]) {
68+
return common.Address{}, 0, false, nil
69+
}
70+
balance := binary.LittleEndian.Uint64(account[:8])
71+
if balance == 0 {
72+
return common.Address{}, 0, false, errors.New("non-empty account has zero balance")
73+
}
74+
if balance > math.MaxInt64 {
75+
return common.Address{}, 0, false, fmt.Errorf("account balance %d exceeds int64 accounts-drive limit", balance)
76+
}
77+
address := common.BytesToAddress(account[8:28])
78+
if address == (common.Address{}) {
79+
return common.Address{}, 0, false, errors.New("non-empty account has zero address")
80+
}
81+
if !bytes.Equal(account[28:32], []byte{0, 0, 0, 0}) {
82+
return common.Address{}, 0, false, errors.New("non-empty account has non-zero padding")
83+
}
84+
return address, balance, true, nil
85+
}
86+
87+
func BuildProof(
88+
drive []byte,
89+
address common.Address,
90+
log2MaxNumOfAccounts uint8,
91+
log2LeavesPerAccount uint8,
92+
) (*Proof, error) {
93+
if address == (common.Address{}) {
94+
return nil, errors.New("account address must not be zero")
95+
}
96+
driveSize, err := DriveSize(log2MaxNumOfAccounts, log2LeavesPerAccount)
97+
if err != nil {
98+
return nil, err
99+
}
100+
if uint64(len(drive)) > driveSize {
101+
return nil, fmt.Errorf("accounts drive is too large: got %d bytes, want at most %d", len(drive), driveSize)
102+
}
103+
104+
leafCount := 1 << log2MaxNumOfAccounts
105+
leaves := make([]common.Hash, leafCount)
106+
foundIndex := uint64(math.MaxUint64)
107+
var foundAccount [AccountSize]byte
108+
seenEnd := false
109+
110+
for i := range leaves {
111+
var account [AccountSize]byte
112+
offset := i * AccountSize
113+
if offset < len(drive) {
114+
copy(account[:], drive[offset:min(offset+AccountSize, len(drive))])
115+
}
116+
117+
decodedAddress, _, nonEmpty, err := Decode(account[:])
118+
if err != nil {
119+
return nil, fmt.Errorf("decode account %d: %w", i, err)
120+
}
121+
if nonEmpty {
122+
if seenEnd {
123+
return nil, fmt.Errorf("account %d appears after the first empty slot", i)
124+
}
125+
if decodedAddress == address {
126+
foundIndex = uint64(i)
127+
foundAccount = account
128+
}
129+
} else {
130+
seenEnd = true
131+
}
132+
leaves[i] = crypto.Keccak256Hash(account[:])
133+
}
134+
if foundIndex == math.MaxUint64 {
135+
return nil, fmt.Errorf("%w: %s", ErrAccountNotFound, address)
136+
}
137+
138+
accountRoot := leaves[foundIndex]
139+
siblings := make([]common.Hash, log2MaxNumOfAccounts)
140+
nodeIndex := foundIndex
141+
level := leaves
142+
for height := range int(log2MaxNumOfAccounts) {
143+
siblings[height] = level[nodeIndex^1]
144+
parents := make([]common.Hash, len(level)/2)
145+
for i := 0; i < len(parents); i++ {
146+
parents[i] = crypto.Keccak256Hash(level[2*i].Bytes(), level[2*i+1].Bytes())
147+
}
148+
nodeIndex >>= 1
149+
level = parents
150+
}
151+
152+
return &Proof{
153+
Account: foundAccount,
154+
AccountIndex: foundIndex,
155+
AccountRoot: accountRoot,
156+
DriveRoot: level[0],
157+
Siblings: siblings,
158+
}, nil
159+
}
160+
161+
func RootFromProof(accountRoot common.Hash, accountIndex uint64, siblings []common.Hash) common.Hash {
162+
root := accountRoot
163+
for height, sibling := range siblings {
164+
if (accountIndex>>height)&1 == 0 {
165+
root = crypto.Keccak256Hash(root.Bytes(), sibling.Bytes())
166+
} else {
167+
root = crypto.Keccak256Hash(sibling.Bytes(), root.Bytes())
168+
}
169+
}
170+
return root
171+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// (c) Cartesi and individual authors (see AUTHORS)
2+
// SPDX-License-Identifier: Apache-2.0 (see LICENSE)
3+
4+
package accountdrive
5+
6+
import (
7+
"encoding/hex"
8+
"errors"
9+
"testing"
10+
11+
"github.com/ethereum/go-ethereum/common"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestEncode_MatchesEwtoolsLayout(t *testing.T) {
16+
account, err := Encode(common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), 7)
17+
require.NoError(t, err)
18+
19+
require.Equal(t,
20+
"0700000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb00000000",
21+
hex.EncodeToString(account[:]),
22+
)
23+
}
24+
25+
func TestDecode_RejectsCorruptRecords(t *testing.T) {
26+
t.Run("zero record is empty", func(t *testing.T) {
27+
var zero [AccountSize]byte
28+
_, _, ok, err := Decode(zero[:])
29+
require.NoError(t, err)
30+
require.False(t, ok)
31+
})
32+
33+
t.Run("non-zero padding is invalid", func(t *testing.T) {
34+
account, err := Encode(common.HexToAddress("0x1111111111111111111111111111111111111111"), 1)
35+
require.NoError(t, err)
36+
account[31] = 1
37+
38+
_, _, _, err = Decode(account[:])
39+
require.ErrorContains(t, err, "padding")
40+
})
41+
}
42+
43+
func TestBuildProof_BuildsVerifiableAccountProof(t *testing.T) {
44+
addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111")
45+
addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222")
46+
account1, err := Encode(addr1, 10)
47+
require.NoError(t, err)
48+
account2, err := Encode(addr2, 20)
49+
require.NoError(t, err)
50+
51+
drive := make([]byte, 1<<(Log2AccountSize+DefaultLog2MaxAccount))
52+
copy(drive[0:AccountSize], account1[:])
53+
copy(drive[AccountSize:2*AccountSize], account2[:])
54+
55+
proof, err := BuildProof(drive, addr2, DefaultLog2MaxAccount, 0)
56+
require.NoError(t, err)
57+
58+
require.Equal(t, uint64(1), proof.AccountIndex)
59+
require.Equal(t, account2, proof.Account)
60+
require.Len(t, proof.Siblings, DefaultLog2MaxAccount)
61+
require.Equal(t, proof.DriveRoot, RootFromProof(proof.AccountRoot, proof.AccountIndex, proof.Siblings))
62+
}
63+
64+
func TestBuildProof_ReturnsClearErrorForMissingAccount(t *testing.T) {
65+
drive := make([]byte, 1<<(Log2AccountSize+DefaultLog2MaxAccount))
66+
_, err := BuildProof(drive, common.HexToAddress("0x3333333333333333333333333333333333333333"), DefaultLog2MaxAccount, 0)
67+
require.ErrorIs(t, err, ErrAccountNotFound)
68+
}
69+
70+
func TestBuildProof_RejectsUnsupportedLayout(t *testing.T) {
71+
_, err := BuildProof(nil, common.HexToAddress("0x1111111111111111111111111111111111111111"), DefaultLog2MaxAccount, 1)
72+
require.ErrorIs(t, err, ErrUnsupportedLayout)
73+
}
74+
75+
func TestBuildProof_RejectsNonCompactAccountTable(t *testing.T) {
76+
addr := common.HexToAddress("0x1111111111111111111111111111111111111111")
77+
account, err := Encode(addr, 10)
78+
require.NoError(t, err)
79+
drive := make([]byte, 3*AccountSize)
80+
copy(drive[2*AccountSize:3*AccountSize], account[:])
81+
82+
_, err = BuildProof(drive, addr, 2, 0)
83+
require.Error(t, err)
84+
require.False(t, errors.Is(err, ErrAccountNotFound))
85+
require.ErrorContains(t, err, "after the first empty slot")
86+
}

0 commit comments

Comments
 (0)