From c253779ce1205594a95bc0793bc5f0cd0c96ffbf Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:14 -0300 Subject: [PATCH 01/41] build(contracts): bump to the new v3 alpha Use rollups-contracts v3.0.0-alpha.10 and Dave v3.0.0-alpha.4. Pin the official artifact names and SHA-256 checksums. Key extraction stamps by version and checksum so a previous extraction cannot silently satisfy the new dependency. This commit changes artifact selection only; devnet and generated bindings follow separately. --- Makefile | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 5ee243aae..847d6dba1 100644 --- a/Makefile +++ b/Makefile @@ -18,14 +18,14 @@ TARGET_OS?=$(shell uname) export TARGET_OS ROLLUPS_NODE_VERSION := 2.0.0-alpha.12 -ROLLUPS_CONTRACTS_VERSION := 3.0.0-alpha.6 +ROLLUPS_CONTRACTS_VERSION := 3.0.0-alpha.10 ROLLUPS_CONTRACTS_URL:=https://github.com/cartesi/rollups-contracts/releases/download/ -ROLLUPS_CONTRACTS_ARTIFACT:=rollups-contracts-$(ROLLUPS_CONTRACTS_VERSION)-artifacts.tar.gz -ROLLUPS_CONTRACTS_SHA256:=ad1e0880766d25419fc6da1858ea4e7b9074b400e9d9ef68da88b12f4a8bba45 -ROLLUPS_PRT_CONTRACTS_VERSION := 3.0.0-alpha.3 +ROLLUPS_CONTRACTS_ARTIFACT:=cartesi-rollups-contracts-$(ROLLUPS_CONTRACTS_VERSION)-artifacts.tar.gz +ROLLUPS_CONTRACTS_SHA256:=5213ce59d0f5a1c4fef4ebf17b6ef999be709c32b4b94511c320729bb2afa959 +ROLLUPS_PRT_CONTRACTS_VERSION := 3.0.0-alpha.4 ROLLUPS_PRT_CONTRACTS_URL:=https://github.com/cartesi/dave/releases/download/ ROLLUPS_PRT_CONTRACTS_ARTIFACT:=cartesi-rollups-prt-$(ROLLUPS_PRT_CONTRACTS_VERSION)-contract-artifacts.tar.gz -ROLLUPS_PRT_CONTRACTS_SHA256:=240f4934df7a313dc05a4ae6cc3eee97b5c146952c4218502fec0db83f36a5a5 +ROLLUPS_PRT_CONTRACTS_SHA256:=622964166b4049b556dc20b26ef2b9a5e8621a2ad3e1f43eee0b802a085244b3 IMAGE_TAG ?= devel @@ -123,6 +123,8 @@ endif ROLLUPS_CONTRACTS_ABI_BASEDIR:= rollups-contracts/ ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR:= rollups-prt-contracts/ +ROLLUPS_CONTRACTS_ABI_STAMP:= $(ROLLUPS_CONTRACTS_ABI_BASEDIR).$(ROLLUPS_CONTRACTS_VERSION)-$(ROLLUPS_CONTRACTS_SHA256).stamp +ROLLUPS_PRT_CONTRACTS_ABI_STAMP:= $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR).$(ROLLUPS_PRT_CONTRACTS_VERSION)-$(ROLLUPS_PRT_CONTRACTS_SHA256).stamp all: build @@ -195,10 +197,11 @@ check-generate: generate ## Check whether the generated files are in sync exit 1; \ fi -contracts: $(ROLLUPS_CONTRACTS_ABI_BASEDIR)/.stamp $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR)/.stamp ## Export the contract artifacts +contracts: $(ROLLUPS_CONTRACTS_ABI_STAMP) $(ROLLUPS_PRT_CONTRACTS_ABI_STAMP) ## Export the contract artifacts -$(ROLLUPS_CONTRACTS_ABI_BASEDIR)/.stamp: +$(ROLLUPS_CONTRACTS_ABI_STAMP): @echo "Downloading rollups-contracts artifacts" + @rm -rf $(ROLLUPS_CONTRACTS_ABI_BASEDIR) @mkdir -p $(ROLLUPS_CONTRACTS_ABI_BASEDIR) @curl -sSL $(ROLLUPS_CONTRACTS_URL)/v$(ROLLUPS_CONTRACTS_VERSION)/$(ROLLUPS_CONTRACTS_ARTIFACT) -o $(ROLLUPS_CONTRACTS_ARTIFACT) @echo "$(ROLLUPS_CONTRACTS_SHA256) $(ROLLUPS_CONTRACTS_ARTIFACT)" | shasum -a 256 --check > /dev/null @@ -206,8 +209,9 @@ $(ROLLUPS_CONTRACTS_ABI_BASEDIR)/.stamp: @touch $@ @rm -f $(ROLLUPS_CONTRACTS_ARTIFACT) -$(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR)/.stamp: +$(ROLLUPS_PRT_CONTRACTS_ABI_STAMP): @echo "Downloading rollups-prt-contracts artifacts" + @rm -rf $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR) @mkdir -p $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR) @curl -sSL $(ROLLUPS_PRT_CONTRACTS_URL)/v$(ROLLUPS_PRT_CONTRACTS_VERSION)/$(ROLLUPS_PRT_CONTRACTS_ARTIFACT) -o $(ROLLUPS_PRT_CONTRACTS_ARTIFACT) @echo "$(ROLLUPS_PRT_CONTRACTS_SHA256) $(ROLLUPS_PRT_CONTRACTS_ARTIFACT)" | shasum -a 256 --check > /dev/null From a81af91c33079babfb10f5a136aedb288a822ffb Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:14 -0300 Subject: [PATCH 02/41] build(devnet): adopt verified v3 bundle Use the official Dave deployment bundle and its Anvil version. The bundle includes the USD withdrawal output builder, so the devnet image does not deploy another copy. Update the development addresses in Makefile, Compose, and the withdrawal script together. Keep deployments.json generated at devnet startup; do not add a tracked deployment manifest or an address-generation layer. --- Makefile | 26 +++++------ compose.individual-services.yaml | 66 ++++++++++++++++++++++++--- compose.yaml | 14 +++--- scripts/withdrawal-lifecycle | 4 +- test/compose/compose.integration.yaml | 18 ++++---- test/compose/compose.test.yaml | 14 +++--- test/devnet/Dockerfile | 55 ++++------------------ 7 files changed, 105 insertions(+), 92 deletions(-) diff --git a/Makefile b/Makefile index 847d6dba1..3ee25a33a 100644 --- a/Makefile +++ b/Makefile @@ -143,15 +143,15 @@ env: @echo export CARTESI_BLOCKCHAIN_HTTP_ENDPOINT="http://localhost:8545" @echo export CARTESI_BLOCKCHAIN_ID="31337" @echo export CARTESI_EVM_READER_POLLING_INTERVAL="1" - @echo export CARTESI_CONTRACTS_INPUT_BOX_ADDRESS="0x346B3df038FE9f8380071eC6514D5a83aD143939" - @echo export CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS="0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0" - @echo export CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS="0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F" - @echo export CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS="0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483" - @echo export CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS="0x6145C5996a71a379E030aEb0440df79D60833418" - @echo export CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS="0x33FFf0b681c90664dD048a60400AE2D827a4c5bb" - @echo export CARTESI_DEVNET_ERC20_PORTAL_ADDRESS="0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1" - @echo export CARTESI_DEVNET_TEST_ERC20_ADDRESS="0x88A2120B7068E78692C8fd12E751d610B6377E4d" - @echo export CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS="0x0745787835A019cd4dae8EDB541Fbc0647793d63" + @echo export CARTESI_CONTRACTS_INPUT_BOX_ADDRESS="0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + @echo export CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS="0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + @echo export CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS="0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + @echo export CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS="0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + @echo export CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS="0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + @echo export CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS="0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + @echo export CARTESI_DEVNET_ERC20_PORTAL_ADDRESS="0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419" + @echo export CARTESI_DEVNET_TEST_ERC20_ADDRESS="0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" + @echo export CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS="0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" @echo export CARTESI_AUTH_MNEMONIC=\"test test test test test test test test test test test junk\" @echo export CARTESI_DATABASE_CONNECTION="postgres://postgres:password@localhost:5432/rollupsdb?sslmode=disable" @echo export CARTESI_SNAPSHOTS_DIR="snapshots" @@ -388,8 +388,8 @@ applications/unexpected-yield-dapp: ## Create unexpected-yield test application applications/erc20-withdrawal-dapp: test/dapps/erc20-withdrawal/install.sh ## Create ERC-20 withdrawal test application @echo "Creating ERC-20 withdrawal test application" @mkdir -p applications - @PORTAL=$${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1}; \ - TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x88A2120B7068E78692C8fd12E751d610B6377E4d}; \ + @PORTAL=$${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419}; \ + TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}; \ cartesi-machine --ram-length=128Mi \ --flash-drive=label:accounts,length:4Mi,mke2fs:false,mount:false,user:dapp \ --env=TRUSTED_ERC20_PORTAL=$$PORTAL \ @@ -417,7 +417,7 @@ deploy-erc20-withdrawal-dapp: applications/erc20-withdrawal-dapp ## Deploy ERC-2 @set -e; \ APP=$${APP:-erc20-withdrawal-dapp}; \ GUARDIAN=$${GUARDIAN:-0x70997970C51812dc3A010C7d01b50e0d17dc79C8}; \ - BUILDER=$${CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS:-0x0745787835A019cd4dae8EDB541Fbc0647793d63}; \ + BUILDER=$${CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS:-0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7}; \ DRIVE_START_INDEX=$$(jq -r '.config.flash_drive[] | select(.length == 4194304) | (.start / 4194304 | floor)' \ applications/erc20-withdrawal-dapp/config.json); \ WITHDRAWAL_CONFIG=$$(jq -cn \ @@ -436,7 +436,7 @@ deploy-erc20-withdrawal-dapp: applications/erc20-withdrawal-dapp ## Deploy ERC-2 fund-wallet: ## Fund the default Anvil wallet with ETH and test ERC-20 @set -e; \ RPC_URL=$${CARTESI_BLOCKCHAIN_HTTP_ENDPOINT:-http://localhost:8545}; \ - TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x88A2120B7068E78692C8fd12E751d610B6377E4d}; \ + TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}; \ WALLET=$${WALLET:-$$(cast rpc --rpc-url "$$RPC_URL" eth_accounts | jq -r '.[0]')}; \ ETH_WEI=$${ETH_WEI:-0x8ac7230489e80000}; \ TOKEN_AMOUNT=$${TOKEN_AMOUNT:-1000000}; \ diff --git a/compose.individual-services.yaml b/compose.individual-services.yaml index f70b25611..ad7a3d86b 100644 --- a/compose.individual-services.yaml +++ b/compose.individual-services.yaml @@ -3,15 +3,18 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint CARTESI_BLOCKCHAIN_ID: 31337 CARTESI_EVM_READER_POLLING_INTERVAL: 1 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x346B3df038FE9f8380071eC6514D5a83aD143939 - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0 - CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: 0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483 - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x6145C5996a71a379E030aEb0440df79D60833418 - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0x33FFf0b681c90664dD048a60400AE2D827a4c5bb - CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: 0x0745787835A019cd4dae8EDB541Fbc0647793d63 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" CARTESI_DATABASE_CONNECTION_FILE: /run/secrets/database_connection CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 services: ethereum_provider: @@ -63,6 +66,11 @@ services: - devnet ports: - "10001:10001" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10001/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - auth_mnemonic - blockchain_http_endpoint @@ -86,6 +94,11 @@ services: ports: - "10002:10002" # Supervisor - "10012:10012" # Inspect Service + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10002/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - auth_mnemonic - database_connection @@ -102,6 +115,11 @@ services: - devnet ports: - "10003:10003" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10003/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - database_connection environment: @@ -119,6 +137,35 @@ services: - devnet ports: - "10004:10004" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10004/readyz"] + interval: 1s + timeout: 1s + retries: 5 + secrets: + - auth_mnemonic + - blockchain_http_endpoint + - database_connection + environment: + <<: *env + + prt: + image: cartesi/rollups-node:devel + command: cartesi-rollups-prt + depends_on: + database: + condition: service_healthy + ethereum_provider: + condition: service_healthy + networks: + - devnet + ports: + - "10006:10006" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10006/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - auth_mnemonic - blockchain_http_endpoint @@ -137,6 +184,11 @@ services: ports: - "10005:10005" # Supervisor - "10011:10011" # Jsonrpc API service + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10005/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - auth_mnemonic - blockchain_http_endpoint diff --git a/compose.yaml b/compose.yaml index 4cedca5f9..b8d414178 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,13 +3,13 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint CARTESI_BLOCKCHAIN_ID: 31337 CARTESI_EVM_READER_POLLING_INTERVAL: 1 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x346B3df038FE9f8380071eC6514D5a83aD143939 - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0 - CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: 0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483 - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x6145C5996a71a379E030aEb0440df79D60833418 - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0x33FFf0b681c90664dD048a60400AE2D827a4c5bb - CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: 0x0745787835A019cd4dae8EDB541Fbc0647793d63 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" CARTESI_DATABASE_CONNECTION_FILE: /run/secrets/database_connection CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic diff --git a/scripts/withdrawal-lifecycle b/scripts/withdrawal-lifecycle index d7ffb0a22..b61dd1f39 100755 --- a/scripts/withdrawal-lifecycle +++ b/scripts/withdrawal-lifecycle @@ -19,8 +19,8 @@ NODE="${NODE:-$ROOT/cartesi-rollups-node}" MACHINE_TOOL="${MACHINE_TOOL:-$ROOT/cartesi-rollups-machine-tool}" RPC_URL="${CARTESI_BLOCKCHAIN_HTTP_ENDPOINT:-http://localhost:8545}" -PORTAL="${PORTAL:-${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1}}" -TOKEN="${TOKEN:-${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x88A2120B7068E78692C8fd12E751d610B6377E4d}}" +PORTAL="${PORTAL:-${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419}}" +TOKEN="${TOKEN:-${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}}" DEPOSIT_AMOUNT="${DEPOSIT_AMOUNT:-100}" WITHDRAW_AMOUNT="${WITHDRAW_AMOUNT:-25}" diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 5054be4c6..00fc53008 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -3,15 +3,15 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: http://ethereum_provider:8545 CARTESI_BLOCKCHAIN_ID: 31337 CARTESI_EVM_READER_POLLING_INTERVAL: 1 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x346B3df038FE9f8380071eC6514D5a83aD143939 - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0 - CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: 0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483 - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x6145C5996a71a379E030aEb0440df79D60833418 - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0x33FFf0b681c90664dD048a60400AE2D827a4c5bb - CARTESI_DEVNET_ERC20_PORTAL_ADDRESS: 0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1 - CARTESI_DEVNET_TEST_ERC20_ADDRESS: 0x88A2120B7068E78692C8fd12E751d610B6377E4d - CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: 0x0745787835A019cd4dae8EDB541Fbc0647793d63 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" + CARTESI_DEVNET_ERC20_PORTAL_ADDRESS: "0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419" + CARTESI_DEVNET_TEST_ERC20_ADDRESS: "0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" CARTESI_DATABASE_CONNECTION: postgres://postgres:password@database:5432/rollupsdb?sslmode=disable CARTESI_AUTH_MNEMONIC: "test test test test test test test test test test test junk" diff --git a/test/compose/compose.test.yaml b/test/compose/compose.test.yaml index b7fa3b3b2..719690341 100644 --- a/test/compose/compose.test.yaml +++ b/test/compose/compose.test.yaml @@ -3,13 +3,13 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: http://ethereum_provider:8545 CARTESI_BLOCKCHAIN_ID: 31337 CARTESI_EVM_READER_POLLING_INTERVAL: 1 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x346B3df038FE9f8380071eC6514D5a83aD143939 - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0 - CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: 0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483 - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x6145C5996a71a379E030aEb0440df79D60833418 - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0x33FFf0b681c90664dD048a60400AE2D827a4c5bb - CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: 0x0745787835A019cd4dae8EDB541Fbc0647793d63 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" CARTESI_DATABASE_CONNECTION: postgres://postgres:password@database:5432/rollupsdb?sslmode=disable CARTESI_TEST_DATABASE_CONNECTION: postgres://test_user:password@database:5432/test_rollupsdb?sslmode=disable CARTESI_AUTH_MNEMONIC: "test test test test test test test test test test test junk" diff --git a/test/devnet/Dockerfile b/test/devnet/Dockerfile index c0dece8b7..079160ec1 100644 --- a/test/devnet/Dockerfile +++ b/test/devnet/Dockerfile @@ -1,5 +1,5 @@ -ARG FOUNDRY_VERSION=1.4.3 -ARG PRT_CONTRACTS_VERSION=3.0.0-alpha.3 +ARG FOUNDRY_VERSION=1.5.1 +ARG PRT_CONTRACTS_VERSION=3.0.0-alpha.4 ARG DEVNET_BUILD_PATH=/opt/cartesi/rollups-contracts FROM debian:trixie-20250811 AS rollups-node-devnet @@ -25,64 +25,25 @@ RUN </dev/null | grep -q 31337; do sleep 0.2; done - - TOKEN=$(jq -r .address ${DEVNET_BUILD_PATH}/deployments/31337/TestFungibleToken.json) - FACTORY=$(jq -r .address ${DEVNET_BUILD_PATH}/deployments/31337/UsdWithdrawalOutputBuilderFactory.json) - SALT=0x0000000000000000000000000000000000000000000000000000000000000000 - - # Predict the deterministic builder address (used to synth the JSON below). - BUILDER=$(cast call "$FACTORY" \ - 'calculateUsdWithdrawalOutputBuilderAddress(address,bytes32)(address)' \ - "$TOKEN" "$SALT") - - # Deploy the builder via the factory using anvil's first unlocked account. - DEPLOYER=$(cast rpc eth_accounts | jq -r '.[0]') - cast send --from "$DEPLOYER" --unlocked "$FACTORY" \ - 'newUsdWithdrawalOutputBuilder(address,bytes32)' "$TOKEN" "$SALT" - - # Synthesize the per-contract JSON BEFORE killing anvil, so a cast-send - # failure (set -e) aborts before we write a JSON unbacked by chain state. - jq -n --arg addr "$BUILDER" \ - '{contractName: "UsdWithdrawalOutputBuilder", address: $addr}' \ - > ${DEVNET_BUILD_PATH}/deployments/31337/UsdWithdrawalOutputBuilder.json - - # Graceful shutdown lets --state dump the post-deploy chain back to disk. - # `wait` returns non-zero on a SIGINT-terminated child; `|| true` keeps - # `set -e` from aborting the build on that expected non-zero. - kill -INT $ANVIL_PID - wait $ANVIL_PID || true - cat ${DEVNET_BUILD_PATH}/deployments/31337/*.json | jq -s 'map({ (.contractName): .address }) | add' > /usr/share/devnet/deployment.json mv ${DEVNET_BUILD_PATH}/state.json /usr/share/devnet/anvil_state.json EOF From 83daf2a7fe15e0567e1029aa44ab0cd3c002c717 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:43:17 -0300 Subject: [PATCH 03/41] build(lint): use the v2 configuration toolchain --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5c6f64b7f..9306b036a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,8 +112,8 @@ RUN make build-go FROM go-builder AS tester # Install golangci-lint for linting inside Docker. -ARG GOLANGCI_LINT_VERSION=1.64.5 -RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v${GOLANGCI_LINT_VERSION} +ARG GOLANGCI_LINT_VERSION=2.13.2 +RUN GOTOOLCHAIN=local go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v${GOLANGCI_LINT_VERSION} # Install gotestsum for test output formatting and summaries. ARG GOTESTSUM_VERSION=1.13.0 From 9949d19a390c6fa03e5da01f3267c65ae764fcb8 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:14 -0300 Subject: [PATCH 04/41] fix(generate): preserve shared source access Generated source must be usable by the human and agent accounts in the same staff group. Give binding directories and files explicit shared access after creation, including under a restrictive caller umask. Normalize only the generated database tree after Jet runs. Add a subprocess test for binding permissions. Do not change permissions of unrelated checkout files. --- Makefile | 1 + pkg/contracts/generate/main.go | 10 ++++-- pkg/contracts/generate/main_test.go | 56 +++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 pkg/contracts/generate/main_test.go diff --git a/Makefile b/Makefile index 3ee25a33a..de8f21e12 100644 --- a/Makefile +++ b/Makefile @@ -228,6 +228,7 @@ generate-db: ## Generate repository/db with Jet @rm -rf internal/repository/postgres/db @go run github.com/go-jet/jet/v2/cmd/jet -dsn=$$CARTESI_DATABASE_CONNECTION -schema=public -path=./internal/repository/postgres/db @rm -rf internal/repository/postgres/db/rollupsdb/public/model + @chmod -R g+rwX internal/repository/postgres/db # ============================================================================= # Clean diff --git a/pkg/contracts/generate/main.go b/pkg/contracts/generate/main.go index a7ae882d7..cc74dbbdb 100644 --- a/pkg/contracts/generate/main.go +++ b/pkg/contracts/generate/main.go @@ -181,14 +181,20 @@ func generateBinding(b contractBinding, content []byte) { checkErr("removing dir", err) } - const dirMode = 0700 + // Generated source is shared by developers in the same group. Set the + // final modes explicitly so the caller's umask does not remove group write. + const dirMode = 0775 err = os.Mkdir(pkg, dirMode) checkErr("creating dir", err) + err = os.Chmod(pkg, dirMode) + checkErr("setting dir permissions", err) - const fileMode = 0600 + const fileMode = 0664 filePath := pkg + "/" + pkg + ".go" err = os.WriteFile(filePath, []byte(code), fileMode) checkErr("write binding file", err) + err = os.Chmod(filePath, fileMode) + checkErr("setting binding permissions", err) log.Print("generated binding for ", filePath) } diff --git a/pkg/contracts/generate/main_test.go b/pkg/contracts/generate/main_test.go new file mode 100644 index 000000000..2cb9c7a27 --- /dev/null +++ b/pkg/contracts/generate/main_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build unix + +package main + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateBinding_SharedWorktreePermissions(t *testing.T) { + t.Chdir(t.TempDir()) + // Keep this test serial: both Chdir and Umask change process-wide state. + // The usual umask removes group write during creation. Generation must + // restore it so another user in the same group can regenerate the package. + const sourceCreationMask = 0022 + previousMask := syscall.Umask(sourceCreationMask) + t.Cleanup(func() { syscall.Umask(previousMask) }) + const artifact = `{ + "abi": [{ + "type": "function", + "name": "value", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view" + }] + }` + + for _, name := range []string{"first generation", "regeneration"} { + t.Run(name, func(t *testing.T) { + generateBinding(contractBinding{typeName: "TestContract"}, []byte(artifact)) + + directory, err := os.Stat("testcontract") + require.NoError(t, err) + require.True(t, directory.IsDir()) + const sharedDirectoryBits os.FileMode = 0775 + require.Equal(t, sharedDirectoryBits, directory.Mode().Perm()&sharedDirectoryBits, + "group members must be able to regenerate the publicly readable package") + + binding, err := os.Stat(filepath.Join("testcontract", "testcontract.go")) + require.NoError(t, err) + require.True(t, binding.Mode().IsRegular()) + const sharedSourceBits os.FileMode = 0664 + require.Equal(t, sharedSourceBits, binding.Mode().Perm()&sharedSourceBits, + "group members must be able to edit the publicly readable Go source") + const executeBits os.FileMode = 0111 + require.Zero(t, binding.Mode().Perm()&executeBits, "generated Go source must not be executable") + }) + } +} From 82ce64ff58781a97744090d271d4d63f0290b9bb Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:15 -0300 Subject: [PATCH 05/41] build(contracts): regenerate v3 bindings Generate bindings from rollups-contracts v3.0.0-alpha.10 and Dave v3.0.0-alpha.4. Use the upstream public ERC interfaces, the renamed IErc20Portal, and IMultiLevelTournamentFactory. Remove DataAvailability from the generation list and generated packages. The node targets the new contracts; no legacy compatibility path is added. --- .../dataavailability/dataavailability.go | 223 ---- pkg/contracts/generate/main.go | 18 +- pkg/contracts/iapplication/iapplication.go | 451 ++++++- .../iapplicationfactory.go | 88 +- pkg/contracts/iauthority/iauthority.go | 70 +- pkg/contracts/iconsensus/iconsensus.go | 70 +- .../idaveappfactory/idaveappfactory.go | 50 +- .../idaveconsensus/idaveconsensus.go | 1035 +++++++++++++++-- pkg/contracts/ierc20portal/ierc20portal.go | 199 ++-- pkg/contracts/iinputbox/iinputbox.go | 2 +- .../imultileveltournamentfactory.go | 459 ++++++++ pkg/contracts/iquorum/iquorum.go | 70 +- .../iselfhostedapplicationfactory.go | 50 +- pkg/contracts/itournament/itournament.go | 1003 ++++++++++------ .../iusdwithdrawaloutputbuilder.go | 2 +- 15 files changed, 2842 insertions(+), 948 deletions(-) delete mode 100644 pkg/contracts/dataavailability/dataavailability.go create mode 100644 pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go diff --git a/pkg/contracts/dataavailability/dataavailability.go b/pkg/contracts/dataavailability/dataavailability.go deleted file mode 100644 index 14d684b26..000000000 --- a/pkg/contracts/dataavailability/dataavailability.go +++ /dev/null @@ -1,223 +0,0 @@ -// Code generated - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package dataavailability - -import ( - "errors" - "math/big" - "strings" - - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = errors.New - _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind - _ = common.Big1 - _ = types.BloomLookup - _ = event.NewSubscription - _ = abi.ConvertType -) - -// DataAvailabilityMetaData contains all meta data concerning the DataAvailability contract. -var DataAvailabilityMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"InputBox\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"InputBoxAndEspresso\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"fromBlock\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"namespaceId\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"}]", -} - -// DataAvailabilityABI is the input ABI used to generate the binding from. -// Deprecated: Use DataAvailabilityMetaData.ABI instead. -var DataAvailabilityABI = DataAvailabilityMetaData.ABI - -// DataAvailability is an auto generated Go binding around an Ethereum contract. -type DataAvailability struct { - DataAvailabilityCaller // Read-only binding to the contract - DataAvailabilityTransactor // Write-only binding to the contract - DataAvailabilityFilterer // Log filterer for contract events -} - -// DataAvailabilityCaller is an auto generated read-only Go binding around an Ethereum contract. -type DataAvailabilityCaller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// DataAvailabilityTransactor is an auto generated write-only Go binding around an Ethereum contract. -type DataAvailabilityTransactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// DataAvailabilityFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type DataAvailabilityFilterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// DataAvailabilitySession is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type DataAvailabilitySession struct { - Contract *DataAvailability // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// DataAvailabilityCallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type DataAvailabilityCallerSession struct { - Contract *DataAvailabilityCaller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// DataAvailabilityTransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type DataAvailabilityTransactorSession struct { - Contract *DataAvailabilityTransactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// DataAvailabilityRaw is an auto generated low-level Go binding around an Ethereum contract. -type DataAvailabilityRaw struct { - Contract *DataAvailability // Generic contract binding to access the raw methods on -} - -// DataAvailabilityCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type DataAvailabilityCallerRaw struct { - Contract *DataAvailabilityCaller // Generic read-only contract binding to access the raw methods on -} - -// DataAvailabilityTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type DataAvailabilityTransactorRaw struct { - Contract *DataAvailabilityTransactor // Generic write-only contract binding to access the raw methods on -} - -// NewDataAvailability creates a new instance of DataAvailability, bound to a specific deployed contract. -func NewDataAvailability(address common.Address, backend bind.ContractBackend) (*DataAvailability, error) { - contract, err := bindDataAvailability(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &DataAvailability{DataAvailabilityCaller: DataAvailabilityCaller{contract: contract}, DataAvailabilityTransactor: DataAvailabilityTransactor{contract: contract}, DataAvailabilityFilterer: DataAvailabilityFilterer{contract: contract}}, nil -} - -// NewDataAvailabilityCaller creates a new read-only instance of DataAvailability, bound to a specific deployed contract. -func NewDataAvailabilityCaller(address common.Address, caller bind.ContractCaller) (*DataAvailabilityCaller, error) { - contract, err := bindDataAvailability(address, caller, nil, nil) - if err != nil { - return nil, err - } - return &DataAvailabilityCaller{contract: contract}, nil -} - -// NewDataAvailabilityTransactor creates a new write-only instance of DataAvailability, bound to a specific deployed contract. -func NewDataAvailabilityTransactor(address common.Address, transactor bind.ContractTransactor) (*DataAvailabilityTransactor, error) { - contract, err := bindDataAvailability(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &DataAvailabilityTransactor{contract: contract}, nil -} - -// NewDataAvailabilityFilterer creates a new log filterer instance of DataAvailability, bound to a specific deployed contract. -func NewDataAvailabilityFilterer(address common.Address, filterer bind.ContractFilterer) (*DataAvailabilityFilterer, error) { - contract, err := bindDataAvailability(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &DataAvailabilityFilterer{contract: contract}, nil -} - -// bindDataAvailability binds a generic wrapper to an already deployed contract. -func bindDataAvailability(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := DataAvailabilityMetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_DataAvailability *DataAvailabilityRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _DataAvailability.Contract.DataAvailabilityCaller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_DataAvailability *DataAvailabilityRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _DataAvailability.Contract.DataAvailabilityTransactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_DataAvailability *DataAvailabilityRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _DataAvailability.Contract.DataAvailabilityTransactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_DataAvailability *DataAvailabilityCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _DataAvailability.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_DataAvailability *DataAvailabilityTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _DataAvailability.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_DataAvailability *DataAvailabilityTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _DataAvailability.Contract.contract.Transact(opts, method, params...) -} - -// InputBox is a paid mutator transaction binding the contract method 0xb12c9ede. -// -// Solidity: function InputBox(address inputBox) returns() -func (_DataAvailability *DataAvailabilityTransactor) InputBox(opts *bind.TransactOpts, inputBox common.Address) (*types.Transaction, error) { - return _DataAvailability.contract.Transact(opts, "InputBox", inputBox) -} - -// InputBox is a paid mutator transaction binding the contract method 0xb12c9ede. -// -// Solidity: function InputBox(address inputBox) returns() -func (_DataAvailability *DataAvailabilitySession) InputBox(inputBox common.Address) (*types.Transaction, error) { - return _DataAvailability.Contract.InputBox(&_DataAvailability.TransactOpts, inputBox) -} - -// InputBox is a paid mutator transaction binding the contract method 0xb12c9ede. -// -// Solidity: function InputBox(address inputBox) returns() -func (_DataAvailability *DataAvailabilityTransactorSession) InputBox(inputBox common.Address) (*types.Transaction, error) { - return _DataAvailability.Contract.InputBox(&_DataAvailability.TransactOpts, inputBox) -} - -// InputBoxAndEspresso is a paid mutator transaction binding the contract method 0x8579fd0c. -// -// Solidity: function InputBoxAndEspresso(address inputBox, uint256 fromBlock, uint32 namespaceId) returns() -func (_DataAvailability *DataAvailabilityTransactor) InputBoxAndEspresso(opts *bind.TransactOpts, inputBox common.Address, fromBlock *big.Int, namespaceId uint32) (*types.Transaction, error) { - return _DataAvailability.contract.Transact(opts, "InputBoxAndEspresso", inputBox, fromBlock, namespaceId) -} - -// InputBoxAndEspresso is a paid mutator transaction binding the contract method 0x8579fd0c. -// -// Solidity: function InputBoxAndEspresso(address inputBox, uint256 fromBlock, uint32 namespaceId) returns() -func (_DataAvailability *DataAvailabilitySession) InputBoxAndEspresso(inputBox common.Address, fromBlock *big.Int, namespaceId uint32) (*types.Transaction, error) { - return _DataAvailability.Contract.InputBoxAndEspresso(&_DataAvailability.TransactOpts, inputBox, fromBlock, namespaceId) -} - -// InputBoxAndEspresso is a paid mutator transaction binding the contract method 0x8579fd0c. -// -// Solidity: function InputBoxAndEspresso(address inputBox, uint256 fromBlock, uint32 namespaceId) returns() -func (_DataAvailability *DataAvailabilityTransactorSession) InputBoxAndEspresso(inputBox common.Address, fromBlock *big.Int, namespaceId uint32) (*types.Transaction, error) { - return _DataAvailability.Contract.InputBoxAndEspresso(&_DataAvailability.TransactOpts, inputBox, fromBlock, namespaceId) -} diff --git a/pkg/contracts/generate/main.go b/pkg/contracts/generate/main.go index cc74dbbdb..17e0e48e4 100644 --- a/pkg/contracts/generate/main.go +++ b/pkg/contracts/generate/main.go @@ -3,9 +3,9 @@ // This binary generates the Go bindings for the Cartesi Rollups contracts. // This binary should be called with `go generate` in the parent dir. -// First, it downloads the Cartesi Rollups npm package containing the contracts. -// Then, it generates the bindings using abi-gen. -// Finally, it stores the bindings in the current directory. +// The Makefile extracts the release artifacts before this generator runs. +// It reads their ABIs and generates the Go bindings with bind.Bind. +// It stores the bindings in the current directory. package main import ( @@ -73,10 +73,6 @@ var bindings = []contractBinding{ jsonPath: rollupsContractsPath + "Outputs.sol/Outputs.json", typeName: "Outputs", }, - { - jsonPath: rollupsContractsPath + "DataAvailability.sol/DataAvailability.json", - typeName: "DataAvailability", - }, { jsonPath: rollupsContractsPath + "IUsdWithdrawalOutputBuilder.sol/IUsdWithdrawalOutputBuilder.json", typeName: "IUsdWithdrawalOutputBuilder", @@ -86,8 +82,8 @@ var bindings = []contractBinding{ typeName: "IERC20Metadata", }, { - jsonPath: rollupsContractsPath + "IERC20Portal.sol/IERC20Portal.json", - typeName: "IERC20Portal", + jsonPath: rollupsContractsPath + "IErc20Portal.sol/IErc20Portal.json", + typeName: "IErc20Portal", }, { jsonPath: rollupsContractsPath + "draft-IERC6093.sol/IERC20Errors.json", @@ -97,6 +93,10 @@ var bindings = []contractBinding{ jsonPath: rollupsPrtContractsPath + "prt/contracts/out/ITournament.sol/ITournament.json", typeName: "ITournament", }, + { + jsonPath: rollupsPrtContractsPath + "prt/contracts/out/IMultiLevelTournamentFactory.sol/IMultiLevelTournamentFactory.json", + typeName: "IMultiLevelTournamentFactory", + }, { jsonPath: rollupsPrtContractsPath + "cartesi-rollups/contracts/out/IDaveConsensus.sol/IDaveConsensus.json", typeName: "IDaveConsensus", diff --git a/pkg/contracts/iapplication/iapplication.go b/pkg/contracts/iapplication/iapplication.go index dab53f9a5..66a00a8fe 100644 --- a/pkg/contracts/iapplication/iapplication.go +++ b/pkg/contracts/iapplication/iapplication.go @@ -52,7 +52,7 @@ type WithdrawalConfig struct { // IApplicationMetaData contains all meta data concerning the IApplication contract. var IApplicationMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"executeOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"foreclose\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getAccountsDriveMerkleRoot\",\"inputs\":[],\"outputs\":[{\"name\":\"wasAccountsDriveMerkleRootProved\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAccountsDriveStartIndex\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDataAvailability\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getGuardian\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2LeavesPerAccount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2MaxNumOfAccounts\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfExecutedOutputs\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfWithdrawals\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getOutputsMerkleRootValidator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTemplateHash\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalConfig\",\"inputs\":[],\"outputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalOutputBuilder\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isForeclosed\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"migrateToOutputsMerkleRootValidator\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"proveAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"validateAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateAccountMerkleRoot\",\"inputs\":[{\"name\":\"accountMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutputHash\",\"inputs\":[{\"name\":\"outputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasOutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wereAccountFundsWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdraw\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AccountsDriveMerkleRootProved\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Foreclosure\",\"inputs\":[],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputsMerkleRootValidatorChanged\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Withdrawal\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountFundsAlreadyWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccountTooShort\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minAccountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootAlreadyProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootNotProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"Foreclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientFunds\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountRootSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRootProofSize\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleRoot\",\"inputs\":[{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputHashesSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRoot\",\"inputs\":[{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"NotForeclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotGuardian\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"OutputNotExecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"OutputNotReexecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"executeOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"foreclose\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getAccountsDriveMerkleRoot\",\"inputs\":[],\"outputs\":[{\"name\":\"wasAccountsDriveMerkleRootProved\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAccountsDriveStartIndex\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getGuardian\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2LeavesPerAccount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2MaxNumOfAccounts\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfExecutedOutputs\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfIssuedRefunds\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfWithdrawals\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getOutputsMerkleRootValidator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getRefundOutputBuilder\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIRefundOutputBuilder\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTemplateHash\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalConfig\",\"inputs\":[],\"outputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalOutputBuilder\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isForeclosed\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"issueRefund\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"migrateToOutputsMerkleRootValidator\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"proveAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"validateAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateAccountMerkleRoot\",\"inputs\":[{\"name\":\"accountMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateInput\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputSender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputPayload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateInputHash\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutputHash\",\"inputs\":[{\"name\":\"outputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasOutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasRefundForInputIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wereAccountFundsWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdraw\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AccountsDriveMerkleRootProved\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Foreclosure\",\"inputs\":[],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputsMerkleRootValidatorChanged\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RefundIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Withdrawal\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountFundsAlreadyWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootAlreadyProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootNotProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CannotRefundFinalizedInput\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"Foreclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IllFormedInput\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientFunds\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountRootSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAccountSize\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"accountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRootProofSize\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidInputHash\",\"inputs\":[{\"name\":\"storedInputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"invalidInputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidInputIndex\",\"inputs\":[{\"name\":\"invalidInputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"numOfInputs\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleRoot\",\"inputs\":[{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputHashesSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRoot\",\"inputs\":[{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"NotDeploymentBlock\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotForeclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotGuardian\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"OutputNotExecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"OutputNotReexecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"RefundAlreadyIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"TargetHasNoCode\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"UnknownInputSender\",\"inputs\":[{\"name\":\"inputSender\",\"type\":\"address\",\"internalType\":\"address\"}]}]", } // IApplicationABI is the input ABI used to generate the binding from. @@ -277,37 +277,6 @@ func (_IApplication *IApplicationCallerSession) GetAccountsDriveStartIndex() (ui return _IApplication.Contract.GetAccountsDriveStartIndex(&_IApplication.CallOpts) } -// GetDataAvailability is a free data retrieval call binding the contract method 0xf02478de. -// -// Solidity: function getDataAvailability() view returns(bytes) -func (_IApplication *IApplicationCaller) GetDataAvailability(opts *bind.CallOpts) ([]byte, error) { - var out []interface{} - err := _IApplication.contract.Call(opts, &out, "getDataAvailability") - - if err != nil { - return *new([]byte), err - } - - out0 := *abi.ConvertType(out[0], new([]byte)).(*[]byte) - - return out0, err - -} - -// GetDataAvailability is a free data retrieval call binding the contract method 0xf02478de. -// -// Solidity: function getDataAvailability() view returns(bytes) -func (_IApplication *IApplicationSession) GetDataAvailability() ([]byte, error) { - return _IApplication.Contract.GetDataAvailability(&_IApplication.CallOpts) -} - -// GetDataAvailability is a free data retrieval call binding the contract method 0xf02478de. -// -// Solidity: function getDataAvailability() view returns(bytes) -func (_IApplication *IApplicationCallerSession) GetDataAvailability() ([]byte, error) { - return _IApplication.Contract.GetDataAvailability(&_IApplication.CallOpts) -} - // GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. // // Solidity: function getDeploymentBlockNumber() view returns(uint256) @@ -370,6 +339,37 @@ func (_IApplication *IApplicationCallerSession) GetGuardian() (common.Address, e return _IApplication.Contract.GetGuardian(&_IApplication.CallOpts) } +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationCaller) GetInputBox(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getInputBox") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationSession) GetInputBox() (common.Address, error) { + return _IApplication.Contract.GetInputBox(&_IApplication.CallOpts) +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationCallerSession) GetInputBox() (common.Address, error) { + return _IApplication.Contract.GetInputBox(&_IApplication.CallOpts) +} + // GetLog2LeavesPerAccount is a free data retrieval call binding the contract method 0x28a0e3c5. // // Solidity: function getLog2LeavesPerAccount() view returns(uint8) @@ -463,6 +463,37 @@ func (_IApplication *IApplicationCallerSession) GetNumberOfExecutedOutputs() (*b return _IApplication.Contract.GetNumberOfExecutedOutputs(&_IApplication.CallOpts) } +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationCaller) GetNumberOfIssuedRefunds(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getNumberOfIssuedRefunds") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationSession) GetNumberOfIssuedRefunds() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfIssuedRefunds(&_IApplication.CallOpts) +} + +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationCallerSession) GetNumberOfIssuedRefunds() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfIssuedRefunds(&_IApplication.CallOpts) +} + // GetNumberOfWithdrawals is a free data retrieval call binding the contract method 0x0e70381b. // // Solidity: function getNumberOfWithdrawals() view returns(uint256) @@ -525,6 +556,37 @@ func (_IApplication *IApplicationCallerSession) GetOutputsMerkleRootValidator() return _IApplication.Contract.GetOutputsMerkleRootValidator(&_IApplication.CallOpts) } +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationCaller) GetRefundOutputBuilder(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getRefundOutputBuilder") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationSession) GetRefundOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetRefundOutputBuilder(&_IApplication.CallOpts) +} + +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationCallerSession) GetRefundOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetRefundOutputBuilder(&_IApplication.CallOpts) +} + // GetTemplateHash is a free data retrieval call binding the contract method 0x61b12c66. // // Solidity: function getTemplateHash() view returns(bytes32) @@ -738,6 +800,85 @@ func (_IApplication *IApplicationCallerSession) ValidateAccountMerkleRoot(accoun return _IApplication.Contract.ValidateAccountMerkleRoot(&_IApplication.CallOpts, accountMerkleRoot, proof) } +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationCaller) ValidateInput(opts *bind.CallOpts, inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateInput", inputIndex, input) + + outstruct := new(struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.BlockNumber = *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.InputSender = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + outstruct.InputPayload = *abi.ConvertType(out[2], new([]byte)).(*[]byte) + + return *outstruct, err + +} + +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationSession) ValidateInput(inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + return _IApplication.Contract.ValidateInput(&_IApplication.CallOpts, inputIndex, input) +} + +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationCallerSession) ValidateInput(inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + return _IApplication.Contract.ValidateInput(&_IApplication.CallOpts, inputIndex, input) +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationCaller) ValidateInputHash(opts *bind.CallOpts, inputIndex *big.Int, inputHash [32]byte) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateInputHash", inputIndex, inputHash) + + if err != nil { + return err + } + + return err + +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationSession) ValidateInputHash(inputIndex *big.Int, inputHash [32]byte) error { + return _IApplication.Contract.ValidateInputHash(&_IApplication.CallOpts, inputIndex, inputHash) +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationCallerSession) ValidateInputHash(inputIndex *big.Int, inputHash [32]byte) error { + return _IApplication.Contract.ValidateInputHash(&_IApplication.CallOpts, inputIndex, inputHash) +} + // ValidateOutput is a free data retrieval call binding the contract method 0xe88d39c0. // // Solidity: function validateOutput(bytes output, (uint64,bytes32[]) proof) view returns() @@ -887,6 +1028,37 @@ func (_IApplication *IApplicationCallerSession) WasOutputExecuted(outputIndex *b return _IApplication.Contract.WasOutputExecuted(&_IApplication.CallOpts, outputIndex) } +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationCaller) WasRefundForInputIssued(opts *bind.CallOpts, inputIndex *big.Int) (bool, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "wasRefundForInputIssued", inputIndex) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationSession) WasRefundForInputIssued(inputIndex *big.Int) (bool, error) { + return _IApplication.Contract.WasRefundForInputIssued(&_IApplication.CallOpts, inputIndex) +} + +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationCallerSession) WasRefundForInputIssued(inputIndex *big.Int) (bool, error) { + return _IApplication.Contract.WasRefundForInputIssued(&_IApplication.CallOpts, inputIndex) +} + // WereAccountFundsWithdrawn is a free data retrieval call binding the contract method 0x8272a6aa. // // Solidity: function wereAccountFundsWithdrawn(uint256 accountIndex) view returns(bool) @@ -960,6 +1132,27 @@ func (_IApplication *IApplicationTransactorSession) Foreclose() (*types.Transact return _IApplication.Contract.Foreclose(&_IApplication.TransactOpts) } +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationTransactor) IssueRefund(opts *bind.TransactOpts, inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "issueRefund", inputIndex, input) +} + +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationSession) IssueRefund(inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.Contract.IssueRefund(&_IApplication.TransactOpts, inputIndex, input) +} + +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationTransactorSession) IssueRefund(inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.Contract.IssueRefund(&_IApplication.TransactOpts, inputIndex, input) +} + // MigrateToOutputsMerkleRootValidator is a paid mutator transaction binding the contract method 0xbf8abff8. // // Solidity: function migrateToOutputsMerkleRootValidator(address newOutputsMerkleRootValidator) returns() @@ -1408,10 +1601,15 @@ type IApplicationOutputExecuted struct { // FilterOutputExecuted is a free log retrieval operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. // -// Solidity: event OutputExecuted(uint64 outputIndex, bytes output) -func (_IApplication *IApplicationFilterer) FilterOutputExecuted(opts *bind.FilterOpts) (*IApplicationOutputExecutedIterator, error) { +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) +func (_IApplication *IApplicationFilterer) FilterOutputExecuted(opts *bind.FilterOpts, outputIndex []uint64) (*IApplicationOutputExecutedIterator, error) { + + var outputIndexRule []interface{} + for _, outputIndexItem := range outputIndex { + outputIndexRule = append(outputIndexRule, outputIndexItem) + } - logs, sub, err := _IApplication.contract.FilterLogs(opts, "OutputExecuted") + logs, sub, err := _IApplication.contract.FilterLogs(opts, "OutputExecuted", outputIndexRule) if err != nil { return nil, err } @@ -1420,10 +1618,15 @@ func (_IApplication *IApplicationFilterer) FilterOutputExecuted(opts *bind.Filte // WatchOutputExecuted is a free log subscription operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. // -// Solidity: event OutputExecuted(uint64 outputIndex, bytes output) -func (_IApplication *IApplicationFilterer) WatchOutputExecuted(opts *bind.WatchOpts, sink chan<- *IApplicationOutputExecuted) (event.Subscription, error) { +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) +func (_IApplication *IApplicationFilterer) WatchOutputExecuted(opts *bind.WatchOpts, sink chan<- *IApplicationOutputExecuted, outputIndex []uint64) (event.Subscription, error) { + + var outputIndexRule []interface{} + for _, outputIndexItem := range outputIndex { + outputIndexRule = append(outputIndexRule, outputIndexItem) + } - logs, sub, err := _IApplication.contract.WatchLogs(opts, "OutputExecuted") + logs, sub, err := _IApplication.contract.WatchLogs(opts, "OutputExecuted", outputIndexRule) if err != nil { return nil, err } @@ -1457,7 +1660,7 @@ func (_IApplication *IApplicationFilterer) WatchOutputExecuted(opts *bind.WatchO // ParseOutputExecuted is a log parse operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. // -// Solidity: event OutputExecuted(uint64 outputIndex, bytes output) +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) func (_IApplication *IApplicationFilterer) ParseOutputExecuted(log types.Log) (*IApplicationOutputExecuted, error) { event := new(IApplicationOutputExecuted) if err := _IApplication.contract.UnpackLog(event, "OutputExecuted", log); err != nil { @@ -1601,6 +1804,152 @@ func (_IApplication *IApplicationFilterer) ParseOutputsMerkleRootValidatorChange return event, nil } +// IApplicationRefundIssuedIterator is returned from FilterRefundIssued and is used to iterate over the raw logs and unpacked data for RefundIssued events raised by the IApplication contract. +type IApplicationRefundIssuedIterator struct { + Event *IApplicationRefundIssued // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationRefundIssuedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationRefundIssued) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationRefundIssued) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationRefundIssuedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationRefundIssuedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationRefundIssued represents a RefundIssued event raised by the IApplication contract. +type IApplicationRefundIssued struct { + InputIndex *big.Int + Input []byte + Output []byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRefundIssued is a free log retrieval operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) FilterRefundIssued(opts *bind.FilterOpts, inputIndex []*big.Int) (*IApplicationRefundIssuedIterator, error) { + + var inputIndexRule []interface{} + for _, inputIndexItem := range inputIndex { + inputIndexRule = append(inputIndexRule, inputIndexItem) + } + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "RefundIssued", inputIndexRule) + if err != nil { + return nil, err + } + return &IApplicationRefundIssuedIterator{contract: _IApplication.contract, event: "RefundIssued", logs: logs, sub: sub}, nil +} + +// WatchRefundIssued is a free log subscription operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) WatchRefundIssued(opts *bind.WatchOpts, sink chan<- *IApplicationRefundIssued, inputIndex []*big.Int) (event.Subscription, error) { + + var inputIndexRule []interface{} + for _, inputIndexItem := range inputIndex { + inputIndexRule = append(inputIndexRule, inputIndexItem) + } + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "RefundIssued", inputIndexRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationRefundIssued) + if err := _IApplication.contract.UnpackLog(event, "RefundIssued", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRefundIssued is a log parse operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) ParseRefundIssued(log types.Log) (*IApplicationRefundIssued, error) { + event := new(IApplicationRefundIssued) + if err := _IApplication.contract.UnpackLog(event, "RefundIssued", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // IApplicationWithdrawalIterator is returned from FilterWithdrawal and is used to iterate over the raw logs and unpacked data for Withdrawal events raised by the IApplication contract. type IApplicationWithdrawalIterator struct { Event *IApplicationWithdrawal // Event containing the contract specifics and raw log @@ -1678,10 +2027,15 @@ type IApplicationWithdrawal struct { // FilterWithdrawal is a free log retrieval operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. // -// Solidity: event Withdrawal(uint64 accountIndex, bytes account, bytes output) -func (_IApplication *IApplicationFilterer) FilterWithdrawal(opts *bind.FilterOpts) (*IApplicationWithdrawalIterator, error) { +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) +func (_IApplication *IApplicationFilterer) FilterWithdrawal(opts *bind.FilterOpts, accountIndex []uint64) (*IApplicationWithdrawalIterator, error) { - logs, sub, err := _IApplication.contract.FilterLogs(opts, "Withdrawal") + var accountIndexRule []interface{} + for _, accountIndexItem := range accountIndex { + accountIndexRule = append(accountIndexRule, accountIndexItem) + } + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "Withdrawal", accountIndexRule) if err != nil { return nil, err } @@ -1690,10 +2044,15 @@ func (_IApplication *IApplicationFilterer) FilterWithdrawal(opts *bind.FilterOpt // WatchWithdrawal is a free log subscription operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. // -// Solidity: event Withdrawal(uint64 accountIndex, bytes account, bytes output) -func (_IApplication *IApplicationFilterer) WatchWithdrawal(opts *bind.WatchOpts, sink chan<- *IApplicationWithdrawal) (event.Subscription, error) { +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) +func (_IApplication *IApplicationFilterer) WatchWithdrawal(opts *bind.WatchOpts, sink chan<- *IApplicationWithdrawal, accountIndex []uint64) (event.Subscription, error) { + + var accountIndexRule []interface{} + for _, accountIndexItem := range accountIndex { + accountIndexRule = append(accountIndexRule, accountIndexItem) + } - logs, sub, err := _IApplication.contract.WatchLogs(opts, "Withdrawal") + logs, sub, err := _IApplication.contract.WatchLogs(opts, "Withdrawal", accountIndexRule) if err != nil { return nil, err } @@ -1727,7 +2086,7 @@ func (_IApplication *IApplicationFilterer) WatchWithdrawal(opts *bind.WatchOpts, // ParseWithdrawal is a log parse operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. // -// Solidity: event Withdrawal(uint64 accountIndex, bytes account, bytes output) +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) func (_IApplication *IApplicationFilterer) ParseWithdrawal(log types.Log) (*IApplicationWithdrawal, error) { event := new(IApplicationWithdrawal) if err := _IApplication.contract.UnpackLog(event, "Withdrawal", log); err != nil { diff --git a/pkg/contracts/iapplicationfactory/iapplicationfactory.go b/pkg/contracts/iapplicationfactory/iapplicationfactory.go index dc871c872..1b7899d7f 100644 --- a/pkg/contracts/iapplicationfactory/iapplicationfactory.go +++ b/pkg/contracts/iapplicationfactory/iapplicationfactory.go @@ -40,7 +40,7 @@ type WithdrawalConfig struct { // IApplicationFactoryMetaData contains all meta data concerning the IApplicationFactory contract. var IApplicationFactoryMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"calculateApplicationAddress\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ApplicationCreated\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"calculateApplicationAddress\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ApplicationCreated\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]}]", } // IApplicationFactoryABI is the input ABI used to generate the binding from. @@ -189,12 +189,12 @@ func (_IApplicationFactory *IApplicationFactoryTransactorRaw) Transact(opts *bin return _IApplicationFactory.Contract.contract.Transact(opts, method, params...) } -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddress(opts *bind.CallOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddress(opts *bind.CallOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { var out []interface{} - err := _IApplicationFactory.contract.Call(opts, &out, "calculateApplicationAddress", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) + err := _IApplicationFactory.contract.Call(opts, &out, "calculateApplicationAddress", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) if err != nil { return *new(common.Address), err @@ -206,18 +206,18 @@ func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddre } -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactorySession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { - return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactorySession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { + return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactoryCallerSession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { - return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactoryCallerSession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { + return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } // Version is a free data retrieval call binding the contract method 0x54fd4d50. @@ -280,46 +280,46 @@ func (_IApplicationFactory *IApplicationFactoryCallerSession) Version() (struct return _IApplicationFactory.Contract.Version(&_IApplicationFactory.CallOpts) } -// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { - return _IApplicationFactory.contract.Transact(opts, "newApplication", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.contract.Transact(opts, "newApplication", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) } -// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) -func (_IApplicationFactory *IApplicationFactorySession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactorySession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) } -// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication0(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.contract.Transact(opts, "newApplication0", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication0(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.contract.Transact(opts, "newApplication0", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactorySession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactorySession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } // IApplicationFactoryApplicationCreatedIterator is returned from FilterApplicationCreated and is used to iterate over the raw logs and unpacked data for ApplicationCreated events raised by the IApplicationFactory contract. @@ -394,15 +394,15 @@ type IApplicationFactoryApplicationCreated struct { OutputsMerkleRootValidator common.Address AppOwner common.Address TemplateHash [32]byte - DataAvailability []byte + InputBox common.Address WithdrawalConfig WithdrawalConfig AppContract common.Address Raw types.Log // Blockchain specific contextual infos } -// FilterApplicationCreated is a free log retrieval operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. +// FilterApplicationCreated is a free log retrieval operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) func (_IApplicationFactory *IApplicationFactoryFilterer) FilterApplicationCreated(opts *bind.FilterOpts, outputsMerkleRootValidator []common.Address) (*IApplicationFactoryApplicationCreatedIterator, error) { var outputsMerkleRootValidatorRule []interface{} @@ -417,9 +417,9 @@ func (_IApplicationFactory *IApplicationFactoryFilterer) FilterApplicationCreate return &IApplicationFactoryApplicationCreatedIterator{contract: _IApplicationFactory.contract, event: "ApplicationCreated", logs: logs, sub: sub}, nil } -// WatchApplicationCreated is a free log subscription operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. +// WatchApplicationCreated is a free log subscription operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) func (_IApplicationFactory *IApplicationFactoryFilterer) WatchApplicationCreated(opts *bind.WatchOpts, sink chan<- *IApplicationFactoryApplicationCreated, outputsMerkleRootValidator []common.Address) (event.Subscription, error) { var outputsMerkleRootValidatorRule []interface{} @@ -459,9 +459,9 @@ func (_IApplicationFactory *IApplicationFactoryFilterer) WatchApplicationCreated }), nil } -// ParseApplicationCreated is a log parse operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. +// ParseApplicationCreated is a log parse operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) func (_IApplicationFactory *IApplicationFactoryFilterer) ParseApplicationCreated(log types.Log) (*IApplicationFactoryApplicationCreated, error) { event := new(IApplicationFactoryApplicationCreated) if err := _IApplicationFactory.contract.UnpackLog(event, "ApplicationCreated", log); err != nil { diff --git a/pkg/contracts/iauthority/iauthority.go b/pkg/contracts/iauthority/iauthority.go index 3e3388c52..dd4526b14 100644 --- a/pkg/contracts/iauthority/iauthority.go +++ b/pkg/contracts/iauthority/iauthority.go @@ -36,9 +36,22 @@ type IConsensusClaim struct { StagedOutputsMerkleRoot [32]byte } +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + // IAuthorityMetaData contains all meta data concerning the IAuthority contract. var IAuthorityMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProofSize\",\"inputs\":[{\"name\":\"suppliedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", } // IAuthorityABI is the input ABI used to generate the binding from. @@ -557,6 +570,37 @@ func (_IAuthority *IAuthorityCallerSession) Version() (struct { return _IAuthority.Contract.Version(&_IAuthority.CallOpts) } +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthorityCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthoritySession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IAuthority.Contract.WasInputFinalized(&_IAuthority.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthorityCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IAuthority.Contract.WasInputFinalized(&_IAuthority.CallOpts, appContract, inputIndex, blockNumber) +} + // AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. // // Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() @@ -599,25 +643,25 @@ func (_IAuthority *IAuthorityTransactorSession) RenounceOwnership() (*types.Tran return _IAuthority.Contract.RenounceOwnership(&_IAuthority.TransactOpts) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IAuthority *IAuthorityTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IAuthority.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthorityTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IAuthority *IAuthoritySession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthoritySession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IAuthority *IAuthorityTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthorityTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } // TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. diff --git a/pkg/contracts/iconsensus/iconsensus.go b/pkg/contracts/iconsensus/iconsensus.go index 981a7a55e..65686854d 100644 --- a/pkg/contracts/iconsensus/iconsensus.go +++ b/pkg/contracts/iconsensus/iconsensus.go @@ -36,9 +36,22 @@ type IConsensusClaim struct { StagedOutputsMerkleRoot [32]byte } +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + // IConsensusMetaData contains all meta data concerning the IConsensus contract. var IConsensusMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProofSize\",\"inputs\":[{\"name\":\"suppliedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", } // IConsensusABI is the input ABI used to generate the binding from. @@ -526,6 +539,37 @@ func (_IConsensus *IConsensusCallerSession) Version() (struct { return _IConsensus.Contract.Version(&_IConsensus.CallOpts) } +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IConsensus.Contract.WasInputFinalized(&_IConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IConsensus.Contract.WasInputFinalized(&_IConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + // AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. // // Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() @@ -547,25 +591,25 @@ func (_IConsensus *IConsensusTransactorSession) AcceptClaim(appContract common.A return _IConsensus.Contract.AcceptClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IConsensus *IConsensusTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IConsensus.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IConsensus *IConsensusSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IConsensus *IConsensusTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } // IConsensusClaimAcceptedIterator is returned from FilterClaimAccepted and is used to iterate over the raw logs and unpacked data for ClaimAccepted events raised by the IConsensus contract. diff --git a/pkg/contracts/idaveappfactory/idaveappfactory.go b/pkg/contracts/idaveappfactory/idaveappfactory.go index 5590683e0..ae2621e60 100644 --- a/pkg/contracts/idaveappfactory/idaveappfactory.go +++ b/pkg/contracts/idaveappfactory/idaveappfactory.go @@ -40,7 +40,7 @@ type WithdrawalConfig struct { // IDaveAppFactoryMetaData contains all meta data concerning the IDaveAppFactory contract. var IDaveAppFactoryMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"calculateDaveAppAddress\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContractAddress\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"daveConsensusAddress\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newDaveApp\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"internalType\":\"contractIDaveConsensus\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"DaveAppCreated\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIDaveConsensus\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"calculateDaveAppAddress\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryManager\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"sentries\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContractAddress\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"daveConsensusAddress\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newDaveApp\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryManager\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"sentries\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"internalType\":\"contractIDaveConsensus\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"DaveAppCreated\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIDaveConsensus\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"DuplicatedSentryAddress\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]},{\"type\":\"error\",\"name\":\"ZeroSentryAddress\",\"inputs\":[]}]", } // IDaveAppFactoryABI is the input ABI used to generate the binding from. @@ -189,15 +189,15 @@ func (_IDaveAppFactory *IDaveAppFactoryTransactorRaw) Transact(opts *bind.Transa return _IDaveAppFactory.Contract.contract.Transact(opts, method, params...) } -// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bind.CallOpts, templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bind.CallOpts, templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { var out []interface{} - err := _IDaveAppFactory.contract.Call(opts, &out, "calculateDaveAppAddress", templateHash, withdrawalConfig, salt) + err := _IDaveAppFactory.contract.Call(opts, &out, "calculateDaveAppAddress", templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) outstruct := new(struct { AppContractAddress common.Address @@ -214,45 +214,45 @@ func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bin } -// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactorySession) CalculateDaveAppAddress(templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactorySession) CalculateDaveAppAddress(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { - return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, withdrawalConfig, salt) + return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } -// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactoryCallerSession) CalculateDaveAppAddress(templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactoryCallerSession) CalculateDaveAppAddress(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { - return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, withdrawalConfig, salt) + return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. // -// Solidity: function newDaveApp(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactoryTransactor) NewDaveApp(opts *bind.TransactOpts, templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.contract.Transact(opts, "newDaveApp", templateHash, withdrawalConfig, salt) +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryTransactor) NewDaveApp(opts *bind.TransactOpts, templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.contract.Transact(opts, "newDaveApp", templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. // -// Solidity: function newDaveApp(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactorySession) NewDaveApp(templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, withdrawalConfig, salt) +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactorySession) NewDaveApp(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. // -// Solidity: function newDaveApp(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactoryTransactorSession) NewDaveApp(templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, withdrawalConfig, salt) +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryTransactorSession) NewDaveApp(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } // IDaveAppFactoryDaveAppCreatedIterator is returned from FilterDaveAppCreated and is used to iterate over the raw logs and unpacked data for DaveAppCreated events raised by the IDaveAppFactory contract. diff --git a/pkg/contracts/idaveconsensus/idaveconsensus.go b/pkg/contracts/idaveconsensus/idaveconsensus.go index f920a1fc7..a4b09abb6 100644 --- a/pkg/contracts/idaveconsensus/idaveconsensus.go +++ b/pkg/contracts/idaveconsensus/idaveconsensus.go @@ -29,9 +29,22 @@ var ( _ = abi.ConvertType ) +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + // IDaveConsensusMetaData contains all meta data concerning the IDaveConsensus contract. var IDaveConsensusMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"canSettle\",\"inputs\":[],\"outputs\":[{\"name\":\"isFinished\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"winnerCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApplicationContract\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getCurrentSealedEpoch\",\"inputs\":[],\"outputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"tournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTournamentFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournamentFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"provideMerkleRootOfInput\",\"inputs\":[{\"name\":\"inputIndexWithinEpoch\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"settle\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ConsensusCreation\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIInputBox\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"tournamentFactory\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournamentFactory\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EpochSealed\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"initialMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"tournament\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"received\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IncorrectEpochNumber\",\"inputs\":[{\"name\":\"received\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"actual\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InputHashMismatch\",\"inputs\":[{\"name\":\"fromReceivedInput\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"fromInputBox\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProof\",\"inputs\":[{\"name\":\"settledState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProofSize\",\"inputs\":[{\"name\":\"suppliedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"TournamentNotFinishedYet\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"acceptStagedTournamentResult\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"canAcceptStagedTournamentResult\",\"inputs\":[],\"outputs\":[{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"doAllSentriesAgreeWithStagedTournamentResult\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isClaimStagingPeriodOver\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canStageTournamentResult\",\"inputs\":[],\"outputs\":[{\"name\":\"isFinished\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isTournamentFailed\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"winnerCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"winnerPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApplicationContract\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getCurrentSealedEpoch\",\"inputs\":[],\"outputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"tournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSentries\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryById\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryClaimCount\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryId\",\"inputs\":[{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryManager\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTournamentFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournamentFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"hasSentryClaimedInEpoch\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"provideMerkleRootOfInput\",\"inputs\":[{\"name\":\"inputIndexWithinEpoch\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"rotateSentry\",\"inputs\":[{\"name\":\"currentSentry\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"newSentry\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stageTournamentResult\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"submitSentryClaim\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ConsensusCreation\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIInputBox\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"tournamentFactory\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournamentFactory\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EpochSealed\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"initialMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"tournament\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EpochStaged\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SentryClaim\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SentryRotation\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"oldSentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newSentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"received\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotSentry\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotSentryManager\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"CannotRotateNonSentry\",\"inputs\":[{\"name\":\"nonSentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DuplicatedSentryAddress\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IncorrectEpochNumber\",\"inputs\":[{\"name\":\"received\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"actual\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InputHashMismatch\",\"inputs\":[{\"name\":\"fromReceivedInput\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"fromInputBox\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SentryAlreadyClaimed\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"TournamentNotFinishedYet\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentResultAlreadyStaged\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentResultNotStaged\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ZeroSentryAddress\",\"inputs\":[]}]", } // IDaveConsensusABI is the input ABI used to generate the binding from. @@ -180,54 +193,134 @@ func (_IDaveConsensus *IDaveConsensusTransactorRaw) Transact(opts *bind.Transact return _IDaveConsensus.Contract.contract.Transact(opts, method, params...) } -// CanSettle is a free data retrieval call binding the contract method 0xfaf7ba6a. +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusCaller) CanAcceptStagedTournamentResult(opts *bind.CallOpts) (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "canAcceptStagedTournamentResult") + + outstruct := new(struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.DoAllSentriesAgreeWithStagedTournamentResult = *abi.ConvertType(out[1], new(bool)).(*bool) + outstruct.IsClaimStagingPeriodOver = *abi.ConvertType(out[2], new(bool)).(*bool) + outstruct.EpochNumber = *abi.ConvertType(out[3], new(*big.Int)).(**big.Int) + outstruct.StagedPostEpochMachineStateHash = *abi.ConvertType(out[4], new([32]byte)).(*[32]byte) + outstruct.StagedPostEpochOutputsMerkleRoot = *abi.ConvertType(out[5], new([32]byte)).(*[32]byte) + + return *outstruct, err + +} + +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusSession) CanAcceptStagedTournamentResult() (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + return _IDaveConsensus.Contract.CanAcceptStagedTournamentResult(&_IDaveConsensus.CallOpts) +} + +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusCallerSession) CanAcceptStagedTournamentResult() (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + return _IDaveConsensus.Contract.CanAcceptStagedTournamentResult(&_IDaveConsensus.CallOpts) +} + +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. // -// Solidity: function canSettle() view returns(bool isFinished, uint256 epochNumber, bytes32 winnerCommitment) -func (_IDaveConsensus *IDaveConsensusCaller) CanSettle(opts *bind.CallOpts) (struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusCaller) CanStageTournamentResult(opts *bind.CallOpts) (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte }, error) { var out []interface{} - err := _IDaveConsensus.contract.Call(opts, &out, "canSettle") + err := _IDaveConsensus.contract.Call(opts, &out, "canStageTournamentResult") outstruct := new(struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte }) if err != nil { return *outstruct, err } outstruct.IsFinished = *abi.ConvertType(out[0], new(bool)).(*bool) - outstruct.EpochNumber = *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) - outstruct.WinnerCommitment = *abi.ConvertType(out[2], new([32]byte)).(*[32]byte) + outstruct.IsTournamentFailed = *abi.ConvertType(out[1], new(bool)).(*bool) + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[2], new(bool)).(*bool) + outstruct.EpochNumber = *abi.ConvertType(out[3], new(*big.Int)).(**big.Int) + outstruct.WinnerCommitment = *abi.ConvertType(out[4], new([32]byte)).(*[32]byte) + outstruct.WinnerPostEpochMachineStateHash = *abi.ConvertType(out[5], new([32]byte)).(*[32]byte) return *outstruct, err } -// CanSettle is a free data retrieval call binding the contract method 0xfaf7ba6a. +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. // -// Solidity: function canSettle() view returns(bool isFinished, uint256 epochNumber, bytes32 winnerCommitment) -func (_IDaveConsensus *IDaveConsensusSession) CanSettle() (struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusSession) CanStageTournamentResult() (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte }, error) { - return _IDaveConsensus.Contract.CanSettle(&_IDaveConsensus.CallOpts) + return _IDaveConsensus.Contract.CanStageTournamentResult(&_IDaveConsensus.CallOpts) } -// CanSettle is a free data retrieval call binding the contract method 0xfaf7ba6a. +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. // -// Solidity: function canSettle() view returns(bool isFinished, uint256 epochNumber, bytes32 winnerCommitment) -func (_IDaveConsensus *IDaveConsensusCallerSession) CanSettle() (struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusCallerSession) CanStageTournamentResult() (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte }, error) { - return _IDaveConsensus.Contract.CanSettle(&_IDaveConsensus.CallOpts) + return _IDaveConsensus.Contract.CanStageTournamentResult(&_IDaveConsensus.CallOpts) } // GetApplicationContract is a free data retrieval call binding the contract method 0xc050be00. @@ -261,23 +354,62 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) GetApplicationContract() (co return _IDaveConsensus.Contract.GetApplicationContract(&_IDaveConsensus.CallOpts) } +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getClaimStagingPeriod") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IDaveConsensus.Contract.GetClaimStagingPeriod(&_IDaveConsensus.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IDaveConsensus.Contract.GetClaimStagingPeriod(&_IDaveConsensus.CallOpts) +} + // GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. // -// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament) +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) func (_IDaveConsensus *IDaveConsensusCaller) GetCurrentSealedEpoch(opts *bind.CallOpts) (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte }, error) { var out []interface{} err := _IDaveConsensus.contract.Call(opts, &out, "getCurrentSealedEpoch") outstruct := new(struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte }) if err != nil { return *outstruct, err @@ -287,6 +419,10 @@ func (_IDaveConsensus *IDaveConsensusCaller) GetCurrentSealedEpoch(opts *bind.Ca outstruct.InputIndexLowerBound = *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) outstruct.InputIndexUpperBound = *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) outstruct.Tournament = *abi.ConvertType(out[3], new(common.Address)).(*common.Address) + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[4], new(bool)).(*bool) + outstruct.StagingBlockNumber = *abi.ConvertType(out[5], new(*big.Int)).(**big.Int) + outstruct.StagedPostEpochMachineStateHash = *abi.ConvertType(out[6], new([32]byte)).(*[32]byte) + outstruct.StagedPostEpochOutputsMerkleRoot = *abi.ConvertType(out[7], new([32]byte)).(*[32]byte) return *outstruct, err @@ -294,24 +430,32 @@ func (_IDaveConsensus *IDaveConsensusCaller) GetCurrentSealedEpoch(opts *bind.Ca // GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. // -// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament) +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) func (_IDaveConsensus *IDaveConsensusSession) GetCurrentSealedEpoch() (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte }, error) { return _IDaveConsensus.Contract.GetCurrentSealedEpoch(&_IDaveConsensus.CallOpts) } // GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. // -// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament) +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) func (_IDaveConsensus *IDaveConsensusCallerSession) GetCurrentSealedEpoch() (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte }, error) { return _IDaveConsensus.Contract.GetCurrentSealedEpoch(&_IDaveConsensus.CallOpts) } @@ -409,6 +553,161 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) GetLastFinalizedMachineMerkl return _IDaveConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IDaveConsensus.CallOpts, appContract) } +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetNumberOfSentries(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getNumberOfSentries") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetNumberOfSentries() (*big.Int, error) { + return _IDaveConsensus.Contract.GetNumberOfSentries(&_IDaveConsensus.CallOpts) +} + +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetNumberOfSentries() (*big.Int, error) { + return _IDaveConsensus.Contract.GetNumberOfSentries(&_IDaveConsensus.CallOpts) +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryById(opts *bind.CallOpts, sentryId *big.Int) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryById", sentryId) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryById(sentryId *big.Int) (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryById(&_IDaveConsensus.CallOpts, sentryId) +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryById(sentryId *big.Int) (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryById(&_IDaveConsensus.CallOpts, sentryId) +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryClaimCount(opts *bind.CallOpts, epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryClaimCount", epochNumber, postEpochMachineStateHash) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryClaimCount(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryClaimCount(&_IDaveConsensus.CallOpts, epochNumber, postEpochMachineStateHash) +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryClaimCount(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryClaimCount(&_IDaveConsensus.CallOpts, epochNumber, postEpochMachineStateHash) +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryId(opts *bind.CallOpts, sentry common.Address) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryId", sentry) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryId(sentry common.Address) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryId(&_IDaveConsensus.CallOpts, sentry) +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryId(sentry common.Address) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryId(&_IDaveConsensus.CallOpts, sentry) +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryManager(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryManager") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryManager() (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryManager(&_IDaveConsensus.CallOpts) +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryManager() (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryManager(&_IDaveConsensus.CallOpts) +} + // GetTournamentFactory is a free data retrieval call binding the contract method 0x813a1aaf. // // Solidity: function getTournamentFactory() view returns(address) @@ -440,6 +739,37 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) GetTournamentFactory() (comm return _IDaveConsensus.Contract.GetTournamentFactory(&_IDaveConsensus.CallOpts) } +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCaller) HasSentryClaimedInEpoch(opts *bind.CallOpts, epochNumber *big.Int, sentryId *big.Int) (bool, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "hasSentryClaimedInEpoch", epochNumber, sentryId) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusSession) HasSentryClaimedInEpoch(epochNumber *big.Int, sentryId *big.Int) (bool, error) { + return _IDaveConsensus.Contract.HasSentryClaimedInEpoch(&_IDaveConsensus.CallOpts, epochNumber, sentryId) +} + +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCallerSession) HasSentryClaimedInEpoch(epochNumber *big.Int, sentryId *big.Int) (bool, error) { + return _IDaveConsensus.Contract.HasSentryClaimedInEpoch(&_IDaveConsensus.CallOpts, epochNumber, sentryId) +} + // IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. // // Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) @@ -533,25 +863,119 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) SupportsInterface(interfaceI return _IDaveConsensus.Contract.SupportsInterface(&_IDaveConsensus.CallOpts, interfaceId) } -// Settle is a paid mutator transaction binding the contract method 0x8bca2e0c. +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IDaveConsensus.Contract.WasInputFinalized(&_IDaveConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IDaveConsensus.Contract.WasInputFinalized(&_IDaveConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) AcceptStagedTournamentResult(opts *bind.TransactOpts, epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "acceptStagedTournamentResult", epochNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusSession) AcceptStagedTournamentResult(epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.Contract.AcceptStagedTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) AcceptStagedTournamentResult(epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.Contract.AcceptStagedTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) RotateSentry(opts *bind.TransactOpts, currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "rotateSentry", currentSentry, newSentry) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusSession) RotateSentry(currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.Contract.RotateSentry(&_IDaveConsensus.TransactOpts, currentSentry, newSentry) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) RotateSentry(currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.Contract.RotateSentry(&_IDaveConsensus.TransactOpts, currentSentry, newSentry) +} + +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. +// +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) StageTournamentResult(opts *bind.TransactOpts, epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "stageTournamentResult", epochNumber, proof) +} + +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. // -// Solidity: function settle(uint256 epochNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IDaveConsensus *IDaveConsensusTransactor) Settle(opts *bind.TransactOpts, epochNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IDaveConsensus.contract.Transact(opts, "settle", epochNumber, outputsMerkleRoot, proof) +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusSession) StageTournamentResult(epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.Contract.StageTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber, proof) } -// Settle is a paid mutator transaction binding the contract method 0x8bca2e0c. +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. // -// Solidity: function settle(uint256 epochNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IDaveConsensus *IDaveConsensusSession) Settle(epochNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IDaveConsensus.Contract.Settle(&_IDaveConsensus.TransactOpts, epochNumber, outputsMerkleRoot, proof) +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) StageTournamentResult(epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.Contract.StageTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber, proof) } -// Settle is a paid mutator transaction binding the contract method 0x8bca2e0c. +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. // -// Solidity: function settle(uint256 epochNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IDaveConsensus *IDaveConsensusTransactorSession) Settle(epochNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IDaveConsensus.Contract.Settle(&_IDaveConsensus.TransactOpts, epochNumber, outputsMerkleRoot, proof) +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) SubmitSentryClaim(opts *bind.TransactOpts, epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "submitSentryClaim", epochNumber, postEpochMachineStateHash) +} + +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. +// +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusSession) SubmitSentryClaim(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.Contract.SubmitSentryClaim(&_IDaveConsensus.TransactOpts, epochNumber, postEpochMachineStateHash) +} + +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. +// +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) SubmitSentryClaim(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.Contract.SubmitSentryClaim(&_IDaveConsensus.TransactOpts, epochNumber, postEpochMachineStateHash) } // IDaveConsensusConsensusCreationIterator is returned from FilterConsensusCreation and is used to iterate over the raw logs and unpacked data for ConsensusCreation events raised by the IDaveConsensus contract. @@ -770,10 +1194,15 @@ type IDaveConsensusEpochSealed struct { // FilterEpochSealed is a free log retrieval operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. // -// Solidity: event EpochSealed(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) -func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochSealed(opts *bind.FilterOpts) (*IDaveConsensusEpochSealedIterator, error) { +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochSealed(opts *bind.FilterOpts, epochNumber []*big.Int) (*IDaveConsensusEpochSealedIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } - logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "EpochSealed") + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "EpochSealed", epochNumberRule) if err != nil { return nil, err } @@ -782,10 +1211,15 @@ func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochSealed(opts *bind.Filt // WatchEpochSealed is a free log subscription operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. // -// Solidity: event EpochSealed(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) -func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochSealed(opts *bind.WatchOpts, sink chan<- *IDaveConsensusEpochSealed) (event.Subscription, error) { +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochSealed(opts *bind.WatchOpts, sink chan<- *IDaveConsensusEpochSealed, epochNumber []*big.Int) (event.Subscription, error) { - logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "EpochSealed") + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "EpochSealed", epochNumberRule) if err != nil { return nil, err } @@ -819,7 +1253,7 @@ func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochSealed(opts *bind.Watch // ParseEpochSealed is a log parse operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. // -// Solidity: event EpochSealed(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) func (_IDaveConsensus *IDaveConsensusFilterer) ParseEpochSealed(log types.Log) (*IDaveConsensusEpochSealed, error) { event := new(IDaveConsensusEpochSealed) if err := _IDaveConsensus.contract.UnpackLog(event, "EpochSealed", log); err != nil { @@ -828,3 +1262,474 @@ func (_IDaveConsensus *IDaveConsensusFilterer) ParseEpochSealed(log types.Log) ( event.Raw = log return event, nil } + +// IDaveConsensusEpochStagedIterator is returned from FilterEpochStaged and is used to iterate over the raw logs and unpacked data for EpochStaged events raised by the IDaveConsensus contract. +type IDaveConsensusEpochStagedIterator struct { + Event *IDaveConsensusEpochStaged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusEpochStagedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusEpochStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusEpochStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusEpochStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusEpochStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusEpochStaged represents a EpochStaged event raised by the IDaveConsensus contract. +type IDaveConsensusEpochStaged struct { + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterEpochStaged is a free log retrieval operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochStaged(opts *bind.FilterOpts, epochNumber []*big.Int) (*IDaveConsensusEpochStagedIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "EpochStaged", epochNumberRule) + if err != nil { + return nil, err + } + return &IDaveConsensusEpochStagedIterator{contract: _IDaveConsensus.contract, event: "EpochStaged", logs: logs, sub: sub}, nil +} + +// WatchEpochStaged is a free log subscription operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochStaged(opts *bind.WatchOpts, sink chan<- *IDaveConsensusEpochStaged, epochNumber []*big.Int) (event.Subscription, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "EpochStaged", epochNumberRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusEpochStaged) + if err := _IDaveConsensus.contract.UnpackLog(event, "EpochStaged", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseEpochStaged is a log parse operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseEpochStaged(log types.Log) (*IDaveConsensusEpochStaged, error) { + event := new(IDaveConsensusEpochStaged) + if err := _IDaveConsensus.contract.UnpackLog(event, "EpochStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IDaveConsensusSentryClaimIterator is returned from FilterSentryClaim and is used to iterate over the raw logs and unpacked data for SentryClaim events raised by the IDaveConsensus contract. +type IDaveConsensusSentryClaimIterator struct { + Event *IDaveConsensusSentryClaim // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusSentryClaimIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryClaim) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryClaim) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusSentryClaimIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusSentryClaimIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusSentryClaim represents a SentryClaim event raised by the IDaveConsensus contract. +type IDaveConsensusSentryClaim struct { + EpochNumber *big.Int + SentryId *big.Int + Sentry common.Address + PostEpochMachineStateHash [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSentryClaim is a free log retrieval operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterSentryClaim(opts *bind.FilterOpts, epochNumber []*big.Int, sentryId []*big.Int, sentry []common.Address) (*IDaveConsensusSentryClaimIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var sentryRule []interface{} + for _, sentryItem := range sentry { + sentryRule = append(sentryRule, sentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "SentryClaim", epochNumberRule, sentryIdRule, sentryRule) + if err != nil { + return nil, err + } + return &IDaveConsensusSentryClaimIterator{contract: _IDaveConsensus.contract, event: "SentryClaim", logs: logs, sub: sub}, nil +} + +// WatchSentryClaim is a free log subscription operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchSentryClaim(opts *bind.WatchOpts, sink chan<- *IDaveConsensusSentryClaim, epochNumber []*big.Int, sentryId []*big.Int, sentry []common.Address) (event.Subscription, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var sentryRule []interface{} + for _, sentryItem := range sentry { + sentryRule = append(sentryRule, sentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "SentryClaim", epochNumberRule, sentryIdRule, sentryRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusSentryClaim) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryClaim", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSentryClaim is a log parse operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseSentryClaim(log types.Log) (*IDaveConsensusSentryClaim, error) { + event := new(IDaveConsensusSentryClaim) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryClaim", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IDaveConsensusSentryRotationIterator is returned from FilterSentryRotation and is used to iterate over the raw logs and unpacked data for SentryRotation events raised by the IDaveConsensus contract. +type IDaveConsensusSentryRotationIterator struct { + Event *IDaveConsensusSentryRotation // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusSentryRotationIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryRotation) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryRotation) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusSentryRotationIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusSentryRotationIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusSentryRotation represents a SentryRotation event raised by the IDaveConsensus contract. +type IDaveConsensusSentryRotation struct { + SentryId *big.Int + OldSentry common.Address + NewSentry common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSentryRotation is a free log retrieval operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterSentryRotation(opts *bind.FilterOpts, sentryId []*big.Int, oldSentry []common.Address, newSentry []common.Address) (*IDaveConsensusSentryRotationIterator, error) { + + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var oldSentryRule []interface{} + for _, oldSentryItem := range oldSentry { + oldSentryRule = append(oldSentryRule, oldSentryItem) + } + var newSentryRule []interface{} + for _, newSentryItem := range newSentry { + newSentryRule = append(newSentryRule, newSentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "SentryRotation", sentryIdRule, oldSentryRule, newSentryRule) + if err != nil { + return nil, err + } + return &IDaveConsensusSentryRotationIterator{contract: _IDaveConsensus.contract, event: "SentryRotation", logs: logs, sub: sub}, nil +} + +// WatchSentryRotation is a free log subscription operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchSentryRotation(opts *bind.WatchOpts, sink chan<- *IDaveConsensusSentryRotation, sentryId []*big.Int, oldSentry []common.Address, newSentry []common.Address) (event.Subscription, error) { + + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var oldSentryRule []interface{} + for _, oldSentryItem := range oldSentry { + oldSentryRule = append(oldSentryRule, oldSentryItem) + } + var newSentryRule []interface{} + for _, newSentryItem := range newSentry { + newSentryRule = append(newSentryRule, newSentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "SentryRotation", sentryIdRule, oldSentryRule, newSentryRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusSentryRotation) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryRotation", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSentryRotation is a log parse operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseSentryRotation(log types.Log) (*IDaveConsensusSentryRotation, error) { + event := new(IDaveConsensusSentryRotation) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryRotation", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/ierc20portal/ierc20portal.go b/pkg/contracts/ierc20portal/ierc20portal.go index 0c7796da6..e9158e856 100644 --- a/pkg/contracts/ierc20portal/ierc20portal.go +++ b/pkg/contracts/ierc20portal/ierc20portal.go @@ -29,113 +29,113 @@ var ( _ = abi.ConvertType ) -// IERC20PortalMetaData contains all meta data concerning the IERC20Portal contract. -var IERC20PortalMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"depositERC20Tokens\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"contractIERC20\"},{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"execLayerData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"ERC20TransferFailed\",\"inputs\":[]}]", +// IErc20PortalMetaData contains all meta data concerning the IErc20Portal contract. +var IErc20PortalMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"depositErc20Tokens\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"contractIERC20\"},{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"execLayerData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"Erc20TransferDecreasedApplicationBalance\",\"inputs\":[{\"name\":\"balanceBefore\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balanceAfter\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"Erc20TransferFailed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"Erc20TransferValueIsNotBalanceDelta\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balanceDelta\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]}]", } -// IERC20PortalABI is the input ABI used to generate the binding from. -// Deprecated: Use IERC20PortalMetaData.ABI instead. -var IERC20PortalABI = IERC20PortalMetaData.ABI +// IErc20PortalABI is the input ABI used to generate the binding from. +// Deprecated: Use IErc20PortalMetaData.ABI instead. +var IErc20PortalABI = IErc20PortalMetaData.ABI -// IERC20Portal is an auto generated Go binding around an Ethereum contract. -type IERC20Portal struct { - IERC20PortalCaller // Read-only binding to the contract - IERC20PortalTransactor // Write-only binding to the contract - IERC20PortalFilterer // Log filterer for contract events +// IErc20Portal is an auto generated Go binding around an Ethereum contract. +type IErc20Portal struct { + IErc20PortalCaller // Read-only binding to the contract + IErc20PortalTransactor // Write-only binding to the contract + IErc20PortalFilterer // Log filterer for contract events } -// IERC20PortalCaller is an auto generated read-only Go binding around an Ethereum contract. -type IERC20PortalCaller struct { +// IErc20PortalCaller is an auto generated read-only Go binding around an Ethereum contract. +type IErc20PortalCaller struct { contract *bind.BoundContract // Generic contract wrapper for the low level calls } -// IERC20PortalTransactor is an auto generated write-only Go binding around an Ethereum contract. -type IERC20PortalTransactor struct { +// IErc20PortalTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IErc20PortalTransactor struct { contract *bind.BoundContract // Generic contract wrapper for the low level calls } -// IERC20PortalFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type IERC20PortalFilterer struct { +// IErc20PortalFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IErc20PortalFilterer struct { contract *bind.BoundContract // Generic contract wrapper for the low level calls } -// IERC20PortalSession is an auto generated Go binding around an Ethereum contract, +// IErc20PortalSession is an auto generated Go binding around an Ethereum contract, // with pre-set call and transact options. -type IERC20PortalSession struct { - Contract *IERC20Portal // Generic contract binding to set the session for +type IErc20PortalSession struct { + Contract *IErc20Portal // Generic contract binding to set the session for CallOpts bind.CallOpts // Call options to use throughout this session TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session } -// IERC20PortalCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// IErc20PortalCallerSession is an auto generated read-only Go binding around an Ethereum contract, // with pre-set call options. -type IERC20PortalCallerSession struct { - Contract *IERC20PortalCaller // Generic contract caller binding to set the session for +type IErc20PortalCallerSession struct { + Contract *IErc20PortalCaller // Generic contract caller binding to set the session for CallOpts bind.CallOpts // Call options to use throughout this session } -// IERC20PortalTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// IErc20PortalTransactorSession is an auto generated write-only Go binding around an Ethereum contract, // with pre-set transact options. -type IERC20PortalTransactorSession struct { - Contract *IERC20PortalTransactor // Generic contract transactor binding to set the session for +type IErc20PortalTransactorSession struct { + Contract *IErc20PortalTransactor // Generic contract transactor binding to set the session for TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session } -// IERC20PortalRaw is an auto generated low-level Go binding around an Ethereum contract. -type IERC20PortalRaw struct { - Contract *IERC20Portal // Generic contract binding to access the raw methods on +// IErc20PortalRaw is an auto generated low-level Go binding around an Ethereum contract. +type IErc20PortalRaw struct { + Contract *IErc20Portal // Generic contract binding to access the raw methods on } -// IERC20PortalCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type IERC20PortalCallerRaw struct { - Contract *IERC20PortalCaller // Generic read-only contract binding to access the raw methods on +// IErc20PortalCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IErc20PortalCallerRaw struct { + Contract *IErc20PortalCaller // Generic read-only contract binding to access the raw methods on } -// IERC20PortalTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type IERC20PortalTransactorRaw struct { - Contract *IERC20PortalTransactor // Generic write-only contract binding to access the raw methods on +// IErc20PortalTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IErc20PortalTransactorRaw struct { + Contract *IErc20PortalTransactor // Generic write-only contract binding to access the raw methods on } -// NewIERC20Portal creates a new instance of IERC20Portal, bound to a specific deployed contract. -func NewIERC20Portal(address common.Address, backend bind.ContractBackend) (*IERC20Portal, error) { - contract, err := bindIERC20Portal(address, backend, backend, backend) +// NewIErc20Portal creates a new instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20Portal(address common.Address, backend bind.ContractBackend) (*IErc20Portal, error) { + contract, err := bindIErc20Portal(address, backend, backend, backend) if err != nil { return nil, err } - return &IERC20Portal{IERC20PortalCaller: IERC20PortalCaller{contract: contract}, IERC20PortalTransactor: IERC20PortalTransactor{contract: contract}, IERC20PortalFilterer: IERC20PortalFilterer{contract: contract}}, nil + return &IErc20Portal{IErc20PortalCaller: IErc20PortalCaller{contract: contract}, IErc20PortalTransactor: IErc20PortalTransactor{contract: contract}, IErc20PortalFilterer: IErc20PortalFilterer{contract: contract}}, nil } -// NewIERC20PortalCaller creates a new read-only instance of IERC20Portal, bound to a specific deployed contract. -func NewIERC20PortalCaller(address common.Address, caller bind.ContractCaller) (*IERC20PortalCaller, error) { - contract, err := bindIERC20Portal(address, caller, nil, nil) +// NewIErc20PortalCaller creates a new read-only instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalCaller(address common.Address, caller bind.ContractCaller) (*IErc20PortalCaller, error) { + contract, err := bindIErc20Portal(address, caller, nil, nil) if err != nil { return nil, err } - return &IERC20PortalCaller{contract: contract}, nil + return &IErc20PortalCaller{contract: contract}, nil } -// NewIERC20PortalTransactor creates a new write-only instance of IERC20Portal, bound to a specific deployed contract. -func NewIERC20PortalTransactor(address common.Address, transactor bind.ContractTransactor) (*IERC20PortalTransactor, error) { - contract, err := bindIERC20Portal(address, nil, transactor, nil) +// NewIErc20PortalTransactor creates a new write-only instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalTransactor(address common.Address, transactor bind.ContractTransactor) (*IErc20PortalTransactor, error) { + contract, err := bindIErc20Portal(address, nil, transactor, nil) if err != nil { return nil, err } - return &IERC20PortalTransactor{contract: contract}, nil + return &IErc20PortalTransactor{contract: contract}, nil } -// NewIERC20PortalFilterer creates a new log filterer instance of IERC20Portal, bound to a specific deployed contract. -func NewIERC20PortalFilterer(address common.Address, filterer bind.ContractFilterer) (*IERC20PortalFilterer, error) { - contract, err := bindIERC20Portal(address, nil, nil, filterer) +// NewIErc20PortalFilterer creates a new log filterer instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalFilterer(address common.Address, filterer bind.ContractFilterer) (*IErc20PortalFilterer, error) { + contract, err := bindIErc20Portal(address, nil, nil, filterer) if err != nil { return nil, err } - return &IERC20PortalFilterer{contract: contract}, nil + return &IErc20PortalFilterer{contract: contract}, nil } -// bindIERC20Portal binds a generic wrapper to an already deployed contract. -func bindIERC20Portal(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := IERC20PortalMetaData.GetAbi() +// bindIErc20Portal binds a generic wrapper to an already deployed contract. +func bindIErc20Portal(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IErc20PortalMetaData.GetAbi() if err != nil { return nil, err } @@ -146,75 +146,44 @@ func bindIERC20Portal(address common.Address, caller bind.ContractCaller, transa // sets the output to result. The result type might be a single field for simple // returns, a slice of interfaces for anonymous returns and a struct for named // returns. -func (_IERC20Portal *IERC20PortalRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _IERC20Portal.Contract.IERC20PortalCaller.contract.Call(opts, result, method, params...) +func (_IErc20Portal *IErc20PortalRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IErc20Portal.Contract.IErc20PortalCaller.contract.Call(opts, result, method, params...) } // Transfer initiates a plain transaction to move funds to the contract, calling // its default method if one is available. -func (_IERC20Portal *IERC20PortalRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _IERC20Portal.Contract.IERC20PortalTransactor.contract.Transfer(opts) +func (_IErc20Portal *IErc20PortalRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IErc20Portal.Contract.IErc20PortalTransactor.contract.Transfer(opts) } // Transact invokes the (paid) contract method with params as input values. -func (_IERC20Portal *IERC20PortalRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _IERC20Portal.Contract.IERC20PortalTransactor.contract.Transact(opts, method, params...) +func (_IErc20Portal *IErc20PortalRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IErc20Portal.Contract.IErc20PortalTransactor.contract.Transact(opts, method, params...) } // Call invokes the (constant) contract method with params as input values and // sets the output to result. The result type might be a single field for simple // returns, a slice of interfaces for anonymous returns and a struct for named // returns. -func (_IERC20Portal *IERC20PortalCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _IERC20Portal.Contract.contract.Call(opts, result, method, params...) +func (_IErc20Portal *IErc20PortalCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IErc20Portal.Contract.contract.Call(opts, result, method, params...) } // Transfer initiates a plain transaction to move funds to the contract, calling // its default method if one is available. -func (_IERC20Portal *IERC20PortalTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _IERC20Portal.Contract.contract.Transfer(opts) +func (_IErc20Portal *IErc20PortalTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IErc20Portal.Contract.contract.Transfer(opts) } // Transact invokes the (paid) contract method with params as input values. -func (_IERC20Portal *IERC20PortalTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _IERC20Portal.Contract.contract.Transact(opts, method, params...) -} - -// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. -// -// Solidity: function getInputBox() view returns(address) -func (_IERC20Portal *IERC20PortalCaller) GetInputBox(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _IERC20Portal.contract.Call(opts, &out, "getInputBox") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. -// -// Solidity: function getInputBox() view returns(address) -func (_IERC20Portal *IERC20PortalSession) GetInputBox() (common.Address, error) { - return _IERC20Portal.Contract.GetInputBox(&_IERC20Portal.CallOpts) -} - -// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. -// -// Solidity: function getInputBox() view returns(address) -func (_IERC20Portal *IERC20PortalCallerSession) GetInputBox() (common.Address, error) { - return _IERC20Portal.Contract.GetInputBox(&_IERC20Portal.CallOpts) +func (_IErc20Portal *IErc20PortalTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IErc20Portal.Contract.contract.Transact(opts, method, params...) } // Version is a free data retrieval call binding the contract method 0x54fd4d50. // // Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) -func (_IERC20Portal *IERC20PortalCaller) Version(opts *bind.CallOpts) (struct { +func (_IErc20Portal *IErc20PortalCaller) Version(opts *bind.CallOpts) (struct { Major uint64 Minor uint64 Patch uint64 @@ -222,7 +191,7 @@ func (_IERC20Portal *IERC20PortalCaller) Version(opts *bind.CallOpts) (struct { BuildMetadata string }, error) { var out []interface{} - err := _IERC20Portal.contract.Call(opts, &out, "version") + err := _IErc20Portal.contract.Call(opts, &out, "version") outstruct := new(struct { Major uint64 @@ -248,46 +217,46 @@ func (_IERC20Portal *IERC20PortalCaller) Version(opts *bind.CallOpts) (struct { // Version is a free data retrieval call binding the contract method 0x54fd4d50. // // Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) -func (_IERC20Portal *IERC20PortalSession) Version() (struct { +func (_IErc20Portal *IErc20PortalSession) Version() (struct { Major uint64 Minor uint64 Patch uint64 PreRelease string BuildMetadata string }, error) { - return _IERC20Portal.Contract.Version(&_IERC20Portal.CallOpts) + return _IErc20Portal.Contract.Version(&_IErc20Portal.CallOpts) } // Version is a free data retrieval call binding the contract method 0x54fd4d50. // // Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) -func (_IERC20Portal *IERC20PortalCallerSession) Version() (struct { +func (_IErc20Portal *IErc20PortalCallerSession) Version() (struct { Major uint64 Minor uint64 Patch uint64 PreRelease string BuildMetadata string }, error) { - return _IERC20Portal.Contract.Version(&_IERC20Portal.CallOpts) + return _IErc20Portal.Contract.Version(&_IErc20Portal.CallOpts) } -// DepositERC20Tokens is a paid mutator transaction binding the contract method 0x95854b81. +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. // -// Solidity: function depositERC20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() -func (_IERC20Portal *IERC20PortalTransactor) DepositERC20Tokens(opts *bind.TransactOpts, token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { - return _IERC20Portal.contract.Transact(opts, "depositERC20Tokens", token, appContract, value, execLayerData) +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalTransactor) DepositErc20Tokens(opts *bind.TransactOpts, token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.contract.Transact(opts, "depositErc20Tokens", token, appContract, value, execLayerData) } -// DepositERC20Tokens is a paid mutator transaction binding the contract method 0x95854b81. +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. // -// Solidity: function depositERC20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() -func (_IERC20Portal *IERC20PortalSession) DepositERC20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { - return _IERC20Portal.Contract.DepositERC20Tokens(&_IERC20Portal.TransactOpts, token, appContract, value, execLayerData) +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalSession) DepositErc20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.Contract.DepositErc20Tokens(&_IErc20Portal.TransactOpts, token, appContract, value, execLayerData) } -// DepositERC20Tokens is a paid mutator transaction binding the contract method 0x95854b81. +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. // -// Solidity: function depositERC20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() -func (_IERC20Portal *IERC20PortalTransactorSession) DepositERC20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { - return _IERC20Portal.Contract.DepositERC20Tokens(&_IERC20Portal.TransactOpts, token, appContract, value, execLayerData) +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalTransactorSession) DepositErc20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.Contract.DepositErc20Tokens(&_IErc20Portal.TransactOpts, token, appContract, value, execLayerData) } diff --git a/pkg/contracts/iinputbox/iinputbox.go b/pkg/contracts/iinputbox/iinputbox.go index db7999b13..e4b700084 100644 --- a/pkg/contracts/iinputbox/iinputbox.go +++ b/pkg/contracts/iinputbox/iinputbox.go @@ -31,7 +31,7 @@ var ( // IInputBoxMetaData contains all meta data concerning the IInputBox contract. var IInputBoxMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"addInput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputHash\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfInputs\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"InputAdded\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputTooLarge\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxInputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"addInput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputHash\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfInputs\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"InputAdded\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InputTooLarge\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxInputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", } // IInputBoxABI is the input ABI used to generate the binding from. diff --git a/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go b/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go new file mode 100644 index 000000000..f439b43fc --- /dev/null +++ b/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go @@ -0,0 +1,459 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package imultileveltournamentfactory + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// TournamentParameters is an auto generated low-level Go binding around an user-defined struct. +type TournamentParameters struct { + Levels uint64 + Log2step uint64 + Height uint64 + ResponseBudget uint64 + MaxAllowance uint64 +} + +// IMultiLevelTournamentFactoryMetaData contains all meta data concerning the IMultiLevelTournamentFactory contract. +var IMultiLevelTournamentFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"instantiate\",\"inputs\":[{\"name\":\"initialState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"instantiateInner\",\"inputs\":[{\"name\":\"_initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_contestedCommitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"_contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_contestedCommitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"_contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"_startCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"_level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"_provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stateTransition\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIStateTransition\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentLevelCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentParameters\",\"inputs\":[{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structTournamentParameters\",\"components\":[{\"name\":\"levels\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"log2step\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"responseBudget\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"maxAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"TournamentCreated\",\"inputs\":[{\"name\":\"tournament\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournament\"}],\"anonymous\":false}]", +} + +// IMultiLevelTournamentFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use IMultiLevelTournamentFactoryMetaData.ABI instead. +var IMultiLevelTournamentFactoryABI = IMultiLevelTournamentFactoryMetaData.ABI + +// IMultiLevelTournamentFactory is an auto generated Go binding around an Ethereum contract. +type IMultiLevelTournamentFactory struct { + IMultiLevelTournamentFactoryCaller // Read-only binding to the contract + IMultiLevelTournamentFactoryTransactor // Write-only binding to the contract + IMultiLevelTournamentFactoryFilterer // Log filterer for contract events +} + +// IMultiLevelTournamentFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IMultiLevelTournamentFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IMultiLevelTournamentFactorySession struct { + Contract *IMultiLevelTournamentFactory // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IMultiLevelTournamentFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IMultiLevelTournamentFactoryCallerSession struct { + Contract *IMultiLevelTournamentFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IMultiLevelTournamentFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IMultiLevelTournamentFactoryTransactorSession struct { + Contract *IMultiLevelTournamentFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IMultiLevelTournamentFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryRaw struct { + Contract *IMultiLevelTournamentFactory // Generic contract binding to access the raw methods on +} + +// IMultiLevelTournamentFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryCallerRaw struct { + Contract *IMultiLevelTournamentFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// IMultiLevelTournamentFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryTransactorRaw struct { + Contract *IMultiLevelTournamentFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIMultiLevelTournamentFactory creates a new instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactory(address common.Address, backend bind.ContractBackend) (*IMultiLevelTournamentFactory, error) { + contract, err := bindIMultiLevelTournamentFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactory{IMultiLevelTournamentFactoryCaller: IMultiLevelTournamentFactoryCaller{contract: contract}, IMultiLevelTournamentFactoryTransactor: IMultiLevelTournamentFactoryTransactor{contract: contract}, IMultiLevelTournamentFactoryFilterer: IMultiLevelTournamentFactoryFilterer{contract: contract}}, nil +} + +// NewIMultiLevelTournamentFactoryCaller creates a new read-only instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryCaller(address common.Address, caller bind.ContractCaller) (*IMultiLevelTournamentFactoryCaller, error) { + contract, err := bindIMultiLevelTournamentFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryCaller{contract: contract}, nil +} + +// NewIMultiLevelTournamentFactoryTransactor creates a new write-only instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*IMultiLevelTournamentFactoryTransactor, error) { + contract, err := bindIMultiLevelTournamentFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryTransactor{contract: contract}, nil +} + +// NewIMultiLevelTournamentFactoryFilterer creates a new log filterer instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*IMultiLevelTournamentFactoryFilterer, error) { + contract, err := bindIMultiLevelTournamentFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryFilterer{contract: contract}, nil +} + +// bindIMultiLevelTournamentFactory binds a generic wrapper to an already deployed contract. +func bindIMultiLevelTournamentFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IMultiLevelTournamentFactoryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IMultiLevelTournamentFactory.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.contract.Transact(opts, method, params...) +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) StateTransition(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "stateTransition") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) StateTransition() (common.Address, error) { + return _IMultiLevelTournamentFactory.Contract.StateTransition(&_IMultiLevelTournamentFactory.CallOpts) +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) StateTransition() (common.Address, error) { + return _IMultiLevelTournamentFactory.Contract.StateTransition(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "tournamentLevelCount") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) TournamentLevelCount() (uint64, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentLevelCount(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) TournamentLevelCount() (uint64, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentLevelCount(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) TournamentParameters(opts *bind.CallOpts, level uint64) (TournamentParameters, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "tournamentParameters", level) + + if err != nil { + return *new(TournamentParameters), err + } + + out0 := *abi.ConvertType(out[0], new(TournamentParameters)).(*TournamentParameters) + + return out0, err + +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) TournamentParameters(level uint64) (TournamentParameters, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentParameters(&_IMultiLevelTournamentFactory.CallOpts, level) +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) TournamentParameters(level uint64) (TournamentParameters, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentParameters(&_IMultiLevelTournamentFactory.CallOpts, level) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactor) Instantiate(opts *bind.TransactOpts, initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.contract.Transact(opts, "instantiate", initialState, provider) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) Instantiate(initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.Instantiate(&_IMultiLevelTournamentFactory.TransactOpts, initialState, provider) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorSession) Instantiate(initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.Instantiate(&_IMultiLevelTournamentFactory.TransactOpts, initialState, provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactor) InstantiateInner(opts *bind.TransactOpts, _initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.contract.Transact(opts, "instantiateInner", _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) InstantiateInner(_initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.InstantiateInner(&_IMultiLevelTournamentFactory.TransactOpts, _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorSession) InstantiateInner(_initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.InstantiateInner(&_IMultiLevelTournamentFactory.TransactOpts, _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// IMultiLevelTournamentFactoryTournamentCreatedIterator is returned from FilterTournamentCreated and is used to iterate over the raw logs and unpacked data for TournamentCreated events raised by the IMultiLevelTournamentFactory contract. +type IMultiLevelTournamentFactoryTournamentCreatedIterator struct { + Event *IMultiLevelTournamentFactoryTournamentCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IMultiLevelTournamentFactoryTournamentCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IMultiLevelTournamentFactoryTournamentCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IMultiLevelTournamentFactoryTournamentCreated represents a TournamentCreated event raised by the IMultiLevelTournamentFactory contract. +type IMultiLevelTournamentFactoryTournamentCreated struct { + Tournament common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTournamentCreated is a free log retrieval operation binding the contract event 0x66b8e5c50b708b71a6f155375a4613dd12971db692c5931e7cb0701ee7add6ac. +// +// Solidity: event TournamentCreated(address tournament) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) FilterTournamentCreated(opts *bind.FilterOpts) (*IMultiLevelTournamentFactoryTournamentCreatedIterator, error) { + + logs, sub, err := _IMultiLevelTournamentFactory.contract.FilterLogs(opts, "TournamentCreated") + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryTournamentCreatedIterator{contract: _IMultiLevelTournamentFactory.contract, event: "TournamentCreated", logs: logs, sub: sub}, nil +} + +// WatchTournamentCreated is a free log subscription operation binding the contract event 0x66b8e5c50b708b71a6f155375a4613dd12971db692c5931e7cb0701ee7add6ac. +// +// Solidity: event TournamentCreated(address tournament) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) WatchTournamentCreated(opts *bind.WatchOpts, sink chan<- *IMultiLevelTournamentFactoryTournamentCreated) (event.Subscription, error) { + + logs, sub, err := _IMultiLevelTournamentFactory.contract.WatchLogs(opts, "TournamentCreated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IMultiLevelTournamentFactoryTournamentCreated) + if err := _IMultiLevelTournamentFactory.contract.UnpackLog(event, "TournamentCreated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTournamentCreated is a log parse operation binding the contract event 0x66b8e5c50b708b71a6f155375a4613dd12971db692c5931e7cb0701ee7add6ac. +// +// Solidity: event TournamentCreated(address tournament) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) ParseTournamentCreated(log types.Log) (*IMultiLevelTournamentFactoryTournamentCreated, error) { + event := new(IMultiLevelTournamentFactoryTournamentCreated) + if err := _IMultiLevelTournamentFactory.contract.UnpackLog(event, "TournamentCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iquorum/iquorum.go b/pkg/contracts/iquorum/iquorum.go index 3dda3e5d4..e01b7adf9 100644 --- a/pkg/contracts/iquorum/iquorum.go +++ b/pkg/contracts/iquorum/iquorum.go @@ -36,9 +36,22 @@ type IConsensusClaim struct { StagedOutputsMerkleRoot [32]byte } +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + // IQuorumMetaData contains all meta data concerning the IQuorum contract. var IQuorumMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidators\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorById\",\"inputs\":[{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorId\",\"inputs\":[{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotValidator\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProofSize\",\"inputs\":[{\"name\":\"suppliedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidators\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorById\",\"inputs\":[{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorId\",\"inputs\":[{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotValidator\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", } // IQuorumABI is the input ABI used to generate the binding from. @@ -743,6 +756,37 @@ func (_IQuorum *IQuorumCallerSession) Version() (struct { return _IQuorum.Contract.Version(&_IQuorum.CallOpts) } +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IQuorum.Contract.WasInputFinalized(&_IQuorum.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IQuorum.Contract.WasInputFinalized(&_IQuorum.CallOpts, appContract, inputIndex, blockNumber) +} + // AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. // // Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() @@ -764,25 +808,25 @@ func (_IQuorum *IQuorumTransactorSession) AcceptClaim(appContract common.Address return _IQuorum.Contract.AcceptClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IQuorum *IQuorumTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IQuorum.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IQuorum *IQuorumSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IQuorum *IQuorumTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } // IQuorumClaimAcceptedIterator is returned from FilterClaimAccepted and is used to iterate over the raw logs and unpacked data for ClaimAccepted events raised by the IQuorum contract. diff --git a/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go b/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go index 543289bfe..1aef2ddc5 100644 --- a/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go +++ b/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go @@ -40,7 +40,7 @@ type WithdrawalConfig struct { // ISelfHostedApplicationFactoryMetaData contains all meta data concerning the ISelfHostedApplicationFactory contract. var ISelfHostedApplicationFactoryMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"calculateAddresses\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"deployContracts\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getApplicationFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplicationFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAuthorityFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthorityFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", + ABI: "[{\"type\":\"function\",\"name\":\"calculateAddresses\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"deployContracts\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getApplicationFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplicationFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAuthorityFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthorityFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", } // ISelfHostedApplicationFactoryABI is the input ABI used to generate the binding from. @@ -189,12 +189,12 @@ func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorRaw return _ISelfHostedApplicationFactory.Contract.contract.Transact(opts, method, params...) } -// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) CalculateAddresses(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) CalculateAddresses(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { var out []interface{} - err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "calculateAddresses", authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) + err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "calculateAddresses", authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) if err != nil { return *new(common.Address), *new(common.Address), err @@ -207,18 +207,18 @@ func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) Calcu } -// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { - return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } -// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { - return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } // GetApplicationFactory is a free data retrieval call binding the contract method 0xe63d50ff. @@ -343,23 +343,23 @@ func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession return _ISelfHostedApplicationFactory.Contract.Version(&_ISelfHostedApplicationFactory.CallOpts) } -// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactor) DeployContracts(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.contract.Transact(opts, "deployContracts", authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactor) DeployContracts(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.contract.Transact(opts, "deployContracts", authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } -// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } -// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorSession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorSession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } diff --git a/pkg/contracts/itournament/itournament.go b/pkg/contracts/itournament/itournament.go index c26f6fe5a..9dfabe224 100644 --- a/pkg/contracts/itournament/itournament.go +++ b/pkg/contracts/itournament/itournament.go @@ -29,41 +29,75 @@ var ( _ = abi.ConvertType ) -// ClockState is an auto generated low-level Go binding around an user-defined struct. -type ClockState struct { - Allowance uint64 +// ITournamentBisectingMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentBisectingMatchView struct { + RevealingParent [32]byte + WaitingLeft [32]byte + WaitingRight [32]byte + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + CurrentHeight uint64 + Responder uint8 +} + +// ITournamentCommitmentStandingView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentCommitmentStandingView struct { + Joined bool + FinalState [32]byte + Claimer common.Address + ClockRunning bool + ClockDeadline uint64 + ClockAllowance uint64 +} + +// ITournamentInnerResultView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentInnerResultView struct { + Disposition uint8 + ParentCommitment [32]byte + PausedAllowance uint64 +} + +// ITournamentReadyToSealMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentReadyToSealMatchView struct { + RevealingParent [32]byte + WaitingLeft [32]byte + WaitingRight [32]byte + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + Responder uint8 +} + +// ITournamentSealedMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentSealedMatchView struct { + AgreeState [32]byte + DivergencePosition *big.Int + DivergenceCycle *big.Int + FinalStateOne [32]byte + FinalStateTwo [32]byte +} + +// ITournamentTournamentDescriptor is an auto generated low-level Go binding around an user-defined struct. +type ITournamentTournamentDescriptor struct { + InitialHash [32]byte + BaseCycle *big.Int + Log2Stride uint64 + Height uint64 + Level uint64 + Kind uint8 StartInstant uint64 + Allowance uint64 } -// CommitmentArguments is an auto generated low-level Go binding around an user-defined struct. -type CommitmentArguments struct { - InitialHash [32]byte - StartCycle *big.Int - Log2step uint64 - Height uint64 -} - -// ITournamentNestedDispute is an auto generated low-level Go binding around an user-defined struct. -type ITournamentNestedDispute struct { - ContestedCommitmentOne [32]byte - ContestedFinalStateOne [32]byte - ContestedCommitmentTwo [32]byte - ContestedFinalStateTwo [32]byte -} - -// ITournamentTournamentArguments is an auto generated low-level Go binding around an user-defined struct. -type ITournamentTournamentArguments struct { - CommitmentArgs CommitmentArguments - Level uint64 - Levels uint64 - StartInstant uint64 - Allowance uint64 - MaxAllowance uint64 - MatchEffort uint64 - Provider common.Address - NestedDispute ITournamentNestedDispute - StateTransition common.Address - TournamentFactory common.Address +// ITournamentTournamentStandingView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentTournamentStandingView struct { + Standing uint8 + AcceptsJoins bool + HasCandidate bool + Candidate [32]byte + FinalState [32]byte + ParentCommitment [32]byte + FinishedAt uint64 + WinnerExpiresAt uint64 } // MatchId is an auto generated low-level Go binding around an user-defined struct. @@ -72,19 +106,9 @@ type MatchId struct { CommitmentTwo [32]byte } -// MatchState is an auto generated low-level Go binding around an user-defined struct. -type MatchState struct { - OtherParent [32]byte - LeftNode [32]byte - RightNode [32]byte - RunningLeafPosition *big.Int - CurrentHeight uint64 - IsInit bool -} - // ITournamentMetaData contains all meta data concerning the ITournament contract. var ITournamentMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"advanceMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newLeftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newRightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"arbitrationResult\",\"inputs\":[],\"outputs\":[{\"name\":\"finished\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"winnerCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"bondValue\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canBeEliminated\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canWinMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"eliminateInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"eliminateMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getCommitment\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[{\"name\":\"clock\",\"type\":\"tuple\",\"internalType\":\"structClock.State\",\"components\":[{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}]},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getCommitmentJoinedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structMatch.State\",\"components\":[{\"name\":\"otherParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"runningLeafPosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentHeight\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"isInit\",\"type\":\"bool\",\"internalType\":\"bool\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchAdvancedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchCreatedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchCycle\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchDeletedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNewInnerTournamentCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"innerTournamentWinner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structClock.State\",\"components\":[{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isClosed\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isFinished\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"joinTournament\",\"inputs\":[{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"sealInnerMatchAndCreateInnerTournament\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"sealLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"timeFinished\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentArguments\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentArguments\",\"components\":[{\"name\":\"commitmentArgs\",\"type\":\"tuple\",\"internalType\":\"structCommitment.Arguments\",\"components\":[{\"name\":\"initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"startCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2step\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"levels\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"maxAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"matchEffort\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"},{\"name\":\"nestedDispute\",\"type\":\"tuple\",\"internalType\":\"structITournament.NestedDispute\",\"components\":[{\"name\":\"contestedCommitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"contestedCommitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"name\":\"stateTransition\",\"type\":\"address\",\"internalType\":\"contractIStateTransition\"},{\"name\":\"tournamentFactory\",\"type\":\"address\",\"internalType\":\"address\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentLevelConstants\",\"inputs\":[],\"outputs\":[{\"name\":\"maxLevel\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"log2step\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tryRecoveringBond\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"proofs\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"CommitmentJoined\",\"inputs\":[{\"name\":\"commitment\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"finalStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchAdvanced\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"otherParent\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchCreated\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"leftOfTwo\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchDeleted\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"reason\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.MatchDeletionReason\"},{\"name\":\"winnerCommitment\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.WinnerCommitment\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"NewInnerTournament\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"childTournament\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PartialBondRefund\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"success\",\"type\":\"bool\",\"indexed\":true,\"internalType\":\"bool\"},{\"name\":\"ret\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AtLeastOneClockHasNotTimedOut\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CannotAdvanceTimedOutClock\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentCannotBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentMustBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ClockAlreadyInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ClockNotInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CommitmentProofWrongSize\",\"inputs\":[{\"name\":\"treeHeight\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"siblingsLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"CommitmentStateMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"computed\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"IncorrectAgreeState\",\"inputs\":[{\"name\":\"initialState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InitializedClockCannotHaveZeroAllowance\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientBond\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidChildrenNodes\",\"inputs\":[{\"name\":\"expectedParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"leftChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"InvalidContestedFinalState\",\"inputs\":[{\"name\":\"contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InvalidTournamentWinner\",\"inputs\":[{\"name\":\"winner\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"InvalidWinnerCommitment\",\"inputs\":[{\"name\":\"winnerCommitment\",\"type\":\"uint8\",\"internalType\":\"enumITournament.WinnerCommitment\"}]},{\"type\":\"error\",\"name\":\"MatchCannotBeAdvanced\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchIsNotSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NeitherClockHasTimedOut\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NodeDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"PausedClockCannotTimeout\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyDetected\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonRootTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentFailedNoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsClosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongChildren\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"left\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"right\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"WrongFinalState\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"computedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"committedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"WrongNodesForStep\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongTournamentWinner\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"winner\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"advanceMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newLeftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newRightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"bisectingMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.BisectingMatchView\",\"components\":[{\"name\":\"revealingParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingLeft\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingRight\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"segmentStartCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentHeight\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"responder\",\"type\":\"uint8\",\"internalType\":\"enumITournament.CommitmentSide\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"bondRecovery\",\"inputs\":[],\"outputs\":[{\"name\":\"disposition\",\"type\":\"uint8\",\"internalType\":\"enumITournament.BondDisposition\"},{\"name\":\"claimer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payment\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"bondValue\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"classifyMatchTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"outcome\",\"type\":\"uint8\",\"internalType\":\"enumITournament.MatchTimeoutOutcome\"},{\"name\":\"deferredCharge\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"commitmentStanding\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.CommitmentStandingView\",\"components\":[{\"name\":\"joined\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"claimer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"clockRunning\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"clockDeadline\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"clockAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"eliminateInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"eliminateMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getCommitmentJoinedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLeafMatchSealedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchAdvancedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchCreatedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchDeletedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNewInnerTournamentCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"innerResult\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.InnerResultView\",\"components\":[{\"name\":\"disposition\",\"type\":\"uint8\",\"internalType\":\"enumITournament.InnerTournamentDisposition\"},{\"name\":\"parentCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"pausedAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"joinTournament\",\"inputs\":[{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"readyToSealMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.ReadyToSealMatchView\",\"components\":[{\"name\":\"revealingParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingLeft\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingRight\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"segmentStartCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"responder\",\"type\":\"uint8\",\"internalType\":\"enumITournament.CommitmentSide\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"sealInnerMatchAndCreateInnerTournament\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"sealLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"sealedMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.SealedMatchView\",\"components\":[{\"name\":\"agreeState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"divergencePosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"divergenceCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"finalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"finalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentDescriptor\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentDescriptor\",\"components\":[{\"name\":\"initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"baseCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2Stride\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentKind\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentStanding\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentStandingView\",\"components\":[{\"name\":\"standing\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentStanding\"},{\"name\":\"acceptsJoins\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"hasCandidate\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"candidate\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"parentCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"finishedAt\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"winnerExpiresAt\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tryRecoveringBond\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"proofs\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"BondRecovered\",\"inputs\":[{\"name\":\"commitment\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"claimer\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"payment\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"burned\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"CommitmentJoined\",\"inputs\":[{\"name\":\"commitment\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"finalStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"LeafMatchSealed\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchAdvanced\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"otherParent\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchCreated\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"leftOfTwo\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchDeleted\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"reason\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.MatchDeletionReason\"},{\"name\":\"winnerCommitment\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.WinnerCommitment\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"NewInnerTournament\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"childTournament\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PartialBondRefund\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"success\",\"type\":\"bool\",\"indexed\":true,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"CannotAdvanceTimedOutClock\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentCannotBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentMustBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ClockAlreadyInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CommitmentProofWrongSize\",\"inputs\":[{\"name\":\"treeHeight\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"siblingsLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"CommitmentStateMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"computed\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"IncorrectAgreeState\",\"inputs\":[{\"name\":\"initialState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InsufficientBond\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidChildrenNodes\",\"inputs\":[{\"name\":\"expectedParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"leftChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"InvalidContestedFinalState\",\"inputs\":[{\"name\":\"contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"MatchCannotBeAdvanced\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeEliminatedByTimeout\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeWonByTimeout\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchIsNotSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyDetected\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonRootTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentFailedNoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsClosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongChildren\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"left\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"right\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"WrongFinalState\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"computedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"committedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"WrongNodesForStep\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongTournamentWinner\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"winner\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}]", } // ITournamentABI is the input ABI used to generate the binding from. @@ -233,192 +257,211 @@ func (_ITournament *ITournamentTransactorRaw) Transact(opts *bind.TransactOpts, return _ITournament.Contract.contract.Transact(opts, method, params...) } -// ArbitrationResult is a free data retrieval call binding the contract method 0xcb2773db. +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. // -// Solidity: function arbitrationResult() view returns(bool finished, bytes32 winnerCommitment, bytes32 finalState) -func (_ITournament *ITournamentCaller) ArbitrationResult(opts *bind.CallOpts) (struct { - Finished bool - WinnerCommitment [32]byte - FinalState [32]byte +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentCaller) BisectingMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView }, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "arbitrationResult") + err := _ITournament.contract.Call(opts, &out, "bisectingMatch", matchIdHash) outstruct := new(struct { - Finished bool - WinnerCommitment [32]byte - FinalState [32]byte + ActualPhase uint8 + Value ITournamentBisectingMatchView }) if err != nil { return *outstruct, err } - outstruct.Finished = *abi.ConvertType(out[0], new(bool)).(*bool) - outstruct.WinnerCommitment = *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) - outstruct.FinalState = *abi.ConvertType(out[2], new([32]byte)).(*[32]byte) + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentBisectingMatchView)).(*ITournamentBisectingMatchView) return *outstruct, err } -// ArbitrationResult is a free data retrieval call binding the contract method 0xcb2773db. +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. // -// Solidity: function arbitrationResult() view returns(bool finished, bytes32 winnerCommitment, bytes32 finalState) -func (_ITournament *ITournamentSession) ArbitrationResult() (struct { - Finished bool - WinnerCommitment [32]byte - FinalState [32]byte +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentSession) BisectingMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView }, error) { - return _ITournament.Contract.ArbitrationResult(&_ITournament.CallOpts) + return _ITournament.Contract.BisectingMatch(&_ITournament.CallOpts, matchIdHash) } -// ArbitrationResult is a free data retrieval call binding the contract method 0xcb2773db. +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. // -// Solidity: function arbitrationResult() view returns(bool finished, bytes32 winnerCommitment, bytes32 finalState) -func (_ITournament *ITournamentCallerSession) ArbitrationResult() (struct { - Finished bool - WinnerCommitment [32]byte - FinalState [32]byte +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentCallerSession) BisectingMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView }, error) { - return _ITournament.Contract.ArbitrationResult(&_ITournament.CallOpts) + return _ITournament.Contract.BisectingMatch(&_ITournament.CallOpts, matchIdHash) } -// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. // -// Solidity: function bondValue() view returns(uint256) -func (_ITournament *ITournamentCaller) BondValue(opts *bind.CallOpts) (*big.Int, error) { +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentCaller) BondRecovery(opts *bind.CallOpts) (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "bondValue") + err := _ITournament.contract.Call(opts, &out, "bondRecovery") + outstruct := new(struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int + }) if err != nil { - return *new(*big.Int), err + return *outstruct, err } - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.Disposition = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Claimer = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + outstruct.Payment = *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) - return out0, err + return *outstruct, err } -// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. // -// Solidity: function bondValue() view returns(uint256) -func (_ITournament *ITournamentSession) BondValue() (*big.Int, error) { - return _ITournament.Contract.BondValue(&_ITournament.CallOpts) +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentSession) BondRecovery() (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { + return _ITournament.Contract.BondRecovery(&_ITournament.CallOpts) } -// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. // -// Solidity: function bondValue() view returns(uint256) -func (_ITournament *ITournamentCallerSession) BondValue() (*big.Int, error) { - return _ITournament.Contract.BondValue(&_ITournament.CallOpts) +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentCallerSession) BondRecovery() (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { + return _ITournament.Contract.BondRecovery(&_ITournament.CallOpts) } -// CanBeEliminated is a free data retrieval call binding the contract method 0x95dd0e94. +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. // -// Solidity: function canBeEliminated() view returns(bool) -func (_ITournament *ITournamentCaller) CanBeEliminated(opts *bind.CallOpts) (bool, error) { +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentCaller) BondValue(opts *bind.CallOpts) (*big.Int, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "canBeEliminated") + err := _ITournament.contract.Call(opts, &out, "bondValue") if err != nil { - return *new(bool), err + return *new(*big.Int), err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) return out0, err } -// CanBeEliminated is a free data retrieval call binding the contract method 0x95dd0e94. +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. // -// Solidity: function canBeEliminated() view returns(bool) -func (_ITournament *ITournamentSession) CanBeEliminated() (bool, error) { - return _ITournament.Contract.CanBeEliminated(&_ITournament.CallOpts) +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentSession) BondValue() (*big.Int, error) { + return _ITournament.Contract.BondValue(&_ITournament.CallOpts) } -// CanBeEliminated is a free data retrieval call binding the contract method 0x95dd0e94. +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. // -// Solidity: function canBeEliminated() view returns(bool) -func (_ITournament *ITournamentCallerSession) CanBeEliminated() (bool, error) { - return _ITournament.Contract.CanBeEliminated(&_ITournament.CallOpts) +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentCallerSession) BondValue() (*big.Int, error) { + return _ITournament.Contract.BondValue(&_ITournament.CallOpts) } -// CanWinMatchByTimeout is a free data retrieval call binding the contract method 0x6a1a140d. +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. // -// Solidity: function canWinMatchByTimeout((bytes32,bytes32) matchId) view returns(bool) -func (_ITournament *ITournamentCaller) CanWinMatchByTimeout(opts *bind.CallOpts, matchId MatchId) (bool, error) { +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentCaller) ClassifyMatchTimeout(opts *bind.CallOpts, matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "canWinMatchByTimeout", matchId) + err := _ITournament.contract.Call(opts, &out, "classifyMatchTimeout", matchId) + outstruct := new(struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 + }) if err != nil { - return *new(bool), err + return *outstruct, err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Outcome = *abi.ConvertType(out[1], new(uint8)).(*uint8) + outstruct.DeferredCharge = *abi.ConvertType(out[2], new(uint64)).(*uint64) - return out0, err + return *outstruct, err } -// CanWinMatchByTimeout is a free data retrieval call binding the contract method 0x6a1a140d. +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. // -// Solidity: function canWinMatchByTimeout((bytes32,bytes32) matchId) view returns(bool) -func (_ITournament *ITournamentSession) CanWinMatchByTimeout(matchId MatchId) (bool, error) { - return _ITournament.Contract.CanWinMatchByTimeout(&_ITournament.CallOpts, matchId) +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentSession) ClassifyMatchTimeout(matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { + return _ITournament.Contract.ClassifyMatchTimeout(&_ITournament.CallOpts, matchId) } -// CanWinMatchByTimeout is a free data retrieval call binding the contract method 0x6a1a140d. +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. // -// Solidity: function canWinMatchByTimeout((bytes32,bytes32) matchId) view returns(bool) -func (_ITournament *ITournamentCallerSession) CanWinMatchByTimeout(matchId MatchId) (bool, error) { - return _ITournament.Contract.CanWinMatchByTimeout(&_ITournament.CallOpts, matchId) +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentCallerSession) ClassifyMatchTimeout(matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { + return _ITournament.Contract.ClassifyMatchTimeout(&_ITournament.CallOpts, matchId) } -// GetCommitment is a free data retrieval call binding the contract method 0x7795820c. +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. // -// Solidity: function getCommitment(bytes32 commitmentRoot) view returns((uint64,uint64) clock, bytes32 finalState) -func (_ITournament *ITournamentCaller) GetCommitment(opts *bind.CallOpts, commitmentRoot [32]byte) (struct { - Clock ClockState - FinalState [32]byte -}, error) { +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentCaller) CommitmentStanding(opts *bind.CallOpts, commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "getCommitment", commitmentRoot) + err := _ITournament.contract.Call(opts, &out, "commitmentStanding", commitmentRoot) - outstruct := new(struct { - Clock ClockState - FinalState [32]byte - }) if err != nil { - return *outstruct, err + return *new(ITournamentCommitmentStandingView), err } - outstruct.Clock = *abi.ConvertType(out[0], new(ClockState)).(*ClockState) - outstruct.FinalState = *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) + out0 := *abi.ConvertType(out[0], new(ITournamentCommitmentStandingView)).(*ITournamentCommitmentStandingView) - return *outstruct, err + return out0, err } -// GetCommitment is a free data retrieval call binding the contract method 0x7795820c. +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. // -// Solidity: function getCommitment(bytes32 commitmentRoot) view returns((uint64,uint64) clock, bytes32 finalState) -func (_ITournament *ITournamentSession) GetCommitment(commitmentRoot [32]byte) (struct { - Clock ClockState - FinalState [32]byte -}, error) { - return _ITournament.Contract.GetCommitment(&_ITournament.CallOpts, commitmentRoot) +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentSession) CommitmentStanding(commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { + return _ITournament.Contract.CommitmentStanding(&_ITournament.CallOpts, commitmentRoot) } -// GetCommitment is a free data retrieval call binding the contract method 0x7795820c. +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. // -// Solidity: function getCommitment(bytes32 commitmentRoot) view returns((uint64,uint64) clock, bytes32 finalState) -func (_ITournament *ITournamentCallerSession) GetCommitment(commitmentRoot [32]byte) (struct { - Clock ClockState - FinalState [32]byte -}, error) { - return _ITournament.Contract.GetCommitment(&_ITournament.CallOpts, commitmentRoot) +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) CommitmentStanding(commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { + return _ITournament.Contract.CommitmentStanding(&_ITournament.CallOpts, commitmentRoot) } // GetCommitmentJoinedCount is a free data retrieval call binding the contract method 0x2c243a1e. @@ -452,35 +495,35 @@ func (_ITournament *ITournamentCallerSession) GetCommitmentJoinedCount() (*big.I return _ITournament.Contract.GetCommitmentJoinedCount(&_ITournament.CallOpts) } -// GetMatch is a free data retrieval call binding the contract method 0xfcc6077d. +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. // -// Solidity: function getMatch(bytes32 matchIdHash) view returns((bytes32,bytes32,bytes32,uint256,uint64,bool)) -func (_ITournament *ITournamentCaller) GetMatch(opts *bind.CallOpts, matchIdHash [32]byte) (MatchState, error) { +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentCaller) GetLeafMatchSealedCount(opts *bind.CallOpts) (*big.Int, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "getMatch", matchIdHash) + err := _ITournament.contract.Call(opts, &out, "getLeafMatchSealedCount") if err != nil { - return *new(MatchState), err + return *new(*big.Int), err } - out0 := *abi.ConvertType(out[0], new(MatchState)).(*MatchState) + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) return out0, err } -// GetMatch is a free data retrieval call binding the contract method 0xfcc6077d. +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. // -// Solidity: function getMatch(bytes32 matchIdHash) view returns((bytes32,bytes32,bytes32,uint256,uint64,bool)) -func (_ITournament *ITournamentSession) GetMatch(matchIdHash [32]byte) (MatchState, error) { - return _ITournament.Contract.GetMatch(&_ITournament.CallOpts, matchIdHash) +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentSession) GetLeafMatchSealedCount() (*big.Int, error) { + return _ITournament.Contract.GetLeafMatchSealedCount(&_ITournament.CallOpts) } -// GetMatch is a free data retrieval call binding the contract method 0xfcc6077d. +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. // -// Solidity: function getMatch(bytes32 matchIdHash) view returns((bytes32,bytes32,bytes32,uint256,uint64,bool)) -func (_ITournament *ITournamentCallerSession) GetMatch(matchIdHash [32]byte) (MatchState, error) { - return _ITournament.Contract.GetMatch(&_ITournament.CallOpts, matchIdHash) +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentCallerSession) GetLeafMatchSealedCount() (*big.Int, error) { + return _ITournament.Contract.GetLeafMatchSealedCount(&_ITournament.CallOpts) } // GetMatchAdvancedCount is a free data retrieval call binding the contract method 0xf8cb3bd0. @@ -545,37 +588,6 @@ func (_ITournament *ITournamentCallerSession) GetMatchCreatedCount() (*big.Int, return _ITournament.Contract.GetMatchCreatedCount(&_ITournament.CallOpts) } -// GetMatchCycle is a free data retrieval call binding the contract method 0x8acc802d. -// -// Solidity: function getMatchCycle(bytes32 matchIdHash) view returns(uint256) -func (_ITournament *ITournamentCaller) GetMatchCycle(opts *bind.CallOpts, matchIdHash [32]byte) (*big.Int, error) { - var out []interface{} - err := _ITournament.contract.Call(opts, &out, "getMatchCycle", matchIdHash) - - if err != nil { - return *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) - - return out0, err - -} - -// GetMatchCycle is a free data retrieval call binding the contract method 0x8acc802d. -// -// Solidity: function getMatchCycle(bytes32 matchIdHash) view returns(uint256) -func (_ITournament *ITournamentSession) GetMatchCycle(matchIdHash [32]byte) (*big.Int, error) { - return _ITournament.Contract.GetMatchCycle(&_ITournament.CallOpts, matchIdHash) -} - -// GetMatchCycle is a free data retrieval call binding the contract method 0x8acc802d. -// -// Solidity: function getMatchCycle(bytes32 matchIdHash) view returns(uint256) -func (_ITournament *ITournamentCallerSession) GetMatchCycle(matchIdHash [32]byte) (*big.Int, error) { - return _ITournament.Contract.GetMatchCycle(&_ITournament.CallOpts, matchIdHash) -} - // GetMatchDeletedCount is a free data retrieval call binding the contract method 0xd3976945. // // Solidity: function getMatchDeletedCount() view returns(uint256) @@ -638,218 +650,187 @@ func (_ITournament *ITournamentCallerSession) GetNewInnerTournamentCount() (*big return _ITournament.Contract.GetNewInnerTournamentCount(&_ITournament.CallOpts) } -// InnerTournamentWinner is a free data retrieval call binding the contract method 0x5145236f. -// -// Solidity: function innerTournamentWinner() view returns(bool, bytes32, bytes32, (uint64,uint64)) -func (_ITournament *ITournamentCaller) InnerTournamentWinner(opts *bind.CallOpts) (bool, [32]byte, [32]byte, ClockState, error) { - var out []interface{} - err := _ITournament.contract.Call(opts, &out, "innerTournamentWinner") - - if err != nil { - return *new(bool), *new([32]byte), *new([32]byte), *new(ClockState), err - } - - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - out1 := *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) - out2 := *abi.ConvertType(out[2], new([32]byte)).(*[32]byte) - out3 := *abi.ConvertType(out[3], new(ClockState)).(*ClockState) - - return out0, out1, out2, out3, err - -} - -// InnerTournamentWinner is a free data retrieval call binding the contract method 0x5145236f. -// -// Solidity: function innerTournamentWinner() view returns(bool, bytes32, bytes32, (uint64,uint64)) -func (_ITournament *ITournamentSession) InnerTournamentWinner() (bool, [32]byte, [32]byte, ClockState, error) { - return _ITournament.Contract.InnerTournamentWinner(&_ITournament.CallOpts) -} - -// InnerTournamentWinner is a free data retrieval call binding the contract method 0x5145236f. -// -// Solidity: function innerTournamentWinner() view returns(bool, bytes32, bytes32, (uint64,uint64)) -func (_ITournament *ITournamentCallerSession) InnerTournamentWinner() (bool, [32]byte, [32]byte, ClockState, error) { - return _ITournament.Contract.InnerTournamentWinner(&_ITournament.CallOpts) -} - -// IsClosed is a free data retrieval call binding the contract method 0xc2b6b58c. +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. // -// Solidity: function isClosed() view returns(bool) -func (_ITournament *ITournamentCaller) IsClosed(opts *bind.CallOpts) (bool, error) { +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentCaller) InnerResult(opts *bind.CallOpts) (ITournamentInnerResultView, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "isClosed") + err := _ITournament.contract.Call(opts, &out, "innerResult") if err != nil { - return *new(bool), err + return *new(ITournamentInnerResultView), err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + out0 := *abi.ConvertType(out[0], new(ITournamentInnerResultView)).(*ITournamentInnerResultView) return out0, err } -// IsClosed is a free data retrieval call binding the contract method 0xc2b6b58c. +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. // -// Solidity: function isClosed() view returns(bool) -func (_ITournament *ITournamentSession) IsClosed() (bool, error) { - return _ITournament.Contract.IsClosed(&_ITournament.CallOpts) +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentSession) InnerResult() (ITournamentInnerResultView, error) { + return _ITournament.Contract.InnerResult(&_ITournament.CallOpts) } -// IsClosed is a free data retrieval call binding the contract method 0xc2b6b58c. +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. // -// Solidity: function isClosed() view returns(bool) -func (_ITournament *ITournamentCallerSession) IsClosed() (bool, error) { - return _ITournament.Contract.IsClosed(&_ITournament.CallOpts) +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentCallerSession) InnerResult() (ITournamentInnerResultView, error) { + return _ITournament.Contract.InnerResult(&_ITournament.CallOpts) } -// IsFinished is a free data retrieval call binding the contract method 0x7b352962. +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. // -// Solidity: function isFinished() view returns(bool) -func (_ITournament *ITournamentCaller) IsFinished(opts *bind.CallOpts) (bool, error) { +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentCaller) ReadyToSealMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "isFinished") + err := _ITournament.contract.Call(opts, &out, "readyToSealMatch", matchIdHash) + outstruct := new(struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView + }) if err != nil { - return *new(bool), err + return *outstruct, err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentReadyToSealMatchView)).(*ITournamentReadyToSealMatchView) - return out0, err + return *outstruct, err } -// IsFinished is a free data retrieval call binding the contract method 0x7b352962. +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. // -// Solidity: function isFinished() view returns(bool) -func (_ITournament *ITournamentSession) IsFinished() (bool, error) { - return _ITournament.Contract.IsFinished(&_ITournament.CallOpts) +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentSession) ReadyToSealMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { + return _ITournament.Contract.ReadyToSealMatch(&_ITournament.CallOpts, matchIdHash) } -// IsFinished is a free data retrieval call binding the contract method 0x7b352962. +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. // -// Solidity: function isFinished() view returns(bool) -func (_ITournament *ITournamentCallerSession) IsFinished() (bool, error) { - return _ITournament.Contract.IsFinished(&_ITournament.CallOpts) +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentCallerSession) ReadyToSealMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { + return _ITournament.Contract.ReadyToSealMatch(&_ITournament.CallOpts, matchIdHash) } -// TimeFinished is a free data retrieval call binding the contract method 0x39cdfaf2. +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. // -// Solidity: function timeFinished() view returns(bool, uint64) -func (_ITournament *ITournamentCaller) TimeFinished(opts *bind.CallOpts) (bool, uint64, error) { +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentCaller) SealedMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "timeFinished") + err := _ITournament.contract.Call(opts, &out, "sealedMatch", matchIdHash) + outstruct := new(struct { + ActualPhase uint8 + Value ITournamentSealedMatchView + }) if err != nil { - return *new(bool), *new(uint64), err + return *outstruct, err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - out1 := *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentSealedMatchView)).(*ITournamentSealedMatchView) - return out0, out1, err + return *outstruct, err } -// TimeFinished is a free data retrieval call binding the contract method 0x39cdfaf2. +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. // -// Solidity: function timeFinished() view returns(bool, uint64) -func (_ITournament *ITournamentSession) TimeFinished() (bool, uint64, error) { - return _ITournament.Contract.TimeFinished(&_ITournament.CallOpts) +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentSession) SealedMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { + return _ITournament.Contract.SealedMatch(&_ITournament.CallOpts, matchIdHash) } -// TimeFinished is a free data retrieval call binding the contract method 0x39cdfaf2. +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. // -// Solidity: function timeFinished() view returns(bool, uint64) -func (_ITournament *ITournamentCallerSession) TimeFinished() (bool, uint64, error) { - return _ITournament.Contract.TimeFinished(&_ITournament.CallOpts) +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentCallerSession) SealedMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { + return _ITournament.Contract.SealedMatch(&_ITournament.CallOpts, matchIdHash) } -// TournamentArguments is a free data retrieval call binding the contract method 0x4b3fbb10. +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. // -// Solidity: function tournamentArguments() view returns(((bytes32,uint256,uint64,uint64),uint64,uint64,uint64,uint64,uint64,uint64,address,(bytes32,bytes32,bytes32,bytes32),address,address)) -func (_ITournament *ITournamentCaller) TournamentArguments(opts *bind.CallOpts) (ITournamentTournamentArguments, error) { +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentCaller) TournamentDescriptor(opts *bind.CallOpts) (ITournamentTournamentDescriptor, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "tournamentArguments") + err := _ITournament.contract.Call(opts, &out, "tournamentDescriptor") if err != nil { - return *new(ITournamentTournamentArguments), err + return *new(ITournamentTournamentDescriptor), err } - out0 := *abi.ConvertType(out[0], new(ITournamentTournamentArguments)).(*ITournamentTournamentArguments) + out0 := *abi.ConvertType(out[0], new(ITournamentTournamentDescriptor)).(*ITournamentTournamentDescriptor) return out0, err } -// TournamentArguments is a free data retrieval call binding the contract method 0x4b3fbb10. +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. // -// Solidity: function tournamentArguments() view returns(((bytes32,uint256,uint64,uint64),uint64,uint64,uint64,uint64,uint64,uint64,address,(bytes32,bytes32,bytes32,bytes32),address,address)) -func (_ITournament *ITournamentSession) TournamentArguments() (ITournamentTournamentArguments, error) { - return _ITournament.Contract.TournamentArguments(&_ITournament.CallOpts) +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentSession) TournamentDescriptor() (ITournamentTournamentDescriptor, error) { + return _ITournament.Contract.TournamentDescriptor(&_ITournament.CallOpts) } -// TournamentArguments is a free data retrieval call binding the contract method 0x4b3fbb10. +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. // -// Solidity: function tournamentArguments() view returns(((bytes32,uint256,uint64,uint64),uint64,uint64,uint64,uint64,uint64,uint64,address,(bytes32,bytes32,bytes32,bytes32),address,address)) -func (_ITournament *ITournamentCallerSession) TournamentArguments() (ITournamentTournamentArguments, error) { - return _ITournament.Contract.TournamentArguments(&_ITournament.CallOpts) +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) TournamentDescriptor() (ITournamentTournamentDescriptor, error) { + return _ITournament.Contract.TournamentDescriptor(&_ITournament.CallOpts) } -// TournamentLevelConstants is a free data retrieval call binding the contract method 0xa1af906b. +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. // -// Solidity: function tournamentLevelConstants() view returns(uint64 maxLevel, uint64 level, uint64 log2step, uint64 height) -func (_ITournament *ITournamentCaller) TournamentLevelConstants(opts *bind.CallOpts) (struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 -}, error) { +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentCaller) TournamentStanding(opts *bind.CallOpts) (ITournamentTournamentStandingView, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "tournamentLevelConstants") + err := _ITournament.contract.Call(opts, &out, "tournamentStanding") - outstruct := new(struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 - }) if err != nil { - return *outstruct, err + return *new(ITournamentTournamentStandingView), err } - outstruct.MaxLevel = *abi.ConvertType(out[0], new(uint64)).(*uint64) - outstruct.Level = *abi.ConvertType(out[1], new(uint64)).(*uint64) - outstruct.Log2step = *abi.ConvertType(out[2], new(uint64)).(*uint64) - outstruct.Height = *abi.ConvertType(out[3], new(uint64)).(*uint64) + out0 := *abi.ConvertType(out[0], new(ITournamentTournamentStandingView)).(*ITournamentTournamentStandingView) - return *outstruct, err + return out0, err } -// TournamentLevelConstants is a free data retrieval call binding the contract method 0xa1af906b. +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. // -// Solidity: function tournamentLevelConstants() view returns(uint64 maxLevel, uint64 level, uint64 log2step, uint64 height) -func (_ITournament *ITournamentSession) TournamentLevelConstants() (struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 -}, error) { - return _ITournament.Contract.TournamentLevelConstants(&_ITournament.CallOpts) +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentSession) TournamentStanding() (ITournamentTournamentStandingView, error) { + return _ITournament.Contract.TournamentStanding(&_ITournament.CallOpts) } -// TournamentLevelConstants is a free data retrieval call binding the contract method 0xa1af906b. +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. // -// Solidity: function tournamentLevelConstants() view returns(uint64 maxLevel, uint64 level, uint64 log2step, uint64 height) -func (_ITournament *ITournamentCallerSession) TournamentLevelConstants() (struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 -}, error) { - return _ITournament.Contract.TournamentLevelConstants(&_ITournament.CallOpts) +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) TournamentStanding() (ITournamentTournamentStandingView, error) { + return _ITournament.Contract.TournamentStanding(&_ITournament.CallOpts) } // AdvanceMatch is a paid mutator transaction binding the contract method 0xfcc85391. @@ -1062,6 +1043,161 @@ func (_ITournament *ITournamentTransactorSession) WinMatchByTimeout(matchId Matc return _ITournament.Contract.WinMatchByTimeout(&_ITournament.TransactOpts, matchId, leftNode, rightNode) } +// ITournamentBondRecoveredIterator is returned from FilterBondRecovered and is used to iterate over the raw logs and unpacked data for BondRecovered events raised by the ITournament contract. +type ITournamentBondRecoveredIterator struct { + Event *ITournamentBondRecovered // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentBondRecoveredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentBondRecovered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentBondRecovered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentBondRecoveredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentBondRecoveredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentBondRecovered represents a BondRecovered event raised by the ITournament contract. +type ITournamentBondRecovered struct { + Commitment [32]byte + Claimer common.Address + Payment *big.Int + Burned *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterBondRecovered is a free log retrieval operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) FilterBondRecovered(opts *bind.FilterOpts, commitment [][32]byte, claimer []common.Address) (*ITournamentBondRecoveredIterator, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } + var claimerRule []interface{} + for _, claimerItem := range claimer { + claimerRule = append(claimerRule, claimerItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "BondRecovered", commitmentRule, claimerRule) + if err != nil { + return nil, err + } + return &ITournamentBondRecoveredIterator{contract: _ITournament.contract, event: "BondRecovered", logs: logs, sub: sub}, nil +} + +// WatchBondRecovered is a free log subscription operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) WatchBondRecovered(opts *bind.WatchOpts, sink chan<- *ITournamentBondRecovered, commitment [][32]byte, claimer []common.Address) (event.Subscription, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } + var claimerRule []interface{} + for _, claimerItem := range claimer { + claimerRule = append(claimerRule, claimerItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "BondRecovered", commitmentRule, claimerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentBondRecovered) + if err := _ITournament.contract.UnpackLog(event, "BondRecovered", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseBondRecovered is a log parse operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) ParseBondRecovered(log types.Log) (*ITournamentBondRecovered, error) { + event := new(ITournamentBondRecovered) + if err := _ITournament.contract.UnpackLog(event, "BondRecovered", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // ITournamentCommitmentJoinedIterator is returned from FilterCommitmentJoined and is used to iterate over the raw logs and unpacked data for CommitmentJoined events raised by the ITournament contract. type ITournamentCommitmentJoinedIterator struct { Event *ITournamentCommitmentJoined // Event containing the contract specifics and raw log @@ -1139,15 +1275,20 @@ type ITournamentCommitmentJoined struct { // FilterCommitmentJoined is a free log retrieval operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. // -// Solidity: event CommitmentJoined(bytes32 commitment, bytes32 finalStateHash, address indexed submitter) -func (_ITournament *ITournamentFilterer) FilterCommitmentJoined(opts *bind.FilterOpts, submitter []common.Address) (*ITournamentCommitmentJoinedIterator, error) { +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) +func (_ITournament *ITournamentFilterer) FilterCommitmentJoined(opts *bind.FilterOpts, commitment [][32]byte, submitter []common.Address) (*ITournamentCommitmentJoinedIterator, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } var submitterRule []interface{} for _, submitterItem := range submitter { submitterRule = append(submitterRule, submitterItem) } - logs, sub, err := _ITournament.contract.FilterLogs(opts, "CommitmentJoined", submitterRule) + logs, sub, err := _ITournament.contract.FilterLogs(opts, "CommitmentJoined", commitmentRule, submitterRule) if err != nil { return nil, err } @@ -1156,15 +1297,20 @@ func (_ITournament *ITournamentFilterer) FilterCommitmentJoined(opts *bind.Filte // WatchCommitmentJoined is a free log subscription operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. // -// Solidity: event CommitmentJoined(bytes32 commitment, bytes32 finalStateHash, address indexed submitter) -func (_ITournament *ITournamentFilterer) WatchCommitmentJoined(opts *bind.WatchOpts, sink chan<- *ITournamentCommitmentJoined, submitter []common.Address) (event.Subscription, error) { +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) +func (_ITournament *ITournamentFilterer) WatchCommitmentJoined(opts *bind.WatchOpts, sink chan<- *ITournamentCommitmentJoined, commitment [][32]byte, submitter []common.Address) (event.Subscription, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } var submitterRule []interface{} for _, submitterItem := range submitter { submitterRule = append(submitterRule, submitterItem) } - logs, sub, err := _ITournament.contract.WatchLogs(opts, "CommitmentJoined", submitterRule) + logs, sub, err := _ITournament.contract.WatchLogs(opts, "CommitmentJoined", commitmentRule, submitterRule) if err != nil { return nil, err } @@ -1198,7 +1344,7 @@ func (_ITournament *ITournamentFilterer) WatchCommitmentJoined(opts *bind.WatchO // ParseCommitmentJoined is a log parse operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. // -// Solidity: event CommitmentJoined(bytes32 commitment, bytes32 finalStateHash, address indexed submitter) +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) func (_ITournament *ITournamentFilterer) ParseCommitmentJoined(log types.Log) (*ITournamentCommitmentJoined, error) { event := new(ITournamentCommitmentJoined) if err := _ITournament.contract.UnpackLog(event, "CommitmentJoined", log); err != nil { @@ -1208,6 +1354,151 @@ func (_ITournament *ITournamentFilterer) ParseCommitmentJoined(log types.Log) (* return event, nil } +// ITournamentLeafMatchSealedIterator is returned from FilterLeafMatchSealed and is used to iterate over the raw logs and unpacked data for LeafMatchSealed events raised by the ITournament contract. +type ITournamentLeafMatchSealedIterator struct { + Event *ITournamentLeafMatchSealed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentLeafMatchSealedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentLeafMatchSealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentLeafMatchSealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentLeafMatchSealedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentLeafMatchSealedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentLeafMatchSealed represents a LeafMatchSealed event raised by the ITournament contract. +type ITournamentLeafMatchSealed struct { + MatchIdHash [32]byte + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterLeafMatchSealed is a free log retrieval operation binding the contract event 0x304d9235f96c68167f2d1c8f6fbdce133d7d35b4706d00f47b40172cf96bd923. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) FilterLeafMatchSealed(opts *bind.FilterOpts, matchIdHash [][32]byte) (*ITournamentLeafMatchSealedIterator, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "LeafMatchSealed", matchIdHashRule) + if err != nil { + return nil, err + } + return &ITournamentLeafMatchSealedIterator{contract: _ITournament.contract, event: "LeafMatchSealed", logs: logs, sub: sub}, nil +} + +// WatchLeafMatchSealed is a free log subscription operation binding the contract event 0x304d9235f96c68167f2d1c8f6fbdce133d7d35b4706d00f47b40172cf96bd923. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) WatchLeafMatchSealed(opts *bind.WatchOpts, sink chan<- *ITournamentLeafMatchSealed, matchIdHash [][32]byte) (event.Subscription, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "LeafMatchSealed", matchIdHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentLeafMatchSealed) + if err := _ITournament.contract.UnpackLog(event, "LeafMatchSealed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseLeafMatchSealed is a log parse operation binding the contract event 0x304d9235f96c68167f2d1c8f6fbdce133d7d35b4706d00f47b40172cf96bd923. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) ParseLeafMatchSealed(log types.Log) (*ITournamentLeafMatchSealed, error) { + event := new(ITournamentLeafMatchSealed) + if err := _ITournament.contract.UnpackLog(event, "LeafMatchSealed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // ITournamentMatchAdvancedIterator is returned from FilterMatchAdvanced and is used to iterate over the raw logs and unpacked data for MatchAdvanced events raised by the ITournament contract. type ITournamentMatchAdvancedIterator struct { Event *ITournamentMatchAdvanced // Event containing the contract specifics and raw log @@ -1277,15 +1568,17 @@ func (it *ITournamentMatchAdvancedIterator) Close() error { // ITournamentMatchAdvanced represents a MatchAdvanced event raised by the ITournament contract. type ITournamentMatchAdvanced struct { - MatchIdHash [32]byte - OtherParent [32]byte - LeftNode [32]byte - Raw types.Log // Blockchain specific contextual infos + MatchIdHash [32]byte + OtherParent [32]byte + LeftNode [32]byte + SegmentStartPosition *big.Int + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos } -// FilterMatchAdvanced is a free log retrieval operation binding the contract event 0xbc14010e647cf07dd4f48df2f806ec59932be73b1b969e6dff6fa55e805a1cbc. +// FilterMatchAdvanced is a free log retrieval operation binding the contract event 0xb6135d4f6f9687ce2f55ce0fd69c7f9d77e003a036ac8e402828abdd973621b1. // -// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode) +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) FilterMatchAdvanced(opts *bind.FilterOpts, matchIdHash [][32]byte) (*ITournamentMatchAdvancedIterator, error) { var matchIdHashRule []interface{} @@ -1300,9 +1593,9 @@ func (_ITournament *ITournamentFilterer) FilterMatchAdvanced(opts *bind.FilterOp return &ITournamentMatchAdvancedIterator{contract: _ITournament.contract, event: "MatchAdvanced", logs: logs, sub: sub}, nil } -// WatchMatchAdvanced is a free log subscription operation binding the contract event 0xbc14010e647cf07dd4f48df2f806ec59932be73b1b969e6dff6fa55e805a1cbc. +// WatchMatchAdvanced is a free log subscription operation binding the contract event 0xb6135d4f6f9687ce2f55ce0fd69c7f9d77e003a036ac8e402828abdd973621b1. // -// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode) +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) WatchMatchAdvanced(opts *bind.WatchOpts, sink chan<- *ITournamentMatchAdvanced, matchIdHash [][32]byte) (event.Subscription, error) { var matchIdHashRule []interface{} @@ -1342,9 +1635,9 @@ func (_ITournament *ITournamentFilterer) WatchMatchAdvanced(opts *bind.WatchOpts }), nil } -// ParseMatchAdvanced is a log parse operation binding the contract event 0xbc14010e647cf07dd4f48df2f806ec59932be73b1b969e6dff6fa55e805a1cbc. +// ParseMatchAdvanced is a log parse operation binding the contract event 0xb6135d4f6f9687ce2f55ce0fd69c7f9d77e003a036ac8e402828abdd973621b1. // -// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode) +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) ParseMatchAdvanced(log types.Log) (*ITournamentMatchAdvanced, error) { event := new(ITournamentMatchAdvanced) if err := _ITournament.contract.UnpackLog(event, "MatchAdvanced", log); err != nil { @@ -1423,16 +1716,17 @@ func (it *ITournamentMatchCreatedIterator) Close() error { // ITournamentMatchCreated represents a MatchCreated event raised by the ITournament contract. type ITournamentMatchCreated struct { - MatchIdHash [32]byte - One [32]byte - Two [32]byte - LeftOfTwo [32]byte - Raw types.Log // Blockchain specific contextual infos + MatchIdHash [32]byte + One [32]byte + Two [32]byte + LeftOfTwo [32]byte + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos } -// FilterMatchCreated is a free log retrieval operation binding the contract event 0xbaea19df0c2b83760acad299eaf042b77e11e0f362ce10d0d4bb24b09fa5296d. +// FilterMatchCreated is a free log retrieval operation binding the contract event 0x6257204c2340779874d2f4e6f94db385ead25b228b1660535f7cbc81b492ac1e. // -// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo) +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) FilterMatchCreated(opts *bind.FilterOpts, matchIdHash [][32]byte, one [][32]byte, two [][32]byte) (*ITournamentMatchCreatedIterator, error) { var matchIdHashRule []interface{} @@ -1455,9 +1749,9 @@ func (_ITournament *ITournamentFilterer) FilterMatchCreated(opts *bind.FilterOpt return &ITournamentMatchCreatedIterator{contract: _ITournament.contract, event: "MatchCreated", logs: logs, sub: sub}, nil } -// WatchMatchCreated is a free log subscription operation binding the contract event 0xbaea19df0c2b83760acad299eaf042b77e11e0f362ce10d0d4bb24b09fa5296d. +// WatchMatchCreated is a free log subscription operation binding the contract event 0x6257204c2340779874d2f4e6f94db385ead25b228b1660535f7cbc81b492ac1e. // -// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo) +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) WatchMatchCreated(opts *bind.WatchOpts, sink chan<- *ITournamentMatchCreated, matchIdHash [][32]byte, one [][32]byte, two [][32]byte) (event.Subscription, error) { var matchIdHashRule []interface{} @@ -1505,9 +1799,9 @@ func (_ITournament *ITournamentFilterer) WatchMatchCreated(opts *bind.WatchOpts, }), nil } -// ParseMatchCreated is a log parse operation binding the contract event 0xbaea19df0c2b83760acad299eaf042b77e11e0f362ce10d0d4bb24b09fa5296d. +// ParseMatchCreated is a log parse operation binding the contract event 0x6257204c2340779874d2f4e6f94db385ead25b228b1660535f7cbc81b492ac1e. // -// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo) +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) ParseMatchCreated(log types.Log) (*ITournamentMatchCreated, error) { event := new(ITournamentMatchCreated) if err := _ITournament.contract.UnpackLog(event, "MatchCreated", log); err != nil { @@ -1906,13 +2200,12 @@ type ITournamentPartialBondRefund struct { Recipient common.Address Value *big.Int Success bool - Ret []byte Raw types.Log // Blockchain specific contextual infos } -// FilterPartialBondRefund is a free log retrieval operation binding the contract event 0x938a52b87ed1353360e17d203a73343c4e92b6a9e9a0b50d0e38df31fbf14219. +// FilterPartialBondRefund is a free log retrieval operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. // -// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success, bytes ret) +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) func (_ITournament *ITournamentFilterer) FilterPartialBondRefund(opts *bind.FilterOpts, recipient []common.Address, success []bool) (*ITournamentPartialBondRefundIterator, error) { var recipientRule []interface{} @@ -1932,9 +2225,9 @@ func (_ITournament *ITournamentFilterer) FilterPartialBondRefund(opts *bind.Filt return &ITournamentPartialBondRefundIterator{contract: _ITournament.contract, event: "PartialBondRefund", logs: logs, sub: sub}, nil } -// WatchPartialBondRefund is a free log subscription operation binding the contract event 0x938a52b87ed1353360e17d203a73343c4e92b6a9e9a0b50d0e38df31fbf14219. +// WatchPartialBondRefund is a free log subscription operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. // -// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success, bytes ret) +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) func (_ITournament *ITournamentFilterer) WatchPartialBondRefund(opts *bind.WatchOpts, sink chan<- *ITournamentPartialBondRefund, recipient []common.Address, success []bool) (event.Subscription, error) { var recipientRule []interface{} @@ -1979,9 +2272,9 @@ func (_ITournament *ITournamentFilterer) WatchPartialBondRefund(opts *bind.Watch }), nil } -// ParsePartialBondRefund is a log parse operation binding the contract event 0x938a52b87ed1353360e17d203a73343c4e92b6a9e9a0b50d0e38df31fbf14219. +// ParsePartialBondRefund is a log parse operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. // -// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success, bytes ret) +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) func (_ITournament *ITournamentFilterer) ParsePartialBondRefund(log types.Log) (*ITournamentPartialBondRefund, error) { event := new(ITournamentPartialBondRefund) if err := _ITournament.contract.UnpackLog(event, "PartialBondRefund", log); err != nil { diff --git a/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go b/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go index 4093256fa..bf5504b5c 100644 --- a/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go +++ b/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go @@ -31,7 +31,7 @@ var ( // IUsdWithdrawalOutputBuilderMetaData contains all meta data concerning the IUsdWithdrawalOutputBuilder contract. var IUsdWithdrawalOutputBuilderMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"buildWithdrawalOutput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"token\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIERC20\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"AccountTooShort\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minAccountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"buildWithdrawalOutput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"token\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIERC20\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"InvalidAccountSize\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"accountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]}]", } // IUsdWithdrawalOutputBuilderABI is the input ABI used to generate the binding from. From ffdd54bda7c711b1e8fa54dc78cfe547f6ef35ec Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:15 -0300 Subject: [PATCH 06/41] feat(ethutil): adapt helpers to v3 contracts Use direct InputBox references and the new application and consensus factory arguments. Read complete return data and expose the new staging and tournament configuration through the shared Ethereum helpers. Use the final USD withdrawal account layout and update revert decoding. Keep helper tests and the RPC test synchronization correction with the implementation they verify. Allow callers to control deployment broadcast and receipt waiting while keeping factory construction, result parsing, and verification here. Retain existing Deploy entry points and signed hashes on send failures. Provide an explicit legacy-fee factory with a fresh suggested price for each transaction. Keep network-based automatic selection for callers that do not opt in. --- pkg/ethutil/anvil.go | 30 ++- pkg/ethutil/application.go | 57 +++-- pkg/ethutil/authority.go | 35 ++- pkg/ethutil/data-availability.go | 104 -------- pkg/ethutil/deployment_transaction.go | 57 +++++ pkg/ethutil/deployment_transaction_test.go | 284 +++++++++++++++++++++ pkg/ethutil/ethutil.go | 14 +- pkg/ethutil/ethutil_test.go | 17 +- pkg/ethutil/legacy_fees.go | 45 ++++ pkg/ethutil/legacy_fees_test.go | 220 ++++++++++++++++ pkg/ethutil/prt.go | 161 +++++++----- pkg/ethutil/prt_test.go | 136 ++++++++++ pkg/ethutil/quorum.go | 31 ++- pkg/ethutil/return_data_test.go | 69 +++++ pkg/ethutil/rpcerror.go | 16 ++ pkg/ethutil/rpcerror_test.go | 12 +- pkg/ethutil/selfhosted.go | 74 ++++-- pkg/ethutil/withdrawal_account.go | 62 ++--- pkg/ethutil/withdrawal_account_test.go | 56 ++-- 19 files changed, 1173 insertions(+), 307 deletions(-) delete mode 100644 pkg/ethutil/data-availability.go create mode 100644 pkg/ethutil/deployment_transaction.go create mode 100644 pkg/ethutil/deployment_transaction_test.go create mode 100644 pkg/ethutil/legacy_fees.go create mode 100644 pkg/ethutil/legacy_fees_test.go create mode 100644 pkg/ethutil/prt_test.go create mode 100644 pkg/ethutil/return_data_test.go diff --git a/pkg/ethutil/anvil.go b/pkg/ethutil/anvil.go index d07e31177..c8e2a7e81 100644 --- a/pkg/ethutil/anvil.go +++ b/pkg/ethutil/anvil.go @@ -8,14 +8,22 @@ import ( "fmt" "log" - "github.com/cartesi/rollups-node/internal/config" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" ) -func CreateAnvilSnapshotAndDeployApp(ctx context.Context, client *ethclient.Client, factoryAddr common.Address, templateHash common.Hash, dataAvailability []byte, salt string) (common.Address, func(), error) { +const anvilTestEpochLength uint64 = 10 + +func CreateAnvilSnapshotAndDeployApp( + ctx context.Context, + client *ethclient.Client, + factoryAddr common.Address, + templateHash common.Hash, + inputBoxAddress common.Address, + salt string, +) (common.Address, func(), error) { zero := common.Address{} if client == nil { return zero, nil, fmt.Errorf("ethclient Client is nil") @@ -45,21 +53,19 @@ func CreateAnvilSnapshotAndDeployApp(ctx context.Context, client *ethclient.Clie return zero, nil, fmt.Errorf("failed to create TransactOpts: %w", err) } - // build the self hosted deployment struct - selfHostedApplicationFactoryAddress, err := config.GetContractsSelfHostedApplicationFactoryAddress() + parsedSalt, err := ParseSalt(salt) if err != nil { _ = RevertToAnvilSnapshot(client.Client(), snapshotID) - return zero, nil, fmt.Errorf("failed to retrieve self hosted application factory address: %w", err) + return zero, nil, fmt.Errorf("failed to parse salt: %w", err) } deployment := &SelfhostedApplicationDeployment{ - FactoryAddress: selfHostedApplicationFactoryAddress, - ApplicationOwnerAddress: txOpts.From, - AuthorityOwnerAddress: txOpts.From, - TemplateHash: templateHash, - DataAvailability: dataAvailability, - EpochLength: 10, - Salt: [32]byte{}, + FactoryAddress: factoryAddr, + AuthorityOwnerAddress: txOpts.From, + TemplateHash: templateHash, + InputBoxAddress: inputBoxAddress, + EpochLength: anvilTestEpochLength, + Salt: parsedSalt, } applicationAddress, _, err := deployment.Deploy(ctx, client, NewStaticTransactOptsFactory(txOpts)) if err != nil { diff --git a/pkg/ethutil/application.go b/pkg/ethutil/application.go index 3e0e7cc5b..19f13a13f 100644 --- a/pkg/ethutil/application.go +++ b/pkg/ethutil/application.go @@ -4,17 +4,20 @@ package ethutil import ( "context" - "encoding/hex" "fmt" "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) type IApplicationDeployment interface { - Deploy(ctx context.Context, client *ethclient.Client, txOptsFactory TransactOptsFactory) (common.Address, IApplicationDeploymentResult, error) + Deploy(context.Context, *ethclient.Client, TransactOptsFactory) (common.Address, IApplicationDeploymentResult, error) + DeployWithTransaction( + context.Context, *ethclient.Client, TransactOptsFactory, TransactionRunner, + ) (common.Address, IApplicationDeploymentResult, error) GetFactoryAddress() common.Address } type IApplicationDeploymentResult interface{} @@ -23,7 +26,6 @@ type ApplicationDeployment struct { FactoryAddress common.Address `json:"factory"` Consensus common.Address `json:"consensus"` OwnerAddress common.Address `json:"owner"` - DataAvailability []byte `json:"-"` TemplateHash common.Hash `json:"template_hash"` WithdrawalConfig iapplicationfactory.WithdrawalConfig `json:"withdrawal_config"` Salt SaltBytes `json:"salt"` @@ -46,13 +48,13 @@ type ApplicationDeploymentResult struct { func (me *ApplicationDeployment) String() string { result := "" - result += fmt.Sprintf("application deployment:\n") + result += "application deployment:\n" result += fmt.Sprintf("\tapplication owner: %v\n", me.OwnerAddress) result += fmt.Sprintf("\tconsensus address: %v\n", me.Consensus) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) if me.Verbose { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) - result += fmt.Sprintf("\tdata availability: 0x%v\n", hex.EncodeToString(me.DataAvailability)) result += fmt.Sprintf("\tsalt: %v\n", me.Salt) result += fmt.Sprintf("\tepoch length: %v\n", me.EpochLength) if me.ConsensusType != "" { @@ -72,6 +74,18 @@ func (me *ApplicationDeployment) Deploy( ctx context.Context, client *ethclient.Client, txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction uses runner to submit the deployment. A broadcast-only +// runner returns the predicted address and no confirmed deployment result. +// The predicted address does not prove that the deployment transaction succeeded. +func (me *ApplicationDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, + runner TransactionRunner, ) (common.Address, IApplicationDeploymentResult, error) { zero := common.Address{} result := &ApplicationDeploymentResult{} @@ -89,7 +103,15 @@ func (me *ApplicationDeployment) Deploy( } // check if addresses are available (have no code) - applicationAddress, err := factory.CalculateApplicationAddress(nil, me.Consensus, me.OwnerAddress, me.TemplateHash, me.DataAvailability, me.WithdrawalConfig, me.Salt) + applicationAddress, err := factory.CalculateApplicationAddress( + &bind.CallOpts{Context: ctx}, + me.Consensus, + me.OwnerAddress, + me.TemplateHash, + me.InputBoxAddress, + me.WithdrawalConfig, + me.Salt, + ) if err != nil { return zero, nil, err } @@ -99,7 +121,7 @@ func (me *ApplicationDeployment) Deploy( return zero, nil, err } if len(applicationCode) != 0 { - return zero, nil, fmt.Errorf("application with address: %v already exists. Try a different salt.", applicationAddress) + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", applicationAddress) } // deploy the contracts @@ -108,18 +130,23 @@ func (me *ApplicationDeployment) Deploy( return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) } - tx, err := factory.NewApplication0(txOpts, me.Consensus, me.OwnerAddress, me.TemplateHash, me.DataAvailability, me.WithdrawalConfig, me.Salt) + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewApplication0( + opts, + me.Consensus, + me.OwnerAddress, + me.TemplateHash, + me.InputBoxAddress, + me.WithdrawalConfig, + me.Salt, + ) + }, runner) if err != nil { return zero, nil, fmt.Errorf("transaction failed: %w", err) } - receipt, err := bind.WaitMined(ctx, client, tx) - if err != nil { - return zero, nil, fmt.Errorf("failed to wait for transaction mining: %w", err) - } - - if receipt.Status != 1 { - return zero, nil, fmt.Errorf("transaction failed") + if receipt == nil { + return applicationAddress, nil, nil } // Look for the specific event in the receipt logs diff --git a/pkg/ethutil/authority.go b/pkg/ethutil/authority.go index 228d24596..b5139418f 100644 --- a/pkg/ethutil/authority.go +++ b/pkg/ethutil/authority.go @@ -10,6 +10,7 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) @@ -25,7 +26,7 @@ type AuthorityDeployment struct { func (me *AuthorityDeployment) String() string { result := "" - result += fmt.Sprintf("authority deployment:\n") + result += "authority deployment:\n" result += fmt.Sprintf("\tauthority owner: %v\n", me.OwnerAddress) if me.Verbose { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) @@ -40,6 +41,17 @@ func (me *AuthorityDeployment) Deploy( ctx context.Context, client *ethclient.Client, txOpts *bind.TransactOpts, +) (common.Address, error) { + return me.DeployWithTransaction(ctx, client, txOpts, nil) +} + +// DeployWithTransaction returns a predicted address when runner does not wait for +// a receipt. The prediction does not prove that the deployment transaction succeeded. +func (me *AuthorityDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, + runner TransactionRunner, ) (common.Address, error) { zero := common.Address{} factory, err := iauthorityfactory.NewIAuthorityFactory(me.FactoryAddress, client) @@ -48,7 +60,11 @@ func (me *AuthorityDeployment) Deploy( } // check if addresses are available (have no code) - authorityAddress, err := factory.CalculateAuthorityAddress(nil, me.OwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), me.Salt) + epochLength := new(big.Int).SetUint64(me.EpochLength) + claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) + authorityAddress, err := factory.CalculateAuthorityAddress( + &bind.CallOpts{Context: ctx}, me.OwnerAddress, epochLength, claimStagingPeriod, me.Salt, + ) if err != nil { return zero, err } @@ -58,22 +74,19 @@ func (me *AuthorityDeployment) Deploy( return zero, err } if len(authorityCode) != 0 { - return zero, fmt.Errorf("authority with address: %v already exists. Try a different salt.", authorityAddress) + return zero, fmt.Errorf("authority with address %v already exists; use a different salt", authorityAddress) } // deploy the contracts - tx, err := factory.NewAuthority0(txOpts, me.OwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), me.Salt) + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewAuthority0(opts, me.OwnerAddress, epochLength, claimStagingPeriod, me.Salt) + }, runner) if err != nil { return common.Address{}, fmt.Errorf("failed to create new authority: %w", err) } - receipt, err := bind.WaitMined(ctx, client, tx) - if err != nil { - return common.Address{}, fmt.Errorf("failed to mine new authority transaction: %w", err) - } - - if receipt.Status != 1 { - return common.Address{}, fmt.Errorf("transaction failed") + if receipt == nil { + return authorityAddress, nil } // search for the matching event diff --git a/pkg/ethutil/data-availability.go b/pkg/ethutil/data-availability.go deleted file mode 100644 index 442c42dff..000000000 --- a/pkg/ethutil/data-availability.go +++ /dev/null @@ -1,104 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) -package ethutil - -import ( - "encoding/hex" - "fmt" - "strings" - - "github.com/cartesi/rollups-node/internal/model" - "github.com/cartesi/rollups-node/pkg/contracts/dataavailability" - "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" -) - -func DefaultDA(client *ethclient.Client, inputBoxAddress common.Address) (common.Address, uint64, []byte, error) { - parsedABI, err := dataavailability.DataAvailabilityMetaData.GetAbi() - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("failed to get data availability ABI: %w", err) - } - - encodedDA, err := parsedABI.Pack("InputBox", inputBoxAddress) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("failed pack input box data availability string with: %w", err) - } - - inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("failed to create input box instance: %w", err) - } - - inputBoxBlock, err := inputBox.GetDeploymentBlockNumber(nil) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("failed to get deployment block number: %w", err) - } - - return inputBoxAddress, inputBoxBlock.Uint64(), encodedDA, nil -} - -func CustomDA(client *ethclient.Client, dataAvailability string) (common.Address, uint64, []byte, error) { - if len(dataAvailability) < 3 || (!strings.HasPrefix(dataAvailability, "0x") && !strings.HasPrefix(dataAvailability, "0X")) { - return common.Address{}, 0, nil, fmt.Errorf("data Availability should be an ABI encoded value") - } - - s := dataAvailability[2:] - encodedDA, err := hex.DecodeString(s) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("error parsing Data Availability value: %w", err) - } - - inputBoxAddress, inputBoxBlock, err := DecodeDA(client, encodedDA) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("error decoding Data Availability value: %w", err) - } - - return inputBoxAddress, inputBoxBlock, encodedDA, nil - -} - -func DecodeDA(client *ethclient.Client, encodedDA []byte) (common.Address, uint64, error) { - parsedAbi, err := dataavailability.DataAvailabilityMetaData.GetAbi() - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to get ABI: %w", err) - } - - if len(encodedDA) < model.DATA_AVAILABILITY_SELECTOR_SIZE { - return common.Address{}, 0, fmt.Errorf("invalid Data Availability") - } - - method, err := parsedAbi.MethodById(encodedDA[:model.DATA_AVAILABILITY_SELECTOR_SIZE]) - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to get method by ID: %w", err) - } - - args, err := method.Inputs.Unpack(encodedDA[model.DATA_AVAILABILITY_SELECTOR_SIZE:]) - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to unpack inputs: %w", err) - } - - if len(args) == 0 { - return common.Address{}, 0, fmt.Errorf("invalid Data Availability. Should at least contain InputBox Address") - } - - var inputBoxAddress common.Address - switch addr := args[0].(type) { - case common.Address: - inputBoxAddress = addr - default: - return common.Address{}, 0, fmt.Errorf("first argument in Data Availability is not an address (got %T)", args[0]) - } - - inputbox, err := iinputbox.NewIInputBox(inputBoxAddress, client) - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to create input box instance: %w", err) - } - - inputBoxBlock, err := inputbox.GetDeploymentBlockNumber(nil) - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to get deployment block number: %w", err) - } - - return inputBoxAddress, inputBoxBlock.Uint64(), nil -} diff --git a/pkg/ethutil/deployment_transaction.go b/pkg/ethutil/deployment_transaction.go new file mode 100644 index 000000000..08a5403d2 --- /dev/null +++ b/pkg/ethutil/deployment_transaction.go @@ -0,0 +1,57 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +// TransactionRunner controls transaction submission and receipt waiting. A nil +// receipt with no error means broadcast-only: the deployment is not yet confirmed. +type TransactionRunner func( + context.Context, + *bind.TransactOpts, + func(*bind.TransactOpts) (*types.Transaction, error), +) (*types.Receipt, error) + +func runDeploymentTransaction( + ctx context.Context, + client *ethclient.Client, + opts *bind.TransactOpts, + build func(*bind.TransactOpts) (*types.Transaction, error), + runner TransactionRunner, +) (*types.Receipt, error) { + var receipt *types.Receipt + var err error + if runner != nil { + receipt, err = runner(ctx, opts, build) + } else { + prepared := *opts + prepared.Context = ctx + prepared.NoSend = true + tx, buildErr := build(&prepared) + if buildErr != nil { + return nil, fmt.Errorf("failed to prepare deployment transaction: %w", buildErr) + } + if err := client.SendTransaction(ctx, tx); err != nil { + return nil, fmt.Errorf("failed to broadcast deployment transaction %s: %w", tx.Hash(), err) + } + receipt, err = bind.WaitMined(ctx, client, tx) + if err != nil { + return nil, fmt.Errorf("failed to wait for deployment transaction %s; outcome is unknown: %w", tx.Hash(), err) + } + } + if err != nil { + return nil, err + } + if receipt != nil && receipt.Status != types.ReceiptStatusSuccessful { + return nil, fmt.Errorf("deployment transaction %s failed", receipt.TxHash) + } + return receipt, nil +} diff --git a/pkg/ethutil/deployment_transaction_test.go b/pkg/ethutil/deployment_transaction_test.go new file mode 100644 index 000000000..abda763c2 --- /dev/null +++ b/pkg/ethutil/deployment_transaction_test.go @@ -0,0 +1,284 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iselfhostedapplicationfactory" +) + +func TestDeploymentsHandleBroadcastOnlyAndFailedReceipts(t *testing.T) { + appAddress := common.HexToAddress("0x01") + consensusAddress := common.HexToAddress("0x02") + factoryAddress := common.HexToAddress("0x03") + app := &ApplicationDeployment{FactoryAddress: factoryAddress} + selfhosted := &SelfhostedApplicationDeployment{FactoryAddress: factoryAddress} + prt := &PRTApplicationDeployment{ApplicationDeployment: ApplicationDeployment{FactoryAddress: factoryAddress}} + authority := &AuthorityDeployment{FactoryAddress: factoryAddress} + quorum := &QuorumDeployment{FactoryAddress: factoryAddress} + + for _, variant := range []struct { + name string + metadata *bind.MetaData + method string + outputs []any + deploy func(context.Context, *ethclient.Client, *bind.TransactOpts, TransactionRunner) (common.Address, error) + }{ + { + name: "application", metadata: iapplicationfactory.IApplicationFactoryMetaData, + method: "calculateApplicationAddress", outputs: []any{appAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := app.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "selfhosted", metadata: iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, + method: "calculateAddresses", outputs: []any{appAddress, consensusAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := selfhosted.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "prt", metadata: idaveappfactory.IDaveAppFactoryMetaData, + method: "calculateDaveAppAddress", outputs: []any{appAddress, consensusAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := prt.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "authority", metadata: iauthorityfactory.IAuthorityFactoryMetaData, + method: "calculateAuthorityAddress", outputs: []any{consensusAddress}, deploy: authority.DeployWithTransaction, + }, + { + name: "quorum", metadata: iquorumfactory.IQuorumFactoryMetaData, + method: "calculateQuorumAddress", outputs: []any{consensusAddress}, deploy: quorum.DeployWithTransaction, + }, + } { + t.Run(variant.name, func(t *testing.T) { + for _, noWait := range []bool{true, false} { + t.Run(fmt.Sprintf("no_wait_%t", noWait), func(t *testing.T) { + parsed, err := variant.metadata.GetAbi() + require.NoError(t, err) + method := parsed.Methods[variant.method] + data, err := method.Outputs.Pack(variant.outputs...) + require.NoError(t, err) + backend := &deploymentCallBackend{responses: map[string]hexutil.Bytes{string(method.ID): data}} + if variant.name == "selfhosted" { + for _, name := range []string{"getApplicationFactory", "getAuthorityFactory"} { + getter := parsed.Methods[name] + getterData, err := getter.Outputs.Pack(factoryAddress) + require.NoError(t, err) + backend.responses[string(getter.ID)] = getterData + } + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + opts := &bind.TransactOpts{ + From: common.HexToAddress("0x04"), Nonce: big.NewInt(0), + GasPrice: big.NewInt(1), GasLimit: 1_000_000, NoSend: true, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + called := false + var hash common.Hash + address, err := variant.deploy(t.Context(), client, opts, + func( + ctx context.Context, txOpts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + called = true + require.Equal(t, t.Context(), ctx) + tx, err := build(txOpts) + require.NoError(t, err) + hash = tx.Hash() + if noWait { + return nil, nil + } + return &types.Receipt{Status: types.ReceiptStatusFailed, TxHash: hash}, nil + }) + require.True(t, called) + if noWait { + require.NoError(t, err) + require.Equal(t, variant.outputs[0], address, "return the primary address computed before broadcast") + } else { + require.Zero(t, address) + require.ErrorContains(t, err, hash.Hex()) + require.ErrorContains(t, err, "failed") + } + }) + } + }) + } +} + +type deploymentCallBackend struct { + responses map[string]hexutil.Bytes +} + +func (b *deploymentCallBackend) Call(_ context.Context, call prtFactoryCall, _ string) (hexutil.Bytes, error) { + if len(call.Input) >= 4 { + if response, found := b.responses[string(call.Input[:4])]; found { + return response, nil + } + } + return nil, fmt.Errorf("unexpected contract call: %x", call.Input) +} + +func (*deploymentCallBackend) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{}, nil +} + +func TestDefaultDeploymentRunnerKeepsHashAndChecksReceipt(t *testing.T) { + tx := types.NewTx(&types.LegacyTx{Nonce: 7, Gas: 12345, GasPrice: big.NewInt(1)}) + for _, test := range []struct { + name string + sendError error + receiptError error + status uint64 + wantError string + }{ + {name: "mined", status: types.ReceiptStatusSuccessful}, + {name: "reverted", status: types.ReceiptStatusFailed, wantError: "failed"}, + {name: "broadcast error", sendError: errors.New("connection closed"), wantError: "failed to broadcast"}, + {name: "wait timeout", receiptError: errors.New("provider unavailable"), wantError: "outcome is unknown"}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &deploymentReceiptBackend{ + sendError: test.sendError, receiptError: test.receiptError, + receipt: &types.Receipt{ + TxHash: tx.Hash(), Status: test.status, BlockNumber: big.NewInt(1), Logs: []*types.Log{}, + }, + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + opts := &bind.TransactOpts{GasLimit: 12345} + receipt, err := runDeploymentTransaction(ctx, client, opts, func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.True(t, prepared.NoSend) + require.Equal(t, opts.GasLimit, prepared.GasLimit) + require.Equal(t, ctx, prepared.Context) + return tx, nil + }, nil) + require.False(t, opts.NoSend, "the caller's options must remain unchanged") + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Nil(t, receipt) + } else { + require.NoError(t, err) + require.Equal(t, tx.Hash(), receipt.TxHash) + } + }) + } +} + +type deploymentReceiptBackend struct { + sendError error + receiptError error + receipt *types.Receipt +} + +func (b *deploymentReceiptBackend) SendRawTransaction(context.Context, hexutil.Bytes) (common.Hash, error) { + return b.receipt.TxHash, b.sendError +} + +func (b *deploymentReceiptBackend) GetTransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) { + return b.receipt, b.receiptError +} + +func TestSelfhostedDefaultPreservesTransactionPreparation(t *testing.T) { + parsed, err := iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData.GetAbi() + require.NoError(t, err) + backend := &selfhostedTransactionBackend{deploymentCallBackend: &deploymentCallBackend{responses: map[string]hexutil.Bytes{}}} + for _, name := range []string{"calculateAddresses", "getApplicationFactory", "getAuthorityFactory"} { + method := parsed.Methods[name] + outputs := []any{common.HexToAddress("0x01")} + if name == "calculateAddresses" { + outputs = append(outputs, common.HexToAddress("0x02")) + } + data, err := method.Outputs.Pack(outputs...) + require.NoError(t, err) + backend.responses[string(method.ID)] = data + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + opts := &bind.TransactOpts{ + From: common.HexToAddress("0x03"), Nonce: big.NewInt(99), GasPrice: big.NewInt(88), Value: big.NewInt(77), GasLimit: 12345, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + deployment := &SelfhostedApplicationDeployment{FactoryAddress: common.HexToAddress("0x04")} + _, _, err = deployment.Deploy(t.Context(), client, NewStaticTransactOptsFactory(opts)) + require.ErrorContains(t, err, "stop after transaction preparation") + require.NotNil(t, backend.tx) + require.Equal(t, uint64(7), backend.tx.Nonce()) + require.Equal(t, big.NewInt(42), backend.tx.GasPrice()) + require.Zero(t, backend.tx.Value().Sign()) + require.Equal(t, opts.GasLimit, backend.tx.Gas()) + require.Equal(t, big.NewInt(99), opts.Nonce, "the original caller options are not changed") +} + +type selfhostedTransactionBackend struct { + *deploymentCallBackend + tx *types.Transaction +} + +func (*selfhostedTransactionBackend) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 7 +} + +func (*selfhostedTransactionBackend) GasPrice(context.Context) hexutil.Uint64 { + return 42 +} + +func (b *selfhostedTransactionBackend) SendRawTransaction(_ context.Context, data hexutil.Bytes) (common.Hash, error) { + b.tx = new(types.Transaction) + if err := b.tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + return b.tx.Hash(), fmt.Errorf("stop after transaction preparation") +} diff --git a/pkg/ethutil/ethutil.go b/pkg/ethutil/ethutil.go index 5d31f7e67..d53d818c1 100644 --- a/pkg/ethutil/ethutil.go +++ b/pkg/ethutil/ethutil.go @@ -280,23 +280,23 @@ func GetConsensusAt( return consensus, nil } -func GetDataAvailability( +func GetInputBox( ctx context.Context, client *ethclient.Client, appAddress common.Address, -) ([]byte, error) { +) (common.Address, error) { if client == nil { - return nil, fmt.Errorf("get dataAvailability: client is nil") + return common.Address{}, fmt.Errorf("get input box: client is nil") } app, err := iapplication.NewIApplication(appAddress, client) if err != nil { - return nil, fmt.Errorf("Failed to instantiate contract: %w", err) + return common.Address{}, fmt.Errorf("failed to instantiate contract: %w", err) } - dataAvailability, err := app.GetDataAvailability(&bind.CallOpts{Context: ctx}) + inputBox, err := app.GetInputBox(&bind.CallOpts{Context: ctx}) if err != nil { - return nil, fmt.Errorf("error retrieving application epoch length: %w", err) + return common.Address{}, fmt.Errorf("error retrieving application input box: %w", err) } - return dataAvailability, nil + return inputBox, nil } func GetEpochLength( diff --git a/pkg/ethutil/ethutil_test.go b/pkg/ethutil/ethutil_test.go index cf9ddebb6..4024f927a 100644 --- a/pkg/ethutil/ethutil_test.go +++ b/pkg/ethutil/ethutil_test.go @@ -10,6 +10,7 @@ import ( "math/big" "os" "sync" + "sync/atomic" "testing" "time" @@ -73,9 +74,15 @@ func (s *EthUtilSuite) SetupTest() { s.inputBoxAddr, err = config.GetContractsInputBoxAddress() s.Require().Nil(err) - _, _, encodedDA, err := DefaultDA(s.client, s.inputBoxAddr) salt := "0000000000000000000000000000000000000000000000000000000000000000" - s.appAddr, s.cleanup, err = CreateAnvilSnapshotAndDeployApp(s.ctx, s.client, s.selfHostedAppFactory, templateHash, encodedDA, salt) + s.appAddr, s.cleanup, err = CreateAnvilSnapshotAndDeployApp( + s.ctx, + s.client, + s.selfHostedAppFactory, + templateHash, + s.inputBoxAddr, + salt, + ) s.Require().Nil(err) } @@ -177,11 +184,11 @@ func TestAddInputAsyncUsesContextForBindingTransaction(t *testing.T) { ) require.ErrorIs(t, err, context.DeadlineExceeded) - require.True(t, backend.estimateGasCalled, "expected AddInputAsync to reach the binding gas-estimation boundary") + require.True(t, backend.estimateGasCalled.Load(), "expected AddInputAsync to reach the binding gas-estimation boundary") } type addInputAsyncContextBackend struct { - estimateGasCalled bool + estimateGasCalled atomic.Bool estimateGasTimeout time.Duration } @@ -209,7 +216,7 @@ func (b *addInputAsyncContextBackend) EstimateGas( ctx context.Context, _ map[string]interface{}, ) (hexutil.Uint64, error) { - b.estimateGasCalled = true + b.estimateGasCalled.Store(true) select { case <-ctx.Done(): return 0, ctx.Err() diff --git a/pkg/ethutil/legacy_fees.go b/pkg/ethutil/legacy_fees.go new file mode 100644 index 000000000..912d7fca6 --- /dev/null +++ b/pkg/ethutil/legacy_fees.go @@ -0,0 +1,45 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +type legacyFeeFactory struct { + TransactOptsFactory + client ethereum.GasPricer +} + +// WithLegacyFees forces legacy transactions with a fresh suggested gas price +// for each transaction. Without this wrapper, the binding selects the fee +// format from the caller's options and the network's latest block. +// Gas estimation, signing, nonce selection, value, and NoSend are unchanged. +func WithLegacyFees(factory TransactOptsFactory, client ethereum.GasPricer) TransactOptsFactory { + return &legacyFeeFactory{TransactOptsFactory: factory, client: client} +} + +func (f *legacyFeeFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts, err := f.TransactOptsFactory.NewTransactOpts(ctx) + if err != nil { + return nil, err + } + price, err := f.client.SuggestGasPrice(ctx) + if err != nil { + return nil, fmt.Errorf("suggest legacy gas price: %w", err) + } + // Zero is valid for a zero-fee network; a missing or negative price is not. + if price == nil || price.Sign() < 0 { + return nil, fmt.Errorf("suggest legacy gas price: invalid price %v", price) + } + prepared := *opts + prepared.GasPrice = price + prepared.GasFeeCap = nil + prepared.GasTipCap = nil + return &prepared, nil +} diff --git a/pkg/ethutil/legacy_fees_test.go b/pkg/ethutil/legacy_fees_test.go new file mode 100644 index 000000000..8f388f469 --- /dev/null +++ b/pkg/ethutil/legacy_fees_test.go @@ -0,0 +1,220 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "math/big" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" +) + +func TestLegacyFeeFactorySignedTransactions(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + force bool + baseFee *big.Int + gasLimit uint64 + wantType uint8 + wantPrices int32 + }{ + {name: "automatic dynamic", baseFee: big.NewInt(10), wantType: types.DynamicFeeTxType}, + {name: "automatic legacy", wantType: types.LegacyTxType, wantPrices: 2}, + {name: "forced legacy estimated", force: true, baseFee: big.NewInt(10), wantType: types.LegacyTxType, wantPrices: 2}, + { + name: "forced legacy manual gas", force: true, baseFee: big.NewInt(10), gasLimit: 123456, + wantType: types.LegacyTxType, wantPrices: 2, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + backend := &legacyFeeRPC{baseFee: test.baseFee} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + key, err := crypto.GenerateKey() + require.NoError(t, err) + chainID := big.NewInt(31337) + opts, err := bind.NewKeyedTransactorWithChainID(key, chainID) + require.NoError(t, err) + opts.Nonce = big.NewInt(7) + opts.Value = big.NewInt(19) + opts.GasLimit = test.gasLimit + opts.NoSend = true + factory := NewStaticTransactOptsFactory(opts) + if test.force { + // A forced format must not leave incompatible dynamic fee fields. + opts.GasFeeCap, opts.GasTipCap = big.NewInt(20), big.NewInt(2) + factory = WithLegacyFees(factory, client) + } + require.Equal(t, opts.From, factory.From()) + inputBox, err := iinputbox.NewIInputBox(common.HexToAddress("0x01"), client) + require.NoError(t, err) + for transaction := int64(1); transaction <= 2; transaction++ { + prepared, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Equal(t, t.Context(), prepared.Context) + tx, err := inputBox.AddInput(prepared, common.HexToAddress("0x02"), []byte{3}) + require.NoError(t, err) + require.Equal(t, test.wantType, tx.Type()) + require.Equal(t, opts.Nonce.Uint64(), tx.Nonce()) + require.Equal(t, opts.Value, tx.Value()) + require.Equal(t, chainID, tx.ChainId()) + from, err := types.Sender(types.LatestSignerForChainID(chainID), tx) + require.NoError(t, err) + require.Equal(t, opts.From, from) + if test.wantType == types.LegacyTxType { + require.Equal(t, big.NewInt(100+transaction), tx.GasPrice(), "fetch a fresh price for each transaction") + } + if test.gasLimit == 0 { + require.EqualValues(t, 75000, tx.Gas()) + } else { + require.Equal(t, test.gasLimit, tx.Gas()) + } + } + require.Nil(t, opts.GasPrice, "do not mutate the source options") + if test.force { + require.Equal(t, big.NewInt(20), opts.GasFeeCap) + require.Equal(t, big.NewInt(2), opts.GasTipCap) + } + require.Equal(t, test.wantPrices, backend.prices.Load()) + if test.gasLimit == 0 { + require.EqualValues(t, 2, backend.estimates.Load()) + } else { + require.Zero(t, backend.estimates.Load()) + } + }) + } +} + +type legacyFeeRPC struct { + baseFee *big.Int + prices atomic.Int32 + estimates atomic.Int32 +} + +func (r *legacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} + +func (r *legacyFeeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(0), BaseFee: r.baseFee}, nil +} + +func (*legacyFeeRPC) MaxPriorityFeePerGas(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(2)), nil +} + +func (*legacyFeeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (r *legacyFeeRPC) EstimateGas(context.Context, map[string]any) (hexutil.Uint64, error) { + r.estimates.Add(1) + return 75000, nil +} + +type legacyGasPricer func(context.Context) (*big.Int, error) + +func (p legacyGasPricer) SuggestGasPrice(ctx context.Context) (*big.Int, error) { return p(ctx) } + +func TestLegacyFeeFactoryErrors(t *testing.T) { + t.Parallel() + cause := errors.New("provider unavailable") + for _, test := range []struct { + name string + price *big.Int + err error + }{ + {name: "RPC error", err: cause}, + {name: "missing price"}, + {name: "negative price", price: big.NewInt(-1)}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + opts := &bind.TransactOpts{GasPrice: big.NewInt(2), GasFeeCap: big.NewInt(3), GasTipCap: big.NewInt(1)} + factory := WithLegacyFees(NewStaticTransactOptsFactory(opts), legacyGasPricer(func(ctx context.Context) (*big.Int, error) { + require.Equal(t, t.Context(), ctx) + return test.price, test.err + })) + prepared, err := factory.NewTransactOpts(t.Context()) + require.Nil(t, prepared) + require.ErrorContains(t, err, "suggest legacy gas price") + if test.err != nil { + require.ErrorIs(t, err, test.err) + } + require.Equal(t, big.NewInt(2), opts.GasPrice) + require.Equal(t, big.NewInt(3), opts.GasFeeCap) + require.Equal(t, big.NewInt(1), opts.GasTipCap) + }) + } +} + +func TestLegacyFeeFactoryAcceptsZeroPrice(t *testing.T) { + t.Parallel() + opts := &bind.TransactOpts{GasPrice: big.NewInt(2), GasFeeCap: big.NewInt(3), GasTipCap: big.NewInt(1)} + factory := WithLegacyFees(NewStaticTransactOptsFactory(opts), legacyGasPricer(func(ctx context.Context) (*big.Int, error) { + require.Equal(t, t.Context(), ctx) + return big.NewInt(0), nil + })) + prepared, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.NotNil(t, prepared) + require.Equal(t, big.NewInt(0), prepared.GasPrice) + require.Nil(t, prepared.GasFeeCap) + require.Nil(t, prepared.GasTipCap) + require.Equal(t, big.NewInt(2), opts.GasPrice) + require.Equal(t, big.NewInt(3), opts.GasFeeCap) + require.Equal(t, big.NewInt(1), opts.GasTipCap) +} + +type failedLegacyOptsFactory struct { + TransactOptsFactory + err error +} + +func (f failedLegacyOptsFactory) NewTransactOpts(context.Context) (*bind.TransactOpts, error) { + return nil, f.err +} + +func TestLegacyFeeFactoryPreservesPreparationErrors(t *testing.T) { + t.Parallel() + cause := errors.New("signer unavailable") + factory := WithLegacyFees(failedLegacyOptsFactory{err: cause}, legacyGasPricer(func(context.Context) (*big.Int, error) { + t.Fatal("do not request a price when signer preparation fails") + return nil, nil + })) + opts, err := factory.NewTransactOpts(t.Context()) + require.Nil(t, opts) + require.ErrorIs(t, err, cause) +} + +func TestLegacyFeeFactoryPreservesCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + factory := WithLegacyFees(NewStaticTransactOptsFactory(&bind.TransactOpts{}), + legacyGasPricer(func(priceCtx context.Context) (*big.Int, error) { + require.Equal(t, ctx, priceCtx) + return nil, priceCtx.Err() + })) + opts, err := factory.NewTransactOpts(ctx) + require.Nil(t, opts) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/pkg/ethutil/prt.go b/pkg/ethutil/prt.go index 128dc17ef..76536ae4c 100644 --- a/pkg/ethutil/prt.go +++ b/pkg/ethutil/prt.go @@ -4,18 +4,23 @@ package ethutil import ( "context" - "encoding/hex" "fmt" + "math/big" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) type PRTApplicationDeployment struct { ApplicationDeployment + + // A zero manager disables rotation. Sentry IDs follow the supplied order. + SentryManager common.Address + Sentries []common.Address } type PRTApplicationDeploymentResult struct { @@ -23,7 +28,6 @@ type PRTApplicationDeploymentResult struct { ApplicationAddress common.Address `json:"application_address"` DaveConsensusAddress common.Address `json:"dave_consensus_address"` - DataAvailability []byte `json:"data_availability"` InputBoxAddress common.Address `json:"inputbox_address"` IInputBoxBlock uint64 `json:"inputbox_block"` @@ -31,11 +35,13 @@ type PRTApplicationDeploymentResult struct { func (me *PRTApplicationDeployment) String() string { result := "" - result += fmt.Sprintf("PRT application deployment:\n") + result += "PRT application deployment:\n" if me.Verbose { result += fmt.Sprintf("\tPRT application factory address: %v\n", me.FactoryAddress) result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) result += fmt.Sprintf("\tsalt: %v\n", me.Salt) + result += fmt.Sprintf("\tsentry manager: %v\n", me.SentryManager) + result += fmt.Sprintf("\tsentries: %v\n", me.Sentries) } return result } @@ -44,68 +50,98 @@ func (me *PRTApplicationDeploymentResult) String() string { result := "" result += fmt.Sprintf("\tapplication address: %v\n", me.ApplicationAddress) result += fmt.Sprintf("\tconsensus address: %v\n", me.DaveConsensusAddress) - result += fmt.Sprintf("\tdata availability: 0x%v\n", hex.EncodeToString(me.DataAvailability)) - result += fmt.Sprintf("\tinputbox address: %v\n", me.InputBoxAddress) - result += fmt.Sprintf("\tinputbox block address: %d\n", me.IInputBoxBlock) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) + result += fmt.Sprintf("\tinput box block: %d\n", me.IInputBoxBlock) return result } -func (me *PRTApplicationDeployment) deployPRT( +func (me *PRTApplicationDeployment) Deploy( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction returns the predicted application address and no confirmed +// deployment result when runner does not wait for a receipt. The prediction does +// not prove that the deployment transaction succeeded. +func (me *PRTApplicationDeployment) DeployWithTransaction( ctx context.Context, client *ethclient.Client, - txOpts *bind.TransactOpts, -) (common.Address, common.Address, error) { + txOptsFactory TransactOptsFactory, + runner TransactionRunner, +) (common.Address, IApplicationDeploymentResult, error) { zero := common.Address{} + result := &PRTApplicationDeploymentResult{Deployment: me} + if err := ValidateSentryAddresses(me.Sentries); err != nil { + return zero, nil, err + } factory, err := idaveappfactory.NewIDaveAppFactory(me.FactoryAddress, client) if err != nil { - return zero, zero, fmt.Errorf("failed to instantiate contract binding: %w", err) + return zero, nil, fmt.Errorf("failed to instantiate contract binding: %w", err) } if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { - return zero, zero, err + return zero, nil, err } if err := CheckWithdrawalOutputBuilderCode(ctx, client, me.WithdrawalConfig); err != nil { - return zero, zero, err + return zero, nil, err } - // idaveappfactory has its own WithdrawalConfig type with identical fields. + claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) daveWC := idaveappfactory.WithdrawalConfig(me.WithdrawalConfig) // check if addresses are available (have no code) - addresses, err := factory.CalculateDaveAppAddress(nil, me.TemplateHash, daveWC, me.Salt) + addresses, err := factory.CalculateDaveAppAddress( + &bind.CallOpts{Context: ctx}, + me.TemplateHash, + claimStagingPeriod, + me.SentryManager, + me.Sentries, + daveWC, + me.Salt, + ) if err != nil { - return zero, zero, err + return zero, nil, err } applicationCode, err := client.CodeAt(ctx, addresses.AppContractAddress, nil) if err != nil { - return zero, zero, err + return zero, nil, err } if len(applicationCode) != 0 { - return zero, zero, fmt.Errorf("application with address: %v already exists. Try a different salt.", addresses.AppContractAddress) + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", addresses.AppContractAddress) } daveConsensusCode, err := client.CodeAt(ctx, addresses.DaveConsensusAddress, nil) if err != nil { - return zero, zero, err + return zero, nil, err } if len(daveConsensusCode) != 0 { - return zero, zero, fmt.Errorf("dave consensus with address: %v already exists. Try a different salt.", addresses.DaveConsensusAddress) + return zero, nil, fmt.Errorf("dave consensus with address %v already exists; use a different salt", addresses.DaveConsensusAddress) } - // deploy the contracts - tx, err := factory.NewDaveApp(txOpts, me.TemplateHash, daveWC, me.Salt) + txOpts, err := txOptsFactory.NewTransactOpts(ctx) if err != nil { - return zero, zero, fmt.Errorf("transaction failed: %w", err) + return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) } - - receipt, err := bind.WaitMined(ctx, client, tx) + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewDaveApp( + opts, + me.TemplateHash, + claimStagingPeriod, + me.SentryManager, + me.Sentries, + daveWC, + me.Salt, + ) + }, runner) if err != nil { - return zero, zero, fmt.Errorf("failed to wait for transaction mining: %w", err) + return zero, nil, fmt.Errorf("failed to deploy Dave application and consensus contracts: %w", err) } - - if receipt.Status != 1 { - return zero, zero, fmt.Errorf("transaction failed") + if receipt == nil { + return addresses.AppContractAddress, nil, nil } // Look for the specific event in the receipt logs @@ -115,49 +151,40 @@ func (me *PRTApplicationDeployment) deployPRT( if err != nil { continue // Skip logs that don't match } - return event.AppContract, event.DaveConsensus, nil + result.ApplicationAddress = event.AppContract + result.DaveConsensusAddress = event.DaveConsensus + break } - return zero, zero, fmt.Errorf("failed to find DaveAppCreated event in receipt logs") -} - -func (me *PRTApplicationDeployment) Deploy( - ctx context.Context, - client *ethclient.Client, - txOptsFactory TransactOptsFactory, -) (common.Address, IApplicationDeploymentResult, error) { - zero := common.Address{} - result := &PRTApplicationDeploymentResult{} - result.Deployment = me - - var err error - txOpts, err := txOptsFactory.NewTransactOpts(ctx) - if err != nil { - return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) - } - - appAddress, consensusAddress, err := me.deployPRT(ctx, client, txOpts) - if err != nil { - return zero, nil, fmt.Errorf("failed to deploy Dave Application and consensus contracts: %w", err) + if result.ApplicationAddress == zero { + return zero, nil, fmt.Errorf("failed to find DaveAppCreated event in receipt logs") } - - result.ApplicationAddress = appAddress - result.DaveConsensusAddress = consensusAddress - + appAddress := result.ApplicationAddress application, err := iapplication.NewIApplication(appAddress, client) if err != nil { return zero, nil, fmt.Errorf("failed to instantiate application: %w", err) } - da, err := application.GetDataAvailability(nil) + inputBoxAddress, err := application.GetInputBox(&bind.CallOpts{Context: ctx}) if err != nil { - return zero, nil, fmt.Errorf("failed to retrieve data availability: %w", err) + return zero, nil, fmt.Errorf("failed to retrieve input box: %w", err) + } + if me.InputBoxAddress != (common.Address{}) && inputBoxAddress != me.InputBoxAddress { + return zero, nil, fmt.Errorf( + "deployed application uses input box %v instead of %v", + inputBoxAddress, + me.InputBoxAddress, + ) } - result.DataAvailability = da + result.InputBoxAddress = inputBoxAddress - result.InputBoxAddress, result.IInputBoxBlock, err = DecodeDA(client, da) + inputBoxBlock, err := GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) if err != nil { - return zero, nil, fmt.Errorf("failed to decode data availability: %w", err) + return zero, nil, fmt.Errorf("failed to retrieve input box deployment block: %w", err) + } + if !inputBoxBlock.IsUint64() { + return zero, nil, fmt.Errorf("input box deployment block does not fit uint64: %v", inputBoxBlock) } + result.IInputBoxBlock = inputBoxBlock.Uint64() if err := VerifyDeployedWithdrawalConfig(ctx, client, appAddress, me.WithdrawalConfig); err != nil { return zero, nil, err @@ -169,3 +196,19 @@ func (me *PRTApplicationDeployment) Deploy( func (me *PRTApplicationDeployment) GetFactoryAddress() common.Address { return me.FactoryAddress } + +// ValidateSentryAddresses checks the constructor's address rules without changing +// slot order. An empty list is valid and requires the full claim staging period. +func ValidateSentryAddresses(sentries []common.Address) error { + seen := make(map[common.Address]struct{}, len(sentries)) + for i, address := range sentries { + if address == (common.Address{}) { + return fmt.Errorf("sentry %d must not be the zero address", i+1) + } + if _, duplicate := seen[address]; duplicate { + return fmt.Errorf("sentry %d duplicates address %s", i+1, address) + } + seen[address] = struct{}{} + } + return nil +} diff --git a/pkg/ethutil/prt_test.go b/pkg/ethutil/prt_test.go new file mode 100644 index 000000000..257c5d900 --- /dev/null +++ b/pkg/ethutil/prt_test.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" +) + +func TestPRTDeploymentSendsFactoryArguments(t *testing.T) { + withdrawalConfig := iapplicationfactory.WithdrawalConfig{ + Guardian: common.HexToAddress("0x1000000000000000000000000000000000000001"), + } + + for _, test := range []struct { + name string + manager common.Address + sentries []common.Address + }{ + {name: "no sentries"}, + {name: "manager without slots", manager: common.HexToAddress("0x10")}, + {name: "fixed slots", sentries: []common.Address{common.HexToAddress("0x30"), common.HexToAddress("0x20")}}, + {name: "rotatable slots", manager: common.HexToAddress("0x10"), + sentries: []common.Address{common.HexToAddress("0x30"), common.HexToAddress("0x20")}}, + } { + for _, claimStagingPeriod := range []uint64{0, 1234} { + t.Run(fmt.Sprintf("%s/period_%d", test.name, claimStagingPeriod), func(t *testing.T) { + parsed, err := idaveappfactory.IDaveAppFactoryMetaData.GetAbi() + require.NoError(t, err) + predicted := common.HexToAddress("0x400") + response, err := parsed.Methods["calculateDaveAppAddress"].Outputs.Pack(predicted, common.HexToAddress("0x500")) + require.NoError(t, err) + backend := &prtFactoryCallBackend{calls: make(chan prtFactoryCall, 1), response: response} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + deployment := &PRTApplicationDeployment{ + SentryManager: test.manager, Sentries: test.sentries, ApplicationDeployment: ApplicationDeployment{ + FactoryAddress: common.HexToAddress("0x200"), + TemplateHash: common.HexToHash("0x300"), + Salt: SaltBytes{31: 4}, + ClaimStagingPeriod: claimStagingPeriod, + WithdrawalConfig: withdrawalConfig, + }, + } + + opts := &bind.TransactOpts{ + Nonce: big.NewInt(0), GasPrice: big.NewInt(1), GasLimit: 1_000_000, NoSend: true, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + var deployData []byte + address, result, err := deployment.DeployWithTransaction(t.Context(), client, NewStaticTransactOptsFactory(opts), + func( + _ context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + tx, err := build(opts) + require.NoError(t, err) + require.Equal(t, deployment.FactoryAddress, *tx.To()) + deployData = tx.Data() + return nil, nil + }) + require.NoError(t, err) + require.Equal(t, predicted, address) + require.Nil(t, result) + call := <-backend.calls + require.Equal(t, deployment.FactoryAddress, call.To) + for methodName, data := range map[string][]byte{"calculateDaveAppAddress": call.Input, "newDaveApp": deployData} { + method := parsed.Methods[methodName] + require.Equal(t, method.ID, data[:4]) + args, err := method.Inputs.Unpack(data[4:]) + require.NoError(t, err) + require.Equal(t, [32]byte(deployment.TemplateHash), args[0]) + require.Zero(t, new(big.Int).SetUint64(claimStagingPeriod).Cmp(args[1].(*big.Int))) + require.Equal(t, test.manager, args[2]) + require.Equal(t, append([]common.Address{}, test.sentries...), args[3]) + gotWithdrawal := abi.ConvertType(args[4], new(idaveappfactory.WithdrawalConfig)).(*idaveappfactory.WithdrawalConfig) + require.Equal(t, idaveappfactory.WithdrawalConfig(withdrawalConfig), *gotWithdrawal) + require.Equal(t, [32]byte(deployment.Salt), args[5]) + } + }) + } + } +} + +type prtFactoryCall struct { + To common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` +} + +type prtFactoryCallBackend struct { + calls chan prtFactoryCall + response hexutil.Bytes +} + +func (b *prtFactoryCallBackend) Call(_ context.Context, call prtFactoryCall, _ string) (hexutil.Bytes, error) { + b.calls <- call + return b.response, nil +} + +func (*prtFactoryCallBackend) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{}, nil +} + +func TestPRTDeploymentRejectsInvalidSentriesBeforeRPC(t *testing.T) { + address := common.HexToAddress("0x01") + for _, test := range []struct { + name string + sentries []common.Address + wantError string + }{ + {name: "zero", sentries: []common.Address{{}}, wantError: "must not be the zero address"}, + {name: "duplicate", sentries: []common.Address{address, address}, wantError: "duplicates address"}, + } { + t.Run(test.name, func(t *testing.T) { + deployment := &PRTApplicationDeployment{Sentries: test.sentries} + _, _, err := deployment.Deploy(t.Context(), nil, nil) + require.ErrorContains(t, err, test.wantError) + }) + } +} diff --git a/pkg/ethutil/quorum.go b/pkg/ethutil/quorum.go index 4eafca1bd..716356f22 100644 --- a/pkg/ethutil/quorum.go +++ b/pkg/ethutil/quorum.go @@ -11,6 +11,7 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) @@ -26,7 +27,7 @@ type QuorumDeployment struct { func (me *QuorumDeployment) String() string { result := "" - result += fmt.Sprintf("quorum deployment:\n") + result += "quorum deployment:\n" result += fmt.Sprintf("\tvalidators: %v\n", me.Validators) if me.Verbose { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) @@ -41,6 +42,17 @@ func (me *QuorumDeployment) Deploy( ctx context.Context, client *ethclient.Client, txOpts *bind.TransactOpts, +) (common.Address, error) { + return me.DeployWithTransaction(ctx, client, txOpts, nil) +} + +// DeployWithTransaction returns a predicted address when runner does not wait for +// a receipt. The prediction does not prove that the deployment transaction succeeded. +func (me *QuorumDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, + runner TransactionRunner, ) (common.Address, error) { zero := common.Address{} factory, err := iquorumfactory.NewIQuorumFactory(me.FactoryAddress, client) @@ -51,7 +63,7 @@ func (me *QuorumDeployment) Deploy( epochLength := new(big.Int).SetUint64(me.EpochLength) claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) quorumAddress, err := factory.CalculateQuorumAddress( - nil, + &bind.CallOpts{Context: ctx}, me.Validators, epochLength, claimStagingPeriod, @@ -66,21 +78,18 @@ func (me *QuorumDeployment) Deploy( return zero, err } if len(quorumCode) != 0 { - return zero, fmt.Errorf("quorum with address: %v already exists. Try a different salt.", quorumAddress) + return zero, fmt.Errorf("quorum with address %v already exists; use a different salt", quorumAddress) } - tx, err := factory.NewQuorum(txOpts, me.Validators, epochLength, claimStagingPeriod, me.Salt) + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewQuorum(opts, me.Validators, epochLength, claimStagingPeriod, me.Salt) + }, runner) if err != nil { return zero, fmt.Errorf("failed to create new quorum: %w", err) } - receipt, err := bind.WaitMined(ctx, client, tx) - if err != nil { - return zero, fmt.Errorf("failed to mine new quorum transaction: %w", err) - } - - if receipt.Status != 1 { - return zero, fmt.Errorf("transaction failed") + if receipt == nil { + return quorumAddress, nil } for _, vLog := range receipt.Logs { diff --git a/pkg/ethutil/return_data_test.go b/pkg/ethutil/return_data_test.go new file mode 100644 index 000000000..6951e98f5 --- /dev/null +++ b/pkg/ethutil/return_data_test.go @@ -0,0 +1,69 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "errors" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestApplicationReturnDataSuffix(t *testing.T) { + for _, contract := range []struct { + name string + metadata *bind.MetaData + }{ + {"IConsensus", iconsensus.IConsensusMetaData}, + {"IDaveConsensus", idaveconsensus.IDaveConsensusMetaData}, + } { + for _, name := range []string{"ApplicationReverted", "IllformedApplicationReturnData"} { + t.Run(contract.name+"/"+name, func(t *testing.T) { + parsed, err := contract.metadata.GetAbi() + require.NoError(t, err) + abiError, ok := parsed.Errors[name] + require.True(t, ok) + for _, data := range [][]byte{nil, {0, 0xff, '\n', '\r', 0x1b, '[', '2', 'J'}} { + packed, err := abiError.Inputs.Pack(common.HexToAddress("0x1234"), data) + require.NoError(t, err) + payload := append(append([]byte{}, abiError.ID[:revertSelectorLength]...), packed...) + rpcErr := &rpcDataError{code: 3, msg: executionRevertedMessage, data: payload} + for _, wrapped := range []error{rpcErr, fmt.Errorf("estimate: %w", rpcErr)} { + require.Equal(t, fmt.Sprintf(" Application return data: 0x%x.", data), + ApplicationReturnDataSuffix(wrapped, contract.metadata, name)) + } + } + truncated := &rpcDataError{data: abiError.ID[:revertSelectorLength]} + require.Empty(t, ApplicationReturnDataSuffix(truncated, contract.metadata, name)) + require.Empty(t, ApplicationReturnDataSuffix(nil, contract.metadata, name)) + require.Empty(t, ApplicationReturnDataSuffix(errors.New("unavailable"), contract.metadata, name)) + }) + } + } +} + +func TestApplicationReturnDataSuffixRejectsUnrelatedShapes(t *testing.T) { + // These errors do not have the (address, bytes) return-data shape. + for _, test := range []struct { + name string + args []any + }{ + {"AppReverted", []any{[]byte{1}}}, + {"InsufficientFunds", []any{common.Big1, common.Big2}}, + } { + t.Run(test.name, func(t *testing.T) { + err := describeTestRevert(t, test.name, test.args...) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, nil, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, &bind.MetaData{ABI: "invalid"}, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, "Unknown")) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, "Closed")) + }) + } +} diff --git a/pkg/ethutil/rpcerror.go b/pkg/ethutil/rpcerror.go index 074b6de6e..9b2bb305c 100644 --- a/pkg/ethutil/rpcerror.go +++ b/pkg/ethutil/rpcerror.go @@ -148,6 +148,22 @@ func UnpackRevert(err error, metadata *bind.MetaData, errorName string) ([]any, return values, true } +// ApplicationReturnDataSuffix formats the bytes carried by ApplicationReverted +// or IllformedApplicationReturnData(address, bytes). Contract-controlled bytes +// are hex-encoded so they stay inert in logs and stored reasons. Invalid revert +// data produces no suffix. The caller supplies its consensus ABI explicitly. +func ApplicationReturnDataSuffix(err error, metadata *bind.MetaData, name string) string { + values, ok := UnpackRevert(err, metadata, name) + if !ok || len(values) < 2 { + return "" + } + data, ok := values[1].([]byte) + if !ok { + return "" + } + return fmt.Sprintf(" Application return data: 0x%x.", data) +} + // DescribeRevert decodes the custom-error revert payload carried by err // against the given contract metadatas and renders it as // "Name(param=value, ...)". It returns ("", false) when err carries no revert diff --git a/pkg/ethutil/rpcerror_test.go b/pkg/ethutil/rpcerror_test.go index b3aadb512..2adb0e88a 100644 --- a/pkg/ethutil/rpcerror_test.go +++ b/pkg/ethutil/rpcerror_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/require" ) +const executionRevertedMessage = "execution reverted" + // rpcDataError simulates an RPC error with revert data, as returned by // eth_estimateGas when a contract reverts. type rpcDataError struct { @@ -41,7 +43,7 @@ func TestExtractJSONErrorInfo(t *testing.T) { }) t.Run("RPCDataError", func(t *testing.T) { - err := &rpcDataError{code: 3, msg: "execution reverted", data: "0xdeadbeef"} + err := &rpcDataError{code: 3, msg: executionRevertedMessage, data: "0xdeadbeef"} info, ok := ExtractJSONErrorInfo(err) assert.True(t, ok) assert.True(t, info.HasCode) @@ -134,7 +136,7 @@ func TestIsNonceTooLowError(t *testing.T) { want: true, }, {name: "UnrelatedError", err: errors.New("connection refused"), want: false}, - {name: "RevertedError", err: errors.New("execution reverted"), want: false}, + {name: "RevertedError", err: errors.New(executionRevertedMessage), want: false}, { name: "NonceTooHigh", err: errors.New("nonce too high"), @@ -168,7 +170,7 @@ func describeTestRevert(t *testing.T, name string, args ...any) error { packed, err := abiErr.Inputs.Pack(args...) require.NoError(t, err) payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) - return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", payload)} + return &rpcDataError{code: 3, msg: executionRevertedMessage, data: fmt.Sprintf("0x%x", payload)} } func TestDescribeRevert(t *testing.T) { @@ -208,14 +210,14 @@ func TestDescribeRevert(t *testing.T) { // Selector of a parameterized error with a truncated body. id := parsed.Errors["InsufficientFunds"].ID payload := append([]byte{}, id[:4]...) - e := &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", payload)} + e := &rpcDataError{code: 3, msg: executionRevertedMessage, data: fmt.Sprintf("0x%x", payload)} desc, ok := DescribeRevert(e, describeTestMetaData) assert.True(t, ok) assert.Equal(t, "InsufficientFunds", desc, "bare name when args do not decode") }) t.Run("UnknownSelector", func(t *testing.T) { - e := &rpcDataError{code: 3, msg: "execution reverted", data: "0xdeadbeef"} + e := &rpcDataError{code: 3, msg: executionRevertedMessage, data: "0xdeadbeef"} _, ok := DescribeRevert(e, describeTestMetaData) assert.False(t, ok) }) diff --git a/pkg/ethutil/selfhosted.go b/pkg/ethutil/selfhosted.go index 89817fd68..a95f4c9b4 100644 --- a/pkg/ethutil/selfhosted.go +++ b/pkg/ethutil/selfhosted.go @@ -4,7 +4,6 @@ package ethutil import ( "context" - "encoding/hex" "fmt" "math/big" @@ -18,15 +17,13 @@ import ( ) type SelfhostedApplicationDeployment struct { - FactoryAddress common.Address `json:"factory_address"` - ApplicationOwnerAddress common.Address `json:"application_owner"` - AuthorityOwnerAddress common.Address `json:"authority_owner"` - TemplateHash common.Hash `json:"template_hash"` - DataAvailability []byte `json:"-"` - EpochLength uint64 `json:"epoch_length"` - ClaimStagingPeriod uint64 `json:"claim_staging_period"` - WithdrawalConfig iapplicationfactory.WithdrawalConfig `json:"withdrawal_config"` - Salt SaltBytes `json:"salt"` + FactoryAddress common.Address `json:"factory_address"` + AuthorityOwnerAddress common.Address `json:"authority_owner"` + TemplateHash common.Hash `json:"template_hash"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + WithdrawalConfig iapplicationfactory.WithdrawalConfig `json:"withdrawal_config"` + Salt SaltBytes `json:"salt"` InputBoxAddress common.Address `json:"inputbox_address"` IInputBoxBlock uint64 `json:"inputbox_block"` @@ -46,13 +43,12 @@ type SelfhostedApplicationDeploymentResult struct { func (me *SelfhostedApplicationDeployment) String() string { result := "" - result += fmt.Sprintf("selfhosted deployment:\n") - result += fmt.Sprintf("\tapplication owner: %v\n", me.ApplicationOwnerAddress) + result += "selfhosted deployment:\n" result += fmt.Sprintf("\tauthority owner: %v\n", me.AuthorityOwnerAddress) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) if me.Verbose { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) - result += fmt.Sprintf("\tdata availability: 0x%v\n", hex.EncodeToString(me.DataAvailability)) result += fmt.Sprintf("\tsalt: %v\n", me.Salt) result += fmt.Sprintf("\tepoch length: %v\n", me.EpochLength) result += fmt.Sprintf("\tclaim staging period: %v\n", me.ClaimStagingPeriod) @@ -75,6 +71,18 @@ func (me *SelfhostedApplicationDeployment) Deploy( ctx context.Context, client *ethclient.Client, txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction returns the predicted application address and no confirmed +// deployment result when runner does not wait for a receipt. The prediction does +// not prove that the deployment transaction succeeded. +func (me *SelfhostedApplicationDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, + runner TransactionRunner, ) (common.Address, IApplicationDeploymentResult, error) { zero := common.Address{} result := &SelfhostedApplicationDeploymentResult{} @@ -97,13 +105,12 @@ func (me *SelfhostedApplicationDeployment) Deploy( // check if addresses are available (have no code) applicationAddress, authorityAddress, err := factory.CalculateAddresses( - nil, + &bind.CallOpts{Context: ctx}, me.AuthorityOwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), - me.ApplicationOwnerAddress, me.TemplateHash, - me.DataAvailability, + me.InputBoxAddress, shWC, me.Salt, ) @@ -116,7 +123,7 @@ func (me *SelfhostedApplicationDeployment) Deploy( return zero, nil, err } if len(applicationCode) != 0 { - return zero, nil, fmt.Errorf("application with address: %v already exists. Try a different salt.", applicationAddress) + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", applicationAddress) } authorityCode, err := client.CodeAt(ctx, authorityAddress, nil) @@ -124,18 +131,30 @@ func (me *SelfhostedApplicationDeployment) Deploy( return zero, nil, err } if len(authorityCode) != 0 { - return zero, nil, fmt.Errorf("authority with address: %v already exists. Try a different salt.", authorityAddress) + return zero, nil, fmt.Errorf("authority with address %v already exists; use a different salt", authorityAddress) + } + + var txOpts *bind.TransactOpts + if runner == nil { + // Preserve the existing helper's preparation for non-CLI callers. The + // CLI runner applies its common transaction policy instead. + txOpts, err = _prepareTransaction(ctx, client, txOptsFactory, big.NewInt(0)) + } else { + txOpts, err = txOptsFactory.NewTransactOpts(ctx) + } + if err != nil { + return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) } // deploy the contracts - receipt, err := sendTransaction( - ctx, client, txOptsFactory, big.NewInt(0), + receipt, err := runDeploymentTransaction( + ctx, client, txOpts, func(txOpts *bind.TransactOpts) (*types.Transaction, error) { - result.ApplicationFactoryAddress, err = factory.GetApplicationFactory(nil) + result.ApplicationFactoryAddress, err = factory.GetApplicationFactory(&bind.CallOpts{Context: ctx}) if err != nil { return nil, fmt.Errorf("failed to retrieve application factory address: %w", err) } - result.AuthorityFactoryAddress, err = factory.GetAuthorityFactory(nil) + result.AuthorityFactoryAddress, err = factory.GetAuthorityFactory(&bind.CallOpts{Context: ctx}) if err != nil { return nil, fmt.Errorf("failed to retrieve authority factory address: %w", err) } @@ -144,17 +163,20 @@ func (me *SelfhostedApplicationDeployment) Deploy( me.AuthorityOwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), - me.ApplicationOwnerAddress, me.TemplateHash, - me.DataAvailability, + me.InputBoxAddress, shWC, me.Salt, ) }, + runner, ) if err != nil { return zero, nil, fmt.Errorf("failed to create a self hosted application: %w", err) } + if receipt == nil { + return applicationAddress, nil, nil + } applicationFactory, err := iapplicationfactory.NewIApplicationFactory(result.ApplicationFactoryAddress, client) if err != nil { @@ -174,7 +196,7 @@ func (me *SelfhostedApplicationDeployment) Deploy( result.ApplicationAddress = event.AppContract goto applicationEventFound } - return zero, nil, fmt.Errorf("failed to obtain application address during self hosted application deployment. ApplicationCreated event not found in the receipt logs") + return zero, nil, fmt.Errorf("self-hosted deployment receipt has no ApplicationCreated event") applicationEventFound: for _, vLog := range receipt.Logs { @@ -185,7 +207,7 @@ applicationEventFound: result.AuthorityAddress = event.Authority goto authorityEventFound } - return zero, nil, fmt.Errorf("failed to obtain authority address during self hosted application deployment. AuthorityCreated event not found in the recipe logs") + return zero, nil, fmt.Errorf("self-hosted deployment receipt has no AuthorityCreated event") authorityEventFound: if err := VerifyDeployedWithdrawalConfig(ctx, client, result.ApplicationAddress, me.WithdrawalConfig); err != nil { diff --git a/pkg/ethutil/withdrawal_account.go b/pkg/ethutil/withdrawal_account.go index 9679a8856..68e9ad8ff 100644 --- a/pkg/ethutil/withdrawal_account.go +++ b/pkg/ethutil/withdrawal_account.go @@ -5,7 +5,6 @@ package ethutil import ( "context" - "encoding/binary" "fmt" "math/big" @@ -17,15 +16,12 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/iusdwithdrawaloutputbuilder" ) -// usdAccountMinSize is the minimum byte length of the LibUsdAccount encoding consumed +// usdAccountSize is the exact byte length of the LibUsdAccount encoding consumed // by every UsdWithdrawalOutputBuilder: // -// bytes 0..7 uint64 balance, little-endian -// bytes 8..27 20-byte user address -// -// The account may be larger. LibUsdAccount ignores bytes after byte 27, which -// lets ewtools-style 32-byte account-drive records be withdrawn directly. -const usdAccountMinSize = 28 +// bytes 0..11 uint96 balance, little-endian +// bytes 12..31 20-byte user address +const usdAccountSize = 32 // DescribeWithdrawalAccount renders a multi-line human description of the // `account` bytes consumed by an IApplication.withdraw() call so the @@ -36,14 +32,12 @@ const usdAccountMinSize = 28 // 1. Call IUsdWithdrawalOutputBuilder.Token() on the on-chain builder. // A revert here means the builder is not a USD-family builder; the // caller should fall back to a raw-bytes display. -// 2. Split the first 28 bytes into recipient and balance per LibUsdAccount. -// A shorter account is a hard error — a malformed proof against a -// recognized builder, not a fallback signal. Longer account records are -// accepted because the contract ignores trailing bytes. +// 2. Require and split the exact 32-byte record into recipient and balance +// per LibUsdAccount. Any other length is a hard error. // 3. Best-effort fetch IERC20Metadata.Symbol() and Decimals() on the // returned token address so the balance can be rendered as a // fixed-point amount. If either view reverts (broken or non-standard -// ERC-20), the raw uint64 balance is shown unmodified. +// ERC-20), the raw uint96 balance is shown unmodified. // // Tri-state return: // @@ -66,12 +60,10 @@ func DescribeWithdrawalAccount( if err != nil { return "", false, nil } - if len(account) < usdAccountMinSize { - return "", true, fmt.Errorf( - "USD account must be at least %d bytes, got %d (token %s)", - usdAccountMinSize, len(account), token) + recipient, balance, err := decodeUSDAccount(account) + if err != nil { + return "", true, fmt.Errorf("%w (token %s)", err, token) } - recipient, balance := decodeUSDAccount(account) symbol, decimals, metaOK := fetchERC20Metadata(ctx, client, token) tokenLine := fmt.Sprintf(" token: %s", token) @@ -81,12 +73,12 @@ func DescribeWithdrawalAccount( var amountLine string if metaOK { amountLine = fmt.Sprintf( - " amount: %s %s (raw: %d, decimals: %d)", - formatTokenAmount(balance, decimals), symbol, balance, decimals) + " amount: %s %s (raw: %s, decimals: %d)", + formatTokenAmount(balance, decimals), symbol, balance.String(), decimals) } else { amountLine = fmt.Sprintf( - " amount (raw uint64): %d (token metadata unavailable)", - balance) + " amount (raw uint96): %s (token metadata unavailable)", + balance.String()) } return fmt.Sprintf( "USD-style account (recognized via IUsdWithdrawalOutputBuilder.Token)\n"+ @@ -95,11 +87,23 @@ func DescribeWithdrawalAccount( ), true, nil } -func decodeUSDAccount(account []byte) (common.Address, uint64) { - balance := binary.LittleEndian.Uint64(account[:8]) +func decodeUSDAccount(account []byte) (common.Address, *big.Int, error) { + if len(account) != usdAccountSize { + return common.Address{}, nil, fmt.Errorf( + "USD account must be exactly %d bytes, got %d", + usdAccountSize, + len(account), + ) + } + + var balanceBytes [12]byte + for i := range balanceBytes { + balanceBytes[len(balanceBytes)-1-i] = account[i] + } + balance := new(big.Int).SetBytes(balanceBytes[:]) var recipient common.Address - copy(recipient[:], account[8:usdAccountMinSize]) - return recipient, balance + copy(recipient[:], account[12:usdAccountSize]) + return recipient, balance, nil } // fetchERC20Metadata best-effort-fetches the symbol and decimals of an @@ -130,12 +134,12 @@ func fetchERC20Metadata( // fixed-point string (e.g. balance=1_500_000, decimals=6 → "1.5"). Trailing // zeros in the fractional part are trimmed so common round amounts render // compactly. -func formatTokenAmount(raw uint64, decimals uint8) string { +func formatTokenAmount(raw *big.Int, decimals uint8) string { if decimals == 0 { - return fmt.Sprintf("%d", raw) + return raw.String() } denom := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) - whole, frac := new(big.Int).QuoRem(new(big.Int).SetUint64(raw), denom, new(big.Int)) + whole, frac := new(big.Int).QuoRem(new(big.Int).Set(raw), denom, new(big.Int)) if frac.Sign() == 0 { return whole.String() } diff --git a/pkg/ethutil/withdrawal_account_test.go b/pkg/ethutil/withdrawal_account_test.go index 274b04e09..4d4ec5474 100644 --- a/pkg/ethutil/withdrawal_account_test.go +++ b/pkg/ethutil/withdrawal_account_test.go @@ -4,7 +4,8 @@ package ethutil import ( - "encoding/binary" + "bytes" + "math/big" "testing" "github.com/ethereum/go-ethereum/common" @@ -13,38 +14,47 @@ import ( func TestFormatTokenAmount(t *testing.T) { cases := []struct { - raw uint64 + raw string decimals uint8 want string }{ - {0, 0, "0"}, - {42, 0, "42"}, - {1_500_000, 6, "1.5"}, - {1_234_567, 6, "1.234567"}, - {1_000_000, 6, "1"}, - {1, 6, "0.000001"}, - {1_000_000_000_000_000_000, 18, "1"}, - {1_500_000_000_000_000_000, 18, "1.5"}, - {999_999_999, 8, "9.99999999"}, - {1, 18, "0.000000000000000001"}, + {"0", 0, "0"}, + {"42", 0, "42"}, + {"1500000", 6, "1.5"}, + {"1234567", 6, "1.234567"}, + {"1000000", 6, "1"}, + {"1", 6, "0.000001"}, + {"1000000000000000000", 18, "1"}, + {"1500000000000000000", 18, "1.5"}, + {"999999999", 8, "9.99999999"}, + {"1", 18, "0.000000000000000001"}, + {"79228162514264337593543950335", 6, "79228162514264337593543.950335"}, } for _, c := range cases { - got := formatTokenAmount(c.raw, c.decimals) - require.Equalf(t, c.want, got, "formatTokenAmount(%d, %d)", c.raw, c.decimals) + raw, ok := new(big.Int).SetString(c.raw, 10) + require.True(t, ok) + before := new(big.Int).Set(raw) + got := formatTokenAmount(raw, c.decimals) + require.Equalf(t, c.want, got, "formatTokenAmount(%s, %d)", c.raw, c.decimals) + require.Zero(t, before.Cmp(raw), "formatTokenAmount mutated its input") } } -func TestDecodeUSDAccount_AcceptsMinimumAndPaddedAccount(t *testing.T) { +func TestDecodeUSDAccountExactUint96Record(t *testing.T) { recipient := common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") - account := make([]byte, 32) - binary.LittleEndian.PutUint64(account[:8], 75) - copy(account[8:28], recipient.Bytes()) + account := common.FromHex("0x0102030405060708090a0b0cbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + before := bytes.Clone(account) - gotRecipient, gotBalance := decodeUSDAccount(account[:28]) + gotRecipient, gotBalance, err := decodeUSDAccount(account) + require.NoError(t, err) require.Equal(t, recipient, gotRecipient) - require.Equal(t, uint64(75), gotBalance) + require.Equal(t, "3727165692135864801209549313", gotBalance.String()) + require.Equal(t, before, account, "decodeUSDAccount mutated its source bytes") +} - gotRecipient, gotBalance = decodeUSDAccount(account) - require.Equal(t, recipient, gotRecipient) - require.Equal(t, uint64(75), gotBalance) +func TestDecodeUSDAccountRequiresExactSize(t *testing.T) { + for _, size := range []int{0, usdAccountSize - 1, usdAccountSize + 1} { + _, _, err := decodeUSDAccount(make([]byte, size)) + require.ErrorContains(t, err, "exactly 32 bytes") + } } From 03d401c44df9befd67a155b6f292262867e1e57a Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:15 -0300 Subject: [PATCH 07/41] refactor(model): share enum scan validation Use one checked conversion for PostgreSQL enum scans. Preserve rejection of unsupported input types and unknown values. Convert the existing enum Scan methods before adding the new PRT enum types. Tests cover accepted strings and byte slices, invalid values, and the requirement to leave the destination unchanged after failure. --- internal/model/enum.go | 38 +++++++ internal/model/enum_test.go | 79 +++++++++++++ internal/model/models.go | 218 ++---------------------------------- 3 files changed, 125 insertions(+), 210 deletions(-) create mode 100644 internal/model/enum.go create mode 100644 internal/model/enum_test.go diff --git a/internal/model/enum.go b/internal/model/enum.go new file mode 100644 index 000000000..1c70e145d --- /dev/null +++ b/internal/model/enum.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "fmt" + "slices" +) + +// scanEnum accepts the database representations supported by every string enum. +// An invalid value leaves the receiver unchanged. +func scanEnum[T ~string](dst *T, value any, values []T, name string) error { + // Keep the existing public error text, including these two naming exceptions. + prefix := "invalid value" + if name == "SnapshotPolicy" { + prefix = "invalid scan value" + } + typeName := name + if name == "Consensus" { + typeName = "ConsensusType" + } + var text string + switch value := value.(type) { + case string: + text = value + case []byte: + text = string(value) + default: + return fmt.Errorf("%s for %s enum. Enum value has to be of type string or []byte", prefix, typeName) + } + parsed := T(text) + if !slices.Contains(values, parsed) { + return fmt.Errorf("%s '%s' for %s enum", prefix, text, name) + } + *dst = parsed + return nil +} diff --git a/internal/model/enum_test.go b/internal/model/enum_test.go new file mode 100644 index 000000000..d6c2a16f8 --- /dev/null +++ b/internal/model/enum_test.go @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + enumGoldenNone = "NONE" + enumInvalidValue = "UNKNOWN" +) + +func TestEnumScanners(t *testing.T) { + checkEnumScanner(t, "ApplicationStatus", ApplicationStatusAllValues, (*ApplicationStatus).Scan, + "invalid value", "ApplicationStatus", + []string{"OK", "FAILED", "DIVERGED", "CORRUPTED", "GUEST_EXCEPTION", "MACHINE_HALTED", "MCYCLE_OVERFLOW", "UNEXPECTED_YIELD"}) + checkEnumScanner(t, "Consensus", ConsensusAllValues, (*Consensus).Scan, + "invalid value", "ConsensusType", []string{"AUTHORITY", "QUORUM", "PRT"}) + checkEnumScanner(t, "SnapshotPolicy", SnapshotPolicyAllValues, (*SnapshotPolicy).Scan, + "invalid scan value", "SnapshotPolicy", []string{enumGoldenNone, "EVERY_INPUT", "EVERY_EPOCH"}) + checkEnumScanner(t, "EpochStatus", EpochStatusAllValues, (*EpochStatus).Scan, + "invalid value", "EpochStatus", []string{ + "OPEN", "CLOSED", "INPUTS_PROCESSED", "CLAIM_COMPUTED", "CLAIM_SUBMITTED", "CLAIM_STAGED", + "CLAIM_ACCEPTED", "CLAIM_REJECTED", "CLAIM_FORECLOSED", + }) + checkEnumScanner(t, "InputCompletionStatus", InputCompletionStatusAllValues, (*InputCompletionStatus).Scan, + "invalid value", "InputCompletionStatus", + []string{enumGoldenNone, "ACCEPTED", "REJECTED", "EXCEPTION", "MACHINE_HALTED", "OVERFLOW", "UNEXPECTED_YIELD"}) + checkEnumScanner(t, "DefaultBlock", DefaultBlockAllValues, (*DefaultBlock).Scan, + "invalid value", "DefaultBlock", []string{"FINALIZED", "LATEST", "PENDING", "SAFE"}) + checkEnumScanner(t, "MatchDeletionReason", MatchDeletionReasonAllValues, (*MatchDeletionReason).Scan, + "invalid value", "MatchDeletionReason", []string{"STEP", "TIMEOUT", "CHILD_TOURNAMENT", "NOT_DELETED"}) + checkEnumScanner(t, "WinnerCommitment", WinnerCommitmentAllValues, (*WinnerCommitment).Scan, + "invalid value", "WinnerCommitment", []string{enumGoldenNone, "ONE", "TWO"}) +} + +func checkEnumScanner[T ~string]( + t *testing.T, name string, values []T, scan func(*T, any) error, errorPrefix, typeName string, expected []string, +) { + t.Helper() + t.Run(name, func(t *testing.T) { + actual := make([]string, len(values)) + for i, value := range values { + actual[i] = string(value) + } + require.Equal(t, expected, actual, "AllValues must contain the full public enum vocabulary") + for _, text := range expected { + value := T(text) + t.Run(string(value), func(t *testing.T) { + for _, input := range []any{string(value), []byte(value)} { + var got T + require.NoError(t, scan(&got, input)) + require.Equal(t, value, got) + } + }) + } + for _, input := range []any{"INVALID", []byte("INVALID"), "", []byte(nil)} { + got := values[0] + text, ok := input.(string) + if !ok { + text = string(input.([]byte)) + } + require.EqualError(t, scan(&got, input), fmt.Sprintf("%s '%s' for %s enum", errorPrefix, text, name)) + require.Equal(t, values[0], got, "invalid enum values must not modify the receiver") + } + type namedString string + for _, input := range []any{nil, 1, true, namedString(values[0])} { + got := values[0] + require.EqualError(t, scan(&got, input), + fmt.Sprintf("%s for %s enum. Enum value has to be of type string or []byte", errorPrefix, typeName)) + require.Equal(t, values[0], got, "unsupported input types must not modify the receiver") + } + }) +} diff --git a/internal/model/models.go b/internal/model/models.go index 3aeee802e..64982649b 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -407,38 +407,7 @@ func (e ApplicationStatus) IsExecutionTerminal() bool { } func (e *ApplicationStatus) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for ApplicationStatus enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "OK": - *e = ApplicationStatus_OK - case "FAILED": - *e = ApplicationStatus_Failed - case "DIVERGED": - *e = ApplicationStatus_Diverged - case "CORRUPTED": - *e = ApplicationStatus_Corrupted - case "GUEST_EXCEPTION": - *e = ApplicationStatus_GuestException - case "MACHINE_HALTED": - *e = ApplicationStatus_MachineHalted - case "MCYCLE_OVERFLOW": - *e = ApplicationStatus_McycleOverflow - case "UNEXPECTED_YIELD": - *e = ApplicationStatus_UnexpectedYield - default: - return errors.New("invalid value '" + enumValue + "' for ApplicationStatus enum") - } - - return nil + return scanEnum(e, value, ApplicationStatusAllValues, "ApplicationStatus") } func (e ApplicationStatus) String() string { @@ -460,28 +429,7 @@ var ConsensusAllValues = []Consensus{ } func (e *Consensus) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for ConsensusType enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "AUTHORITY": - *e = Consensus_Authority - case "QUORUM": - *e = Consensus_Quorum - case "PRT": - *e = Consensus_PRT - default: - return errors.New("invalid value '" + enumValue + "' for Consensus enum") - } - - return nil + return scanEnum(e, value, ConsensusAllValues, "Consensus") } func (e Consensus) String() string { @@ -547,28 +495,7 @@ var SnapshotPolicyAllValues = []SnapshotPolicy{ } func (e *SnapshotPolicy) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid scan value for SnapshotPolicy enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "NONE": - *e = SnapshotPolicy_None - case "EVERY_INPUT": - *e = SnapshotPolicy_EveryInput - case "EVERY_EPOCH": - *e = SnapshotPolicy_EveryEpoch - default: - return errors.New("invalid scan value '" + enumValue + "' for SnapshotPolicy enum") - } - - return nil + return scanEnum(e, value, SnapshotPolicyAllValues, "SnapshotPolicy") } func (e SnapshotPolicy) String() string { @@ -997,40 +924,7 @@ var EpochStatusAllValues = []EpochStatus{ } func (e *EpochStatus) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for EpochStatus enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "OPEN": - *e = EpochStatus_Open - case "CLOSED": - *e = EpochStatus_Closed - case "INPUTS_PROCESSED": - *e = EpochStatus_InputsProcessed - case "CLAIM_COMPUTED": - *e = EpochStatus_ClaimComputed - case "CLAIM_SUBMITTED": - *e = EpochStatus_ClaimSubmitted - case "CLAIM_STAGED": - *e = EpochStatus_ClaimStaged - case "CLAIM_ACCEPTED": - *e = EpochStatus_ClaimAccepted - case "CLAIM_REJECTED": - *e = EpochStatus_ClaimRejected - case "CLAIM_FORECLOSED": - *e = EpochStatus_ClaimForeclosed - default: - return errors.New("invalid value '" + enumValue + "' for EpochStatus enum") - } - - return nil + return scanEnum(e, value, EpochStatusAllValues, "EpochStatus") } func (e EpochStatus) String() string { @@ -1207,36 +1101,7 @@ func (e InputCompletionStatus) TerminalApplicationStatus() (ApplicationStatus, b } func (e *InputCompletionStatus) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for InputCompletionStatus enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "NONE": - *e = InputCompletionStatus_None - case "ACCEPTED": - *e = InputCompletionStatus_Accepted - case "REJECTED": - *e = InputCompletionStatus_Rejected - case "EXCEPTION": - *e = InputCompletionStatus_Exception - case "MACHINE_HALTED": - *e = InputCompletionStatus_MachineHalted - case "OVERFLOW": - *e = InputCompletionStatus_Overflow - case "UNEXPECTED_YIELD": - *e = InputCompletionStatus_UnexpectedYield - default: - return errors.New("invalid value '" + enumValue + "' for InputCompletionStatus enum") - } - - return nil + return scanEnum(e, value, InputCompletionStatusAllValues, "InputCompletionStatus") } func (e InputCompletionStatus) String() string { @@ -1580,30 +1445,7 @@ var DefaultBlockAllValues = []DefaultBlock{ } func (e *DefaultBlock) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for DefaultBlock enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "FINALIZED": - *e = DefaultBlock_Finalized - case "LATEST": - *e = DefaultBlock_Latest - case "PENDING": - *e = DefaultBlock_Pending - case "SAFE": - *e = DefaultBlock_Safe - default: - return errors.New("invalid value '" + enumValue + "' for DefaultBlock enum") - } - - return nil + return scanEnum(e, value, DefaultBlockAllValues, "DefaultBlock") } func (e DefaultBlock) String() string { @@ -1855,30 +1697,7 @@ var MatchDeletionReasonAllValues = []MatchDeletionReason{ } func (e *MatchDeletionReason) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for MatchDeletionReason enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "STEP": - *e = MatchDeletionReason_STEP - case "TIMEOUT": - *e = MatchDeletionReason_TIMEOUT - case "CHILD_TOURNAMENT": - *e = MatchDeletionReason_CHILD_TOURNAMENT - case "NOT_DELETED": - *e = MatchDeletionReason_NOT_DELETED - default: - return errors.New("invalid value '" + enumValue + "' for MatchDeletionReason enum") - } - - return nil + return scanEnum(e, value, MatchDeletionReasonAllValues, "MatchDeletionReason") } func (e MatchDeletionReason) String() string { @@ -1916,28 +1735,7 @@ var WinnerCommitmentAllValues = []WinnerCommitment{ } func (e *WinnerCommitment) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for WinnerCommitment enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "NONE": - *e = WinnerCommitment_NONE - case "ONE": - *e = WinnerCommitment_ONE - case "TWO": - *e = WinnerCommitment_TWO - default: - return errors.New("invalid value '" + enumValue + "' for WinnerCommitment enum") - } - - return nil + return scanEnum(e, value, WinnerCommitmentAllValues, "WinnerCommitment") } func (e WinnerCommitment) String() string { From c44f8d392d68cff4ee70d872f6df316170a63478 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:15 -0300 Subject: [PATCH 08/41] feat(model)!: define v3 contract state Remove the obsolete application data-availability selector. Define the final tournament, commitment, match, clock, and bond observation types, including their block anchors and exact uint256 values. Expose owned copies of persisted machine proofs and one shared set of non-terminal epoch statuses. Add JSON, enum, numeric-boundary, proof ownership, and status-set tests, plus the shared proof test builder. BREAKING CHANGE: application and dispute model fields now describe the new v3 contracts; the legacy data-availability field is removed. --- go.mod | 2 +- internal/model/epoch_status_test.go | 26 + internal/model/models.go | 481 +++++++++--------- internal/model/models_json_test.go | 92 +++- internal/model/prt_enums.go | 188 +++++++ internal/model/prt_enums_test.go | 201 ++++++++ internal/model/prt_observer.go | 361 +++++++++++++ internal/model/uint256.go | 89 ++++ internal/model/uint256_test.go | 108 ++++ internal/repository/repotest/state_proof.go | 88 ++++ .../repository/repotest/state_proof_test.go | 46 ++ 11 files changed, 1443 insertions(+), 239 deletions(-) create mode 100644 internal/model/epoch_status_test.go create mode 100644 internal/model/prt_enums.go create mode 100644 internal/model/prt_enums_test.go create mode 100644 internal/model/prt_observer.go create mode 100644 internal/model/uint256.go create mode 100644 internal/model/uint256_test.go create mode 100644 internal/repository/repotest/state_proof.go create mode 100644 internal/repository/repotest/state_proof_test.go diff --git a/go.mod b/go.mod index 89997af8b..ee3f893cf 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/holiman/uint256 v1.3.2 github.com/jackc/pgx/v5 v5.10.0 github.com/lmittmann/tint v1.1.3 github.com/oapi-codegen/runtime v1.4.1 @@ -69,7 +70,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/holiman/uint256 v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect diff --git a/internal/model/epoch_status_test.go b/internal/model/epoch_status_test.go new file mode 100644 index 000000000..7dd3c3a22 --- /dev/null +++ b/internal/model/epoch_status_test.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNonTerminalEpochStatusesPartitionAllValues(t *testing.T) { + partition := NonTerminalEpochStatuses() + terminal := []EpochStatus{EpochStatus_ClaimAccepted, EpochStatus_ClaimRejected, EpochStatus_ClaimForeclosed} + partition = append(partition, terminal...) + require.ElementsMatch(t, EpochStatusAllValues, partition, + "classify every epoch status exactly once and keep the epoch_unreconciled_idx predicate in sync") +} + +func TestNonTerminalEpochStatusesReturnsOwnedSlice(t *testing.T) { + first, second := NonTerminalEpochStatuses(), NonTerminalEpochStatuses() + require.NotEmpty(t, first) + first[0] = EpochStatus_ClaimAccepted + require.NotContains(t, second, EpochStatus_ClaimAccepted) + require.Equal(t, second, NonTerminalEpochStatuses()) +} diff --git a/internal/model/models.go b/internal/model/models.go index 64982649b..9c3131b4f 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -28,7 +28,6 @@ type Application struct { EpochLength uint64 `json:"epoch_length"` ClaimStagingPeriod uint64 `json:"claim_staging_period"` WithdrawalConfig WithdrawalConfig `json:"withdrawal_config"` - DataAvailability []byte `json:"data_availability"` ConsensusType Consensus `json:"consensus_type"` Enabled bool `json:"enabled"` Status ApplicationStatus `json:"status"` @@ -158,19 +157,12 @@ func (w *WithdrawalConfig) UnmarshalJSON(data []byte) error { return nil } -// HasDataAvailabilitySelector checks if the application's DataAvailability -// starts with the given DataAvailabilitySelector -func (a *Application) HasDataAvailabilitySelector(selector DataAvailabilitySelector) bool { - return selector.MatchesBytes(a.DataAvailability) -} - func (a *Application) MarshalJSON() ([]byte, error) { // Create an alias to avoid infinite recursion in MarshalJSON. type Alias Application // Define a new structure that embeds the alias but overrides the hex fields. aux := &struct { *Alias - DataAvailability string `json:"data_availability"` IInputBoxBlock string `json:"iinputbox_block"` LastEpochCheckBlock string `json:"last_epoch_check_block"` LastInputCheckBlock string `json:"last_input_check_block"` @@ -186,7 +178,6 @@ func (a *Application) MarshalJSON() ([]byte, error) { AccountsDriveProvedBlock string `json:"accounts_drive_proved_block"` }{ Alias: (*Alias)(a), - DataAvailability: "0x" + hex.EncodeToString(a.DataAvailability), IInputBoxBlock: fmt.Sprintf("0x%x", a.IInputBoxBlock), LastEpochCheckBlock: fmt.Sprintf("0x%x", a.LastEpochCheckBlock), LastInputCheckBlock: fmt.Sprintf("0x%x", a.LastInputCheckBlock), @@ -209,7 +200,6 @@ func (a *Application) UnmarshalJSON(in []byte) error { aux := &struct { *Alias - DataAvailability string `json:"data_availability"` IInputBoxBlock string `json:"iinputbox_block"` LastInputCheckBlock string `json:"last_input_check_block"` LastOutputCheckBlock string `json:"last_output_check_block"` @@ -233,12 +223,6 @@ func (a *Application) UnmarshalJSON(in []byte) error { *a = Application(*aux.Alias) - // manually decode the following values as hex instead of the default (base64) - a.DataAvailability, err = hexutil.Decode(aux.DataAvailability) - if err != nil { - return err - } - a.IInputBoxBlock, err = ParseHexUint64(aux.IInputBoxBlock) if err != nil { return err @@ -436,50 +420,6 @@ func (e Consensus) String() string { return string(e) } -const DATA_AVAILABILITY_SELECTOR_SIZE = 4 - -type DataAvailabilitySelector [DATA_AVAILABILITY_SELECTOR_SIZE]byte - -// Known data availability selectors -var ( - // ABI encoded "InputBox(address)" - DataAvailability_InputBox = DataAvailabilitySelector{0xb1, 0x2c, 0x9e, 0xde} -) - -func (d *DataAvailabilitySelector) MarshalJSON() ([]byte, error) { - return json.Marshal("0x" + hex.EncodeToString(d[:])) -} - -// MatchesBytes checks if this selector matches the first bytes of the given byte slice -func (d DataAvailabilitySelector) MatchesBytes(data []byte) bool { - if len(data) < DATA_AVAILABILITY_SELECTOR_SIZE { - return false - } - for i := range DATA_AVAILABILITY_SELECTOR_SIZE { - if data[i] != d[i] { - return false - } - } - return true -} - -func (d *DataAvailabilitySelector) Scan(value any) error { - var selector []byte - switch v := value.(type) { - case []byte: - selector = v - default: - return errors.New("invalid scan value for DataAvailabilitySelector. Value has to be of type []byte") - } - - if len(selector) != DATA_AVAILABILITY_SELECTOR_SIZE { - return errors.New("invalid value for DataAvailabilitySelector") - } - copy(d[:], selector[:DATA_AVAILABILITY_SELECTOR_SIZE]) - - return nil -} - type SnapshotPolicy string const ( @@ -923,6 +863,21 @@ var EpochStatusAllValues = []EpochStatus{ EpochStatus_ClaimForeclosed, } +// NonTerminalEpochStatuses returns the states that still require epoch or claim +// work. Each caller owns the returned slice. +// Keep this set equal to the epoch_unreconciled_idx predicate in the initial +// PostgreSQL migration (000001_create_initial_schema.up.sql). +func NonTerminalEpochStatuses() []EpochStatus { + return []EpochStatus{ + EpochStatus_Open, + EpochStatus_Closed, + EpochStatus_InputsProcessed, + EpochStatus_ClaimComputed, + EpochStatus_ClaimSubmitted, + EpochStatus_ClaimStaged, + } +} + func (e *EpochStatus) Scan(value any) error { return scanEnum(e, value, EpochStatusAllValues, "EpochStatus") } @@ -1121,7 +1076,7 @@ type Output struct { UpdatedAt time.Time `json:"updated_at"` } -func (i *Output) MarshalJSON() ([]byte, error) { +func (o *Output) MarshalJSON() ([]byte, error) { // Create an alias to avoid infinite recursion in MarshalJSON. type Alias Output // Define a new structure that embeds the alias but overrides the hex fields. @@ -1132,11 +1087,11 @@ func (i *Output) MarshalJSON() ([]byte, error) { RawData string `json:"raw_data"` *Alias }{ - EpochIndex: fmt.Sprintf("0x%x", i.EpochIndex), - InputIndex: fmt.Sprintf("0x%x", i.InputIndex), - Index: fmt.Sprintf("0x%x", i.Index), - RawData: "0x" + hex.EncodeToString(i.RawData), - Alias: (*Alias)(i), + EpochIndex: fmt.Sprintf("0x%x", o.EpochIndex), + InputIndex: fmt.Sprintf("0x%x", o.InputIndex), + Index: fmt.Sprintf("0x%x", o.Index), + RawData: "0x" + hex.EncodeToString(o.RawData), + Alias: (*Alias)(o), } return json.Marshal(aux) } @@ -1342,12 +1297,17 @@ type StateProof struct { HtifTohostProof [][32]byte } +// ErrIncompleteStateProof means an epoch does not contain every persisted +// component required to reconstruct its machine state proof. +var ErrIncompleteStateProof = errors.New("epoch state proof is incomplete") + // StateProofSiblingCount is the height of the canonical machine // memory tree above a 32-byte data block (64 - 5). const StateProofSiblingCount = 59 // IsComplete reports whether all three state leaves have the canonical sibling -// depth. Leaf contents and roots are verified by pkg/machine. +// depth. The machine validates proof contents when it collects them. The +// contracts validate the persisted proof when the node submits it. func (p *StateProof) IsComplete() bool { return p != nil && len(p.TxBufferProof) == StateProofSiblingCount && @@ -1368,6 +1328,32 @@ func (e *Epoch) HasCompleteStateProof() bool { len(e.HtifTohostProof) == StateProofSiblingCount } +// StateProof reconstructs the persisted machine state proof owned by an +// epoch. The returned value owns its sibling slices. +func (e *Epoch) StateProof() (StateProof, error) { + if !e.HasCompleteStateProof() { + return StateProof{}, ErrIncompleteStateProof + } + + return StateProof{ + TxBufferDataBlock: *e.TxBufferDataBlock, + TxBufferProof: copyStateProofSiblings(e.TxBufferProof), + MachineHash: *e.MachineHash, + IflagsYDataBlock: *e.IflagsYDataBlock, + IflagsYProof: copyStateProofSiblings(e.IflagsYProof), + HtifTohostDataBlock: *e.HtifTohostDataBlock, + HtifTohostProof: copyStateProofSiblings(e.HtifTohostProof), + }, nil +} + +func copyStateProofSiblings(siblings []common.Hash) [][32]byte { + result := make([][32]byte, len(siblings)) + for i := range siblings { + result[i] = siblings[i] + } + return result +} + type AdvanceResult struct { StateProof EpochIndex uint64 @@ -1468,6 +1454,9 @@ const ( MonitoredEvent_MatchCreated MonitoredEvent = "MatchCreated" MonitoredEvent_MatchDeleted MonitoredEvent = "MatchDeleted" MonitoredEvent_NewInnerTournament MonitoredEvent = "NewInnerTournament" + MonitoredEvent_LeafMatchSealed MonitoredEvent = "LeafMatchSealed" + MonitoredEvent_PartialBondRefund MonitoredEvent = "PartialBondRefund" + MonitoredEvent_BondRecovered MonitoredEvent = "BondRecovered" ) func (e MonitoredEvent) String() string { @@ -1475,142 +1464,122 @@ func (e MonitoredEvent) String() string { } type Tournament struct { - ApplicationID int64 `sql:"primary_key" json:"-"` - EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` - Address common.Address `sql:"primary_key" json:"address"` - ParentTournamentAddress *common.Address `json:"parent_tournament_address"` - ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` - MaxLevel uint64 `json:"max_level"` - Level uint64 `json:"level"` - Log2Step uint64 `json:"log2step"` - Height uint64 `json:"height"` - WinnerCommitment *common.Hash `json:"winner_commitment"` - FinalStateHash *common.Hash `json:"final_state_hash"` - FinishedAtBlock uint64 `json:"finished_at_block"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (t *Tournament) MarshalJSON() ([]byte, error) { - // Create an alias to avoid infinite recursion in MarshalJSON. + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` + Address common.Address `sql:"primary_key" json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel uint64 `json:"max_level"` + Level uint64 `json:"level"` + Log2Step uint64 `json:"log2step"` + Height uint64 `json:"height"` + InitialHash common.Hash `json:"initial_hash"` + BaseCycle Uint256 `json:"base_cycle"` + Kind TournamentKind `json:"kind"` + StartInstant uint64 `json:"start_instant"` + Allowance uint64 `json:"allowance"` + CreationEvent *TournamentCreationEvent `json:"creation_event"` + Snapshot TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value Tournament) MarshalJSON() ([]byte, error) { type Alias Tournament - // Define a new structure that embeds the alias but overrides the hex fields. - aux := &struct { - EpochIndex string `json:"epoch_index"` - MaxLevel string `json:"max_level"` - Level string `json:"level"` - Log2Step string `json:"log2step"` - Height string `json:"height"` - FinishedAtBlock string `json:"finished_at_block"` + return json.Marshal(struct { *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + MaxLevel hexutil.Uint64 `json:"max_level"` + Level hexutil.Uint64 `json:"level"` + Log2Step hexutil.Uint64 `json:"log2step"` + Height hexutil.Uint64 `json:"height"` + StartInstant hexutil.Uint64 `json:"start_instant"` + Allowance hexutil.Uint64 `json:"allowance"` }{ - Alias: (*Alias)(t), - EpochIndex: fmt.Sprintf("0x%x", t.EpochIndex), - MaxLevel: fmt.Sprintf("0x%x", t.MaxLevel), - Level: fmt.Sprintf("0x%x", t.Level), - Log2Step: fmt.Sprintf("0x%x", t.Log2Step), - Height: fmt.Sprintf("0x%x", t.Height), - FinishedAtBlock: fmt.Sprintf("0x%x", t.FinishedAtBlock), - } - return json.Marshal(aux) + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + MaxLevel: hexutil.Uint64(value.MaxLevel), + Level: hexutil.Uint64(value.Level), + Log2Step: hexutil.Uint64(value.Log2Step), + Height: hexutil.Uint64(value.Height), + StartInstant: hexutil.Uint64(value.StartInstant), + Allowance: hexutil.Uint64(value.Allowance), + }) } -func (t *Tournament) UnmarshalJSON(data []byte) error { +func (value *Tournament) UnmarshalJSON(data []byte) error { type Alias Tournament - aux := &struct { - EpochIndex string `json:"epoch_index"` - MaxLevel string `json:"max_level"` - Level string `json:"level"` - Log2Step string `json:"log2step"` - Height string `json:"height"` - FinishedAtBlock string `json:"finished_at_block"` + var decoded Tournament + aux := struct { *Alias - }{Alias: (*Alias)(t)} - - if err := json.Unmarshal(data, aux); err != nil { + EpochIndex hexutil.Uint64 `json:"epoch_index"` + MaxLevel hexutil.Uint64 `json:"max_level"` + Level hexutil.Uint64 `json:"level"` + Log2Step hexutil.Uint64 `json:"log2step"` + Height hexutil.Uint64 `json:"height"` + StartInstant hexutil.Uint64 `json:"start_instant"` + Allowance hexutil.Uint64 `json:"allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { return err } - *t = Tournament(*aux.Alias) - - var err error - t.EpochIndex, err = ParseHexUint64(aux.EpochIndex) - if err != nil { - return fmt.Errorf("error on EpochIndex: %w", err) - } - t.MaxLevel, err = ParseHexUint64(aux.MaxLevel) - if err != nil { - return fmt.Errorf("error on MaxLevel: %w", err) - } - t.Level, err = ParseHexUint64(aux.Level) - if err != nil { - return fmt.Errorf("error on Level: %w", err) - } - t.Log2Step, err = ParseHexUint64(aux.Log2Step) - if err != nil { - return fmt.Errorf("error on Log2Step: %w", err) - } - t.Height, err = ParseHexUint64(aux.Height) - if err != nil { - return fmt.Errorf("error on Height: %w", err) - } - t.FinishedAtBlock, err = ParseHexUint64(aux.FinishedAtBlock) - if err != nil { - return fmt.Errorf("error on FinishedAtBlock: %w", err) - } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.MaxLevel = uint64(aux.MaxLevel) + decoded.Level = uint64(aux.Level) + decoded.Log2Step = uint64(aux.Log2Step) + decoded.Height = uint64(aux.Height) + decoded.StartInstant = uint64(aux.StartInstant) + decoded.Allowance = uint64(aux.Allowance) + *value = decoded return nil } type Commitment struct { - ApplicationID int64 `sql:"primary_key" json:"-"` - EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` - TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` - Commitment common.Hash `sql:"primary_key" json:"commitment"` - FinalStateHash common.Hash `json:"final_state_hash"` - SubmitterAddress common.Address `json:"submitter_address"` - BlockNumber uint64 `json:"block_number"` - TxHash common.Hash `json:"tx_hash"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (c *Commitment) MarshalJSON() ([]byte, error) { - // Create an alias to avoid infinite recursion in MarshalJSON. + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` + TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` + Commitment common.Hash `sql:"primary_key" json:"commitment"` + FinalStateHash common.Hash `json:"final_state_hash"` + SubmitterAddress common.Address `json:"submitter_address"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + Snapshot CommitmentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value Commitment) MarshalJSON() ([]byte, error) { type Alias Commitment - // Define a new structure that embeds the alias but overrides the hex fields. - aux := &struct { - EpochIndex string `json:"epoch_index"` - BlockNumber string `json:"block_number"` + return json.Marshal(struct { *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` }{ - EpochIndex: fmt.Sprintf("0x%x", c.EpochIndex), - BlockNumber: fmt.Sprintf("0x%x", c.BlockNumber), - Alias: (*Alias)(c), - } - return json.Marshal(aux) + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) } -func (c *Commitment) UnmarshalJSON(data []byte) error { +func (value *Commitment) UnmarshalJSON(data []byte) error { type Alias Commitment - aux := &struct { - EpochIndex string `json:"epoch_index"` - BlockNumber string `json:"block_number"` + var decoded Commitment + aux := struct { *Alias - }{Alias: (*Alias)(c)} - - if err := json.Unmarshal(data, aux); err != nil { + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { return err } - *c = Commitment(*aux.Alias) - - var err error - c.EpochIndex, err = ParseHexUint64(aux.EpochIndex) - if err != nil { - return fmt.Errorf("error on EpochIndex: %w", err) - } - c.BlockNumber, err = ParseHexUint64(aux.BlockNumber) - if err != nil { - return fmt.Errorf("error on BlockNumber: %w", err) - } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded return nil } @@ -1624,59 +1593,117 @@ type Match struct { LeftOfTwo common.Hash `json:"left_of_two"` BlockNumber uint64 `json:"block_number"` TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + EliminableAt uint64 `json:"eliminable_at"` + LeafSeal *LeafMatchSeal `json:"leaf_seal"` Winner WinnerCommitment `json:"winner_commitment"` DeletionReason MatchDeletionReason `json:"deletion_reason"` DeletionBlockNumber uint64 `json:"deletion_block_number"` - DeletionTxHash common.Hash `json:"deletion_tx_hash"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` + DeletionLogIndex *uint64 `json:"deletion_log_index"` + Snapshot MatchSnapshot `json:"snapshot"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -func (m *Match) MarshalJSON() ([]byte, error) { - // Create an alias to avoid infinite recursion in MarshalJSON. +func (value Match) MarshalJSON() ([]byte, error) { type Alias Match - // Define a new structure that embeds the alias but overrides the hex fields. - aux := &struct { - EpochIndex string `json:"epoch_index"` - BlockNumber string `json:"block_number"` - DeletionBlockNumber string `json:"deletion_block_number"` + return json.Marshal(struct { *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + DeletionBlockNumber hexutil.Uint64 `json:"deletion_block_number"` + DeletionLogIndex *hexutil.Uint64 `json:"deletion_log_index"` }{ - EpochIndex: fmt.Sprintf("0x%x", m.EpochIndex), - BlockNumber: fmt.Sprintf("0x%x", m.BlockNumber), - DeletionBlockNumber: fmt.Sprintf("0x%x", m.DeletionBlockNumber), - Alias: (*Alias)(m), + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + EliminableAt: hexutil.Uint64(value.EliminableAt), + DeletionBlockNumber: hexutil.Uint64(value.DeletionBlockNumber), + DeletionLogIndex: (*hexutil.Uint64)(value.DeletionLogIndex), + }) +} + +func (value *Match) UnmarshalJSON(data []byte) error { + type Alias Match + var decoded Match + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + DeletionBlockNumber hexutil.Uint64 `json:"deletion_block_number"` + DeletionLogIndex *hexutil.Uint64 `json:"deletion_log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err } - return json.Marshal(aux) + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.DeletionBlockNumber = uint64(aux.DeletionBlockNumber) + decoded.DeletionLogIndex = (*uint64)(aux.DeletionLogIndex) + *value = decoded + return nil } type MatchAdvanced struct { - ApplicationID int64 `sql:"primary_key" json:"-"` - EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` - TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` - IDHash common.Hash `sql:"primary_key" json:"id_hash"` - OtherParent common.Hash `json:"other_parent"` - LeftNode common.Hash `json:"left_node"` - BlockNumber uint64 `json:"block_number"` - TxHash common.Hash `json:"tx_hash"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (m *MatchAdvanced) MarshalJSON() ([]byte, error) { - // Create an alias to avoid infinite recursion in MarshalJSON. + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + IDHash common.Hash `json:"id_hash"` + OtherParent common.Hash `json:"other_parent"` + LeftNode common.Hash `json:"left_node"` + SegmentStartPosition Uint256 `json:"segment_start_position"` + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `sql:"primary_key" json:"tx_hash"` + LogIndex uint64 `sql:"primary_key" json:"log_index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value MatchAdvanced) MarshalJSON() ([]byte, error) { type Alias MatchAdvanced - // Define a new structure that embeds the alias but overrides the hex fields. - aux := &struct { - EpochIndex string `json:"epoch_index"` - BlockNumber string `json:"block_number"` + return json.Marshal(struct { *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` }{ - EpochIndex: fmt.Sprintf("0x%x", m.EpochIndex), - BlockNumber: fmt.Sprintf("0x%x", m.BlockNumber), - Alias: (*Alias)(m), + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + EliminableAt: hexutil.Uint64(value.EliminableAt), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *MatchAdvanced) UnmarshalJSON(data []byte) error { + type Alias MatchAdvanced + var decoded MatchAdvanced + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err } - return json.Marshal(aux) + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil } // MatchDeletionReason represents the reason why a match was deleted @@ -1704,18 +1731,16 @@ func (e MatchDeletionReason) String() string { return string(e) } -func MatchDeletionReasonFromUint8(v uint8) MatchDeletionReason { +func MatchDeletionReasonFromUint8(v uint8) (MatchDeletionReason, error) { switch v { case 0: - return MatchDeletionReason_STEP + return MatchDeletionReason_STEP, nil case 1: - return MatchDeletionReason_TIMEOUT + return MatchDeletionReason_TIMEOUT, nil case 2: //nolint: mnd - return MatchDeletionReason_CHILD_TOURNAMENT - case 0xff: //nolint: mnd - return MatchDeletionReason_NOT_DELETED + return MatchDeletionReason_CHILD_TOURNAMENT, nil default: - return MatchDeletionReason_STEP // default to STEP for unknown values + return "", fmt.Errorf("unmodelled MatchDeletionReason %d from contract", v) } } @@ -1742,16 +1767,16 @@ func (e WinnerCommitment) String() string { return string(e) } -func WinnerCommitmentFromUint8(v uint8) WinnerCommitment { +func WinnerCommitmentFromUint8(v uint8) (WinnerCommitment, error) { switch v { case 0: - return WinnerCommitment_NONE + return WinnerCommitment_NONE, nil case 1: - return WinnerCommitment_ONE + return WinnerCommitment_ONE, nil case 2: //nolint: mnd - return WinnerCommitment_TWO + return WinnerCommitment_TWO, nil default: - return WinnerCommitment_NONE // default to NONE for unknown values + return "", fmt.Errorf("unmodelled WinnerCommitment %d from contract", v) } } diff --git a/internal/model/models_json_test.go b/internal/model/models_json_test.go index 4c5c9ff91..d592f70e3 100644 --- a/internal/model/models_json_test.go +++ b/internal/model/models_json_test.go @@ -12,6 +12,33 @@ import ( "github.com/stretchr/testify/require" ) +func TestApplicationJSONRoundtrip(t *testing.T) { + original := Application{ + Name: "direct-input-box", + IApplicationAddress: common.HexToAddress("0x1234"), + IConsensusAddress: common.HexToAddress("0x5678"), + IInputBoxAddress: common.HexToAddress("0x9abc"), + IInputBoxBlock: 10, + EpochLength: 20, + ClaimStagingPeriod: 30, + ConsensusType: Consensus_Authority, + Enabled: true, + Status: ApplicationStatus_OK, + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &fields)) + require.Contains(t, fields, "iinputbox_address") + require.NotContains(t, fields, "data_availability") + + var decoded Application + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, original, decoded) +} + func TestEpochJSONRoundtrip(t *testing.T) { root := common.HexToHash("0xabcd") iflagsY := common.HexToHash("0x1234") @@ -92,6 +119,50 @@ func TestStateProofCompleteness(t *testing.T) { require.False(t, (*Epoch)(nil).HasCompleteStateProof()) } +func TestEpochStateProof(t *testing.T) { + machineHash := common.Hash{1} + txBufferDataBlock := common.Hash{2} + iflagsYDataBlock := common.Hash{3} + htifTohostDataBlock := common.Hash{4} + epoch := &Epoch{ + MachineHash: &machineHash, + TxBufferDataBlock: &txBufferDataBlock, + TxBufferProof: make([]common.Hash, StateProofSiblingCount), + IflagsYDataBlock: &iflagsYDataBlock, + IflagsYProof: make([]common.Hash, StateProofSiblingCount), + HtifTohostDataBlock: &htifTohostDataBlock, + HtifTohostProof: make([]common.Hash, StateProofSiblingCount), + } + epoch.TxBufferProof[0] = common.Hash{5} + epoch.IflagsYProof[0] = common.Hash{6} + epoch.HtifTohostProof[0] = common.Hash{7} + + proof, err := epoch.StateProof() + require.NoError(t, err) + require.Equal(t, machineHash, proof.MachineHash) + require.Equal(t, txBufferDataBlock, proof.TxBufferDataBlock) + require.Equal(t, iflagsYDataBlock, proof.IflagsYDataBlock) + require.Equal(t, htifTohostDataBlock, proof.HtifTohostDataBlock) + require.Equal(t, [32]byte{5}, proof.TxBufferProof[0]) + require.Equal(t, [32]byte{6}, proof.IflagsYProof[0]) + require.Equal(t, [32]byte{7}, proof.HtifTohostProof[0]) + + proof.TxBufferProof[0][0] = 8 + proof.IflagsYProof[0][0] = 9 + proof.HtifTohostProof[0][0] = 10 + require.Equal(t, byte(5), epoch.TxBufferProof[0][0]) + require.Equal(t, byte(6), epoch.IflagsYProof[0][0]) + require.Equal(t, byte(7), epoch.HtifTohostProof[0][0]) +} + +func TestEpochStateProofRejectsIncompleteEpoch(t *testing.T) { + _, err := (&Epoch{}).StateProof() + require.ErrorIs(t, err, ErrIncompleteStateProof) + + _, err = (*Epoch)(nil).StateProof() + require.ErrorIs(t, err, ErrIncompleteStateProof) +} + func TestInputJSONRoundtrip(t *testing.T) { machineHash := common.HexToHash("0x1234") txBufferDataBlock := common.HexToHash("0xabcd") @@ -168,8 +239,9 @@ func TestInputUnmarshalJSONInvalidHex(t *testing.T) { wantErr: "LogIndex", }, { - name: "invalid ExceptionData", - json: `{"epoch_index":"0x0","index":"0x0","block_number":"0x0","raw_data":"0x","exception_data":"not-hex","log_index":"0x0"}`, + name: "invalid ExceptionData", + json: `{"epoch_index":"0x0","index":"0x0","block_number":"0x0","raw_data":"0x",` + + `"exception_data":"not-hex","log_index":"0x0"}`, wantErr: "ExceptionData", }, } @@ -271,11 +343,13 @@ func TestTournamentJSONRoundtrip(t *testing.T) { Level: 2, Log2Step: 16, Height: 8, - WinnerCommitment: &winner, - FinalStateHash: &finalState, - FinishedAtBlock: 9999, - CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), - UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), + Snapshot: TournamentSnapshot{ + WinnerCommitment: &winner, + FinalStateHash: &finalState, + FinishedAtBlock: 9999, + }, + CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), + UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), } data, err := json.Marshal(&original) @@ -295,9 +369,7 @@ func TestTournamentJSONRoundtrip(t *testing.T) { require.Equal(t, original.Level, decoded.Level) require.Equal(t, original.Log2Step, decoded.Log2Step) require.Equal(t, original.Height, decoded.Height) - require.Equal(t, original.WinnerCommitment, decoded.WinnerCommitment) - require.Equal(t, original.FinalStateHash, decoded.FinalStateHash) - require.Equal(t, original.FinishedAtBlock, decoded.FinishedAtBlock) + require.Equal(t, original.Snapshot, decoded.Snapshot) } func TestCommitmentJSONRoundtrip(t *testing.T) { diff --git a/internal/model/prt_enums.go b/internal/model/prt_enums.go new file mode 100644 index 000000000..a46ea232c --- /dev/null +++ b/internal/model/prt_enums.go @@ -0,0 +1,188 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +type TournamentKind string + +const ( + TournamentKindLeaf TournamentKind = "LEAF" + TournamentKindNonLeaf TournamentKind = "NON_LEAF" +) + +var TournamentKindAllValues = []TournamentKind{ + TournamentKindLeaf, + TournamentKindNonLeaf, +} + +func (e *TournamentKind) Scan(value any) error { + return scanEnum(e, value, TournamentKindAllValues, "TournamentKind") +} + +func (e TournamentKind) String() string { + return string(e) +} + +type TournamentStandingState string + +const ( + TournamentStandingMatchesActive TournamentStandingState = "MATCHES_ACTIVE" + TournamentStandingAwaitingClosure TournamentStandingState = "AWAITING_CLOSURE" + TournamentStandingRootWinner TournamentStandingState = "ROOT_WINNER" + TournamentStandingRootFailed TournamentStandingState = "ROOT_FAILED" + TournamentStandingInnerWinner TournamentStandingState = "INNER_WINNER" + TournamentStandingInnerEliminableNoWinner TournamentStandingState = "INNER_ELIMINABLE_NO_WINNER" + TournamentStandingInnerEliminableWinnerExpired TournamentStandingState = "INNER_ELIMINABLE_WINNER_EXPIRED" +) + +var TournamentStandingStateAllValues = []TournamentStandingState{ + TournamentStandingMatchesActive, + TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, + TournamentStandingRootFailed, + TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, + TournamentStandingInnerEliminableWinnerExpired, +} + +func (e *TournamentStandingState) Scan(value any) error { + return scanEnum(e, value, TournamentStandingStateAllValues, "TournamentStandingState") +} + +func (e TournamentStandingState) String() string { + return string(e) +} + +type MatchPhase string + +const ( + MatchPhaseUninitialized MatchPhase = "UNINITIALIZED" + MatchPhaseBisecting MatchPhase = "BISECTING" + MatchPhaseReadyToSeal MatchPhase = "READY_TO_SEAL" + MatchPhaseSealed MatchPhase = "SEALED" +) + +var MatchPhaseAllValues = []MatchPhase{ + MatchPhaseUninitialized, + MatchPhaseBisecting, + MatchPhaseReadyToSeal, + MatchPhaseSealed, +} + +func (e *MatchPhase) Scan(value any) error { + return scanEnum(e, value, MatchPhaseAllValues, "MatchPhase") +} + +func (e MatchPhase) String() string { + return string(e) +} + +type CommitmentSide string + +const ( + CommitmentSideOne CommitmentSide = "ONE" + CommitmentSideTwo CommitmentSide = "TWO" +) + +var CommitmentSideAllValues = []CommitmentSide{ + CommitmentSideOne, + CommitmentSideTwo, +} + +func (e *CommitmentSide) Scan(value any) error { + return scanEnum(e, value, CommitmentSideAllValues, "CommitmentSide") +} + +func (e CommitmentSide) String() string { + return string(e) +} + +type MatchTimeoutOutcome string + +const ( + MatchTimeoutNone MatchTimeoutOutcome = "NONE" + MatchTimeoutOneWins MatchTimeoutOutcome = "ONE_WINS" + MatchTimeoutTwoWins MatchTimeoutOutcome = "TWO_WINS" + MatchTimeoutEliminateBoth MatchTimeoutOutcome = "ELIMINATE_BOTH" +) + +var MatchTimeoutOutcomeAllValues = []MatchTimeoutOutcome{ + MatchTimeoutNone, + MatchTimeoutOneWins, + MatchTimeoutTwoWins, + MatchTimeoutEliminateBoth, +} + +func (e *MatchTimeoutOutcome) Scan(value any) error { + return scanEnum(e, value, MatchTimeoutOutcomeAllValues, "MatchTimeoutOutcome") +} + +func (e MatchTimeoutOutcome) String() string { + return string(e) +} + +type InnerTournamentDisposition string + +const ( + InnerTournamentUnsettled InnerTournamentDisposition = "UNSETTLED" + InnerTournamentWinner InnerTournamentDisposition = "WINNER" + InnerTournamentEliminable InnerTournamentDisposition = "ELIMINABLE" +) + +var InnerTournamentDispositionAllValues = []InnerTournamentDisposition{ + InnerTournamentUnsettled, + InnerTournamentWinner, + InnerTournamentEliminable, +} + +func (e *InnerTournamentDisposition) Scan(value any) error { + return scanEnum(e, value, InnerTournamentDispositionAllValues, "InnerTournamentDisposition") +} + +func (e InnerTournamentDisposition) String() string { + return string(e) +} + +type BondDisposition string + +const ( + BondDispositionTournamentRunning BondDisposition = "TOURNAMENT_RUNNING" + BondDispositionNoWinner BondDisposition = "NO_WINNER" + BondDispositionRecoverable BondDisposition = "RECOVERABLE" + BondDispositionRecovered BondDisposition = "RECOVERED" +) + +var BondDispositionAllValues = []BondDisposition{ + BondDispositionTournamentRunning, + BondDispositionNoWinner, + BondDispositionRecoverable, + BondDispositionRecovered, +} + +func (e *BondDisposition) Scan(value any) error { + return scanEnum(e, value, BondDispositionAllValues, "BondDisposition") +} + +func (e BondDisposition) String() string { + return string(e) +} + +type BondEventType string + +const ( + BondEventPartialRefund BondEventType = "PARTIAL_BOND_REFUND" + BondEventRecovered BondEventType = "BOND_RECOVERED" +) + +var BondEventTypeAllValues = []BondEventType{ + BondEventPartialRefund, + BondEventRecovered, +} + +func (e *BondEventType) Scan(value any) error { + return scanEnum(e, value, BondEventTypeAllValues, "BondEventType") +} + +func (e BondEventType) String() string { + return string(e) +} diff --git a/internal/model/prt_enums_test.go b/internal/model/prt_enums_test.go new file mode 100644 index 000000000..911e18be0 --- /dev/null +++ b/internal/model/prt_enums_test.go @@ -0,0 +1,201 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTournamentKindValues(t *testing.T) { + expected := []string{"LEAF", "NON_LEAF"} + actual := make([]string, len(TournamentKindAllValues)) + for i, value := range TournamentKindAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value TournamentKind + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := TournamentKindLeaf + require.Error(t, value.Scan(invalid)) + require.Equal(t, TournamentKindLeaf, value) + } +} + +func TestTournamentStandingStateValues(t *testing.T) { + expected := []string{ + "MATCHES_ACTIVE", "AWAITING_CLOSURE", "ROOT_WINNER", "ROOT_FAILED", + "INNER_WINNER", "INNER_ELIMINABLE_NO_WINNER", "INNER_ELIMINABLE_WINNER_EXPIRED", + } + actual := make([]string, len(TournamentStandingStateAllValues)) + for i, value := range TournamentStandingStateAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value TournamentStandingState + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := TournamentStandingMatchesActive + require.Error(t, value.Scan(invalid)) + require.Equal(t, TournamentStandingMatchesActive, value) + } +} + +func TestMatchPhaseValues(t *testing.T) { + expected := []string{"UNINITIALIZED", "BISECTING", "READY_TO_SEAL", "SEALED"} + actual := make([]string, len(MatchPhaseAllValues)) + for i, value := range MatchPhaseAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value MatchPhase + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := MatchPhaseUninitialized + require.Error(t, value.Scan(invalid)) + require.Equal(t, MatchPhaseUninitialized, value) + } +} + +func TestCommitmentSideValues(t *testing.T) { + expected := []string{"ONE", "TWO"} + actual := make([]string, len(CommitmentSideAllValues)) + for i, value := range CommitmentSideAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value CommitmentSide + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := CommitmentSideOne + require.Error(t, value.Scan(invalid)) + require.Equal(t, CommitmentSideOne, value) + } +} + +func TestMatchTimeoutOutcomeValues(t *testing.T) { + expected := []string{enumGoldenNone, "ONE_WINS", "TWO_WINS", "ELIMINATE_BOTH"} + actual := make([]string, len(MatchTimeoutOutcomeAllValues)) + for i, value := range MatchTimeoutOutcomeAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value MatchTimeoutOutcome + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := MatchTimeoutNone + require.Error(t, value.Scan(invalid)) + require.Equal(t, MatchTimeoutNone, value) + } +} + +func TestInnerTournamentDispositionValues(t *testing.T) { + expected := []string{"UNSETTLED", "WINNER", "ELIMINABLE"} + actual := make([]string, len(InnerTournamentDispositionAllValues)) + for i, value := range InnerTournamentDispositionAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value InnerTournamentDisposition + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := InnerTournamentUnsettled + require.Error(t, value.Scan(invalid)) + require.Equal(t, InnerTournamentUnsettled, value) + } +} + +func TestBondDispositionValues(t *testing.T) { + expected := []string{"TOURNAMENT_RUNNING", "NO_WINNER", "RECOVERABLE", "RECOVERED"} + actual := make([]string, len(BondDispositionAllValues)) + for i, value := range BondDispositionAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value BondDisposition + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := BondDispositionTournamentRunning + require.Error(t, value.Scan(invalid)) + require.Equal(t, BondDispositionTournamentRunning, value) + } +} + +func TestBondEventTypeValues(t *testing.T) { + expected := []string{"PARTIAL_BOND_REFUND", "BOND_RECOVERED"} + actual := make([]string, len(BondEventTypeAllValues)) + for i, value := range BondEventTypeAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value BondEventType + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := BondEventPartialRefund + require.Error(t, value.Scan(invalid)) + require.Equal(t, BondEventPartialRefund, value) + } +} + +func TestContractMatchEnumsRejectUnknownValues(t *testing.T) { + reasons := []MatchDeletionReason{MatchDeletionReason_STEP, MatchDeletionReason_TIMEOUT, MatchDeletionReason_CHILD_TOURNAMENT} + winners := []WinnerCommitment{WinnerCommitment_NONE, WinnerCommitment_ONE, WinnerCommitment_TWO} + for value := 0; value <= 255; value++ { + reason, reasonErr := MatchDeletionReasonFromUint8(uint8(value)) + winner, winnerErr := WinnerCommitmentFromUint8(uint8(value)) + if value < len(reasons) { + require.NoError(t, reasonErr) + require.Equal(t, reasons[value], reason) + require.NoError(t, winnerErr) + require.Equal(t, winners[value], winner) + } else { + require.Error(t, reasonErr) + require.Empty(t, reason) + require.Error(t, winnerErr) + require.Empty(t, winner) + } + } +} diff --git a/internal/model/prt_observer.go b/internal/model/prt_observer.go new file mode 100644 index 000000000..076a6ce4c --- /dev/null +++ b/internal/model/prt_observer.go @@ -0,0 +1,361 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +type TournamentSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Standing TournamentStandingState `json:"standing"` + AcceptsJoins bool `json:"accepts_joins"` + Candidate *common.Hash `json:"candidate"` + WinnerCommitment *common.Hash `json:"winner_commitment"` + FinalStateHash *common.Hash `json:"final_state_hash"` + ParentCommitment *common.Hash `json:"parent_commitment"` + FinishedAtBlock uint64 `json:"finished_at_block"` + WinnerExpiresAt uint64 `json:"winner_expires_at"` + InnerResult *TournamentInnerResult `json:"inner_result"` + BondRecovery TournamentBondRecovery `json:"bond_recovery"` +} + +func (value TournamentSnapshot) MarshalJSON() ([]byte, error) { + type Alias TournamentSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + FinishedAtBlock hexutil.Uint64 `json:"finished_at_block"` + WinnerExpiresAt hexutil.Uint64 `json:"winner_expires_at"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + FinishedAtBlock: hexutil.Uint64(value.FinishedAtBlock), + WinnerExpiresAt: hexutil.Uint64(value.WinnerExpiresAt), + }) +} + +func (value *TournamentSnapshot) UnmarshalJSON(data []byte) error { + type Alias TournamentSnapshot + var decoded TournamentSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + FinishedAtBlock hexutil.Uint64 `json:"finished_at_block"` + WinnerExpiresAt hexutil.Uint64 `json:"winner_expires_at"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.FinishedAtBlock = uint64(aux.FinishedAtBlock) + decoded.WinnerExpiresAt = uint64(aux.WinnerExpiresAt) + *value = decoded + return nil +} + +type TournamentInnerResult struct { + Disposition InnerTournamentDisposition `json:"disposition"` + ParentCommitment *common.Hash `json:"parent_commitment"` + PausedAllowance uint64 `json:"paused_allowance"` +} + +func (value TournamentInnerResult) MarshalJSON() ([]byte, error) { + type Alias TournamentInnerResult + return json.Marshal(struct { + *Alias + PausedAllowance hexutil.Uint64 `json:"paused_allowance"` + }{ + Alias: (*Alias)(&value), + PausedAllowance: hexutil.Uint64(value.PausedAllowance), + }) +} + +func (value *TournamentInnerResult) UnmarshalJSON(data []byte) error { + type Alias TournamentInnerResult + var decoded TournamentInnerResult + aux := struct { + *Alias + PausedAllowance hexutil.Uint64 `json:"paused_allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.PausedAllowance = uint64(aux.PausedAllowance) + *value = decoded + return nil +} + +type TournamentBondRecovery struct { + Disposition BondDisposition `json:"disposition"` + Claimer *common.Address `json:"claimer"` + Payment *Uint256 `json:"payment"` +} + +type CommitmentSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Claimer common.Address `json:"claimer"` + ClockRunning bool `json:"clock_running"` + ClockDeadline uint64 `json:"clock_deadline"` + ClockAllowance uint64 `json:"clock_allowance"` +} + +func (value CommitmentSnapshot) MarshalJSON() ([]byte, error) { + type Alias CommitmentSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + ClockDeadline hexutil.Uint64 `json:"clock_deadline"` + ClockAllowance hexutil.Uint64 `json:"clock_allowance"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + ClockDeadline: hexutil.Uint64(value.ClockDeadline), + ClockAllowance: hexutil.Uint64(value.ClockAllowance), + }) +} + +func (value *CommitmentSnapshot) UnmarshalJSON(data []byte) error { + type Alias CommitmentSnapshot + var decoded CommitmentSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + ClockDeadline hexutil.Uint64 `json:"clock_deadline"` + ClockAllowance hexutil.Uint64 `json:"clock_allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.ClockDeadline = uint64(aux.ClockDeadline) + decoded.ClockAllowance = uint64(aux.ClockAllowance) + *value = decoded + return nil +} + +type MatchSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Phase MatchPhase `json:"phase"` + Bisection *MatchBisectionSnapshot `json:"bisection"` + Sealed *MatchSealedSnapshot `json:"sealed"` + TimeoutOutcome MatchTimeoutOutcome `json:"timeout_outcome"` + DeferredCharge uint64 `json:"deferred_charge"` +} + +func (value MatchSnapshot) MarshalJSON() ([]byte, error) { + type Alias MatchSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + DeferredCharge hexutil.Uint64 `json:"deferred_charge"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + DeferredCharge: hexutil.Uint64(value.DeferredCharge), + }) +} + +func (value *MatchSnapshot) UnmarshalJSON(data []byte) error { + type Alias MatchSnapshot + var decoded MatchSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + DeferredCharge hexutil.Uint64 `json:"deferred_charge"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.DeferredCharge = uint64(aux.DeferredCharge) + *value = decoded + return nil +} + +type MatchBisectionSnapshot struct { + RevealingParent common.Hash `json:"revealing_parent"` + WaitingLeft common.Hash `json:"waiting_left"` + WaitingRight common.Hash `json:"waiting_right"` + SegmentStartPosition Uint256 `json:"segment_start_position"` + SegmentStartCycle Uint256 `json:"segment_start_cycle"` + CurrentHeight *uint64 `json:"current_height"` + Responder CommitmentSide `json:"responder"` +} + +func (value MatchBisectionSnapshot) MarshalJSON() ([]byte, error) { + type Alias MatchBisectionSnapshot + return json.Marshal(struct { + *Alias + CurrentHeight *hexutil.Uint64 `json:"current_height"` + }{ + Alias: (*Alias)(&value), + CurrentHeight: (*hexutil.Uint64)(value.CurrentHeight), + }) +} + +func (value *MatchBisectionSnapshot) UnmarshalJSON(data []byte) error { + type Alias MatchBisectionSnapshot + var decoded MatchBisectionSnapshot + aux := struct { + *Alias + CurrentHeight *hexutil.Uint64 `json:"current_height"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.CurrentHeight = (*uint64)(aux.CurrentHeight) + *value = decoded + return nil +} + +type MatchSealedSnapshot struct { + AgreeState common.Hash `json:"agree_state"` + DivergencePosition Uint256 `json:"divergence_position"` + DivergenceCycle Uint256 `json:"divergence_cycle"` + FinalStateOne common.Hash `json:"final_state_one"` + FinalStateTwo common.Hash `json:"final_state_two"` +} + +type TournamentCreationEvent struct { + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` +} + +func (value TournamentCreationEvent) MarshalJSON() ([]byte, error) { + type Alias TournamentCreationEvent + return json.Marshal(struct { + *Alias + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *TournamentCreationEvent) UnmarshalJSON(data []byte) error { + type Alias TournamentCreationEvent + var decoded TournamentCreationEvent + aux := struct { + *Alias + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type LeafMatchSeal struct { + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` +} + +func (value LeafMatchSeal) MarshalJSON() ([]byte, error) { + type Alias LeafMatchSeal + return json.Marshal(struct { + *Alias + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EliminableAt: hexutil.Uint64(value.EliminableAt), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *LeafMatchSeal) UnmarshalJSON(data []byte) error { + type Alias LeafMatchSeal + var decoded LeafMatchSeal + aux := struct { + *Alias + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type BondEvent struct { + ApplicationID int64 `json:"-"` + EpochIndex uint64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + Type BondEventType `json:"type"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + Refund *PartialBondRefund `json:"refund"` + Recovery *BondRecovered `json:"recovery"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value BondEvent) MarshalJSON() ([]byte, error) { + type Alias BondEvent + return json.Marshal(struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *BondEvent) UnmarshalJSON(data []byte) error { + type Alias BondEvent + var decoded BondEvent + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type PartialBondRefund struct { + Recipient common.Address `json:"recipient"` + Value Uint256 `json:"value"` + Success bool `json:"success"` +} + +type BondRecovered struct { + Commitment common.Hash `json:"commitment"` + Claimer common.Address `json:"claimer"` + Payment Uint256 `json:"payment"` + Burned Uint256 `json:"burned"` +} diff --git a/internal/model/uint256.go b/internal/model/uint256.go new file mode 100644 index 000000000..207876884 --- /dev/null +++ b/internal/model/uint256.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/holiman/uint256" +) + +// Uint256 stores a Solidity unsigned integer without narrowing its value. +// SQL uses decimal NUMERIC values; JSON uses canonical hexadecimal quantities. +type Uint256 uint256.Int + +var ErrInvalidUint256 = errors.New("invalid uint256") + +func Uint256FromBig(value *big.Int) (Uint256, error) { + if value == nil || value.Sign() < 0 { + return Uint256{}, ErrInvalidUint256 + } + parsed, overflow := uint256.FromBig(value) + if overflow { + return Uint256{}, ErrInvalidUint256 + } + return Uint256(*parsed), nil +} + +func (value Uint256) ToBig() *big.Int { + return (*uint256.Int)(&value).ToBig() +} + +func (value *Uint256) Scan(source any) error { + if source == nil { + return fmt.Errorf("cannot scan NULL into Uint256: %w", ErrInvalidUint256) + } + // PostgreSQL NUMERIC can retain decimal scale for an exact integer (1.00). + // Remove only zero fractional digits; a fractional value is not uint256. + var text string + switch source := source.(type) { + case string: + text = source + case []byte: + text = string(source) + default: + return fmt.Errorf("unsupported Uint256 scan type %T: %w", source, ErrInvalidUint256) + } + if text == "" { + return fmt.Errorf("empty Uint256 value: %w", ErrInvalidUint256) + } + if whole, fraction, found := strings.Cut(text, "."); found { + if fraction == "" || strings.Trim(fraction, "0") != "" { + return fmt.Errorf("fractional Uint256 value: %w", ErrInvalidUint256) + } + text = whole + } + var parsed uint256.Int + if err := parsed.Scan(text); err != nil { + return fmt.Errorf("scan Uint256: %w", err) + } + *value = Uint256(parsed) + return nil +} + +func (value Uint256) Value() (driver.Value, error) { + return (*uint256.Int)(&value).Value() +} + +func (value Uint256) MarshalJSON() ([]byte, error) { + return json.Marshal((*uint256.Int)(&value).Hex()) +} + +func (value *Uint256) UnmarshalJSON(data []byte) error { + var text string + if err := json.Unmarshal(data, &text); err != nil { + return err + } + var parsed uint256.Int + if err := parsed.SetFromHex(text); err != nil { + return fmt.Errorf("decode Uint256: %w", err) + } + *value = Uint256(parsed) + return nil +} diff --git a/internal/model/uint256_test.go b/internal/model/uint256_test.go new file mode 100644 index 000000000..235d70a8b --- /dev/null +++ b/internal/model/uint256_test.go @@ -0,0 +1,108 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/require" +) + +func TestUint256Codec(t *testing.T) { + const maximum = "115792089237316195423570985008687907853269984665640564039457584007913129639935" + const over = "115792089237316195423570985008687907853269984665640564039457584007913129639936" + const maxHex = `"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"` + mapping := pgtype.NewMap() + for _, decimal := range []string{"0", "18446744073709551616", maximum} { + var value Uint256 + if err := value.Scan(decimal); err != nil { + t.Fatal(err) + } + for _, format := range []int16{pgtype.TextFormatCode, pgtype.BinaryFormatCode} { + encoded, err := mapping.Encode(pgtype.NumericOID, format, value, nil) + if err != nil { + t.Fatal(err) + } + var decoded Uint256 + if err := mapping.Scan(pgtype.NumericOID, format, encoded, &decoded); err != nil { + t.Fatal(err) + } + if value != decoded { + t.Fatalf("numeric round trip: got %v want %v", decoded, value) + } + } + if decimal == maximum { + encoded, err := json.Marshal(value) + if err != nil || string(encoded) != maxHex { + t.Fatalf("JSON: %s, %v", encoded, err) + } + } + } + for _, src := range []any{nil, "", "-1", "0.5", over, 123, "1e78"} { + value := Uint256(*uint256.NewInt(7)) + before := value + if err := value.Scan(src); err == nil { + t.Fatalf("accepted invalid SQL value %#v", src) + } + if value != before { + t.Fatalf("changed receiver after SQL error %#v", src) + } + } + for _, raw := range []string{`"0x0"`, `"0x10000000000000000"`, maxHex} { + var value Uint256 + if err := json.Unmarshal([]byte(raw), &value); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(value) + if err != nil || string(encoded) != raw { + t.Fatalf("JSON round trip: %s, %v", encoded, err) + } + } + for _, raw := range []string{ + `null`, `1`, `"1"`, `"0x"`, `"0x00"`, `"-0x1"`, + `"0x10000000000000000000000000000000000000000000000000000000000000000"`, + } { + value := Uint256(*uint256.NewInt(7)) + before := value + if err := json.Unmarshal([]byte(raw), &value); err == nil { + t.Fatalf("accepted invalid JSON %s", raw) + } + if value != before { + t.Fatalf("changed receiver after JSON error %s", raw) + } + } + var nullable *Uint256 + if err := mapping.Scan(pgtype.NumericOID, pgtype.TextFormatCode, nil, &nullable); err != nil { + t.Fatal(err) + } + if nullable != nil { + t.Fatal("SQL NULL must remain nil") + } + for _, format := range []int16{pgtype.TextFormatCode, pgtype.BinaryFormatCode} { + scaledInteger := pgtype.Numeric{Int: big.NewInt(100), Exp: -2, Valid: true} + encoded, err := mapping.Encode(pgtype.NumericOID, format, scaledInteger, nil) + require.NoError(t, err) + var decoded Uint256 + require.NoError(t, mapping.Scan(pgtype.NumericOID, format, encoded, &decoded)) + require.Equal(t, big.NewInt(1), decoded.ToBig()) + } +} + +func TestUint256FromBig(t *testing.T) { + limit := new(big.Int).Lsh(big.NewInt(1), 256) + for _, value := range []*big.Int{nil, big.NewInt(-1), limit} { + _, err := Uint256FromBig(value) + require.ErrorIs(t, err, ErrInvalidUint256) + } + maximum := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1)) + value, err := Uint256FromBig(maximum) + require.NoError(t, err) + require.Equal(t, maximum, value.ToBig()) + maximum.SetInt64(0) + require.NotZero(t, value.ToBig().Sign(), "the model must not share mutable big.Int storage") +} diff --git a/internal/repository/repotest/state_proof.go b/internal/repository/repotest/state_proof.go new file mode 100644 index 000000000..7d1d40c06 --- /dev/null +++ b/internal/repository/repotest/state_proof.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "encoding/binary" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// KeccakStateProof builds a valid machine proof with accepted-state registers. +// DummyStateProof remains available for non-cryptographic fixtures. +func KeccakStateProof(outputsRoot common.Hash) StateProof { + const ( + iflagsYDataBlockAddress uint64 = 0x300 + htifTohostDataBlockAddress uint64 = 0x320 + txBufferDataBlockAddress = 0x60800000 + acceptedHtifTohost uint64 = 2<<56 | 1<<48 | 1<<32 + ) + var iflagsYDataBlock common.Hash + binary.LittleEndian.PutUint64(iflagsYDataBlock[8:], 1) + var htifTohostDataBlock common.Hash + binary.LittleEndian.PutUint64(htifTohostDataBlock[16:], acceptedHtifTohost) + blocks := map[uint64]common.Hash{ + iflagsYDataBlockAddress: iflagsYDataBlock, + htifTohostDataBlockAddress: htifTohostDataBlock, + txBufferDataBlockAddress: outputsRoot, + } + machineHash, proofs := buildKeccakStateProofs(blocks) + return StateProof{ + MachineHash: machineHash, + IflagsYDataBlock: iflagsYDataBlock, + IflagsYProof: proofs[iflagsYDataBlockAddress], + HtifTohostDataBlock: htifTohostDataBlock, + HtifTohostProof: proofs[htifTohostDataBlockAddress], + TxBufferDataBlock: outputsRoot, + TxBufferProof: proofs[txBufferDataBlockAddress], + } +} + +func buildKeccakStateProofs(blocks map[uint64]common.Hash) (common.Hash, map[uint64][][32]byte) { + const log2DataBlockSize = 5 + defaultHashes := make([]common.Hash, StateProofSiblingCount+1) + defaultHashes[0] = crypto.Keccak256Hash(make([]byte, 1<> log2DataBlockSize + indexes[address] = index + current[index] = crypto.Keccak256Hash(block[:]) + siblings[address] = make([][32]byte, 0, StateProofSiblingCount) + } + for level := 0; level < StateProofSiblingCount; level++ { + for address, originalIndex := range indexes { + index := originalIndex >> level + sibling, ok := current[index^1] + if !ok { + sibling = defaultHashes[level] + } + siblings[address] = append(siblings[address], sibling) + } + parents := make(map[uint64]struct{}, len(current)) + for index := range current { + parents[index>>1] = struct{}{} + } + next := make(map[uint64]common.Hash, len(parents)) + for parent := range parents { + left, ok := current[parent<<1] + if !ok { + left = defaultHashes[level] + } + right, ok := current[parent<<1|1] + if !ok { + right = defaultHashes[level] + } + next[parent] = crypto.Keccak256Hash(left[:], right[:]) + } + current = next + } + return current[0], siblings +} diff --git a/internal/repository/repotest/state_proof_test.go b/internal/repository/repotest/state_proof_test.go new file mode 100644 index 000000000..9783a817b --- /dev/null +++ b/internal/repository/repotest/state_proof_test.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "encoding/binary" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestKeccakStateProof(t *testing.T) { + outputsRoot := common.HexToHash("0xa1") + proof := KeccakStateProof(outputsRoot) + require.True(t, proof.IsComplete()) + require.Equal(t, outputsRoot, proof.TxBufferDataBlock) + require.Equal(t, uint64(1), binary.LittleEndian.Uint64(proof.IflagsYDataBlock[8:16])) + require.Equal(t, uint64(2)<<56|uint64(1)<<48|uint64(1)<<32, binary.LittleEndian.Uint64(proof.HtifTohostDataBlock[16:24])) + for _, leaf := range []struct { + name string + address uint64 + data common.Hash + siblings [][32]byte + }{ + {name: "iflags.Y", address: 0x300, data: proof.IflagsYDataBlock, siblings: proof.IflagsYProof}, + {name: "htif.tohost", address: 0x320, data: proof.HtifTohostDataBlock, siblings: proof.HtifTohostProof}, + {name: "outputs root", address: 0x60800000, data: proof.TxBufferDataBlock, siblings: proof.TxBufferProof}, + } { + t.Run(leaf.name, func(t *testing.T) { + hash := crypto.Keccak256Hash(leaf.data[:]) + index := leaf.address >> 5 + for _, sibling := range leaf.siblings { + if index&1 == 0 { + hash = crypto.Keccak256Hash(hash[:], sibling[:]) + } else { + hash = crypto.Keccak256Hash(sibling[:], hash[:]) + } + index >>= 1 + } + require.Equal(t, proof.MachineHash, hash) + }) + } +} From 6559120d7b1e4049f408ff0813d5791020f4664e Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:30:58 -0300 Subject: [PATCH 09/41] feat(config): validate saved service settings Require explicit saved chain, observation policy, and submission mode values. Reject changes instead of silently replacing requested or stored settings. Keep submission transitions outside this release. --- internal/config/generate/Config.toml | 15 +++- internal/config/generated.go | 44 ++++++--- internal/config/persistent.go | 130 +++++++++++++++++++++++++++ internal/config/persistent_test.go | 112 +++++++++++++++++++++++ 4 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 internal/config/persistent.go create mode 100644 internal/config/persistent_test.go diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index cb39dc353..60a05ad62 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -80,7 +80,9 @@ used-by = ["evmreader", "node"] default = "true" go-type = "bool" description = """ -If set to false, the node will not submit claims (reader mode).""" +If set to false, the node will not submit claims (reader mode). +The mode is saved when each submitter first starts. A later configuration mismatch +stops startup. Mode changes on an existing database are not supported.""" used-by = ["claimer", "node", "prt"] [features.CARTESI_FEATURE_INSPECT_ENABLED] @@ -218,15 +220,20 @@ used-by = ["evmreader", "claimer", "node"] default = "false" go-type = "bool" description = """ -If set to true the node will send transactions using the legacy gas fee model -(instead of EIP-1559).""" +If true, force the legacy transaction format with a fresh suggested gas price +for each transaction. If false, select the fee format automatically: EIP-1559 +when the network provides a base fee, or legacy otherwise. +This setting does not change gas estimation or replace pending transactions.""" used-by = ["claimer", "node", "cli", "prt"] [blockchain.CARTESI_BLOCKCHAIN_DEFAULT_BLOCK] default = "finalized" go-type = "DefaultBlock" description = """ -The default block to be used by EVM Reader and Claimer when requesting new blocks. +The block used by EVM Reader, Claimer, and PRT for chain-state observations. +PRT checks transaction readiness separately at 'latest'. +Use 'finalized' in production. The node does not detect or roll back reorgs. +The policy is saved when each service first starts. A later configuration mismatch stops startup. One of 'latest', 'pending', 'safe', 'finalized'""" used-by = ["evmreader", "claimer", "node", "prt"] diff --git a/internal/config/generated.go b/internal/config/generated.go index cf9dab7ce..450c549e0 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -406,7 +406,10 @@ func LoadAdvancerConfig() (*AdvancerConfig, error) { // ClaimerConfig holds configuration values for the claimer service. type ClaimerConfig struct { - // The default block to be used by EVM Reader and Claimer when requesting new blocks. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // PRT checks transaction readiness separately at 'latest'. + // Use 'finalized' in production. The node does not detect or roll back reorgs. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. // One of 'latest', 'pending', 'safe', 'finalized' BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` @@ -416,8 +419,10 @@ type ClaimerConfig struct { // An unique identifier representing a blockchain network. BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` - // If set to true the node will send transactions using the legacy gas fee model - // (instead of EIP-1559). + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation or replace pending transactions. BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). @@ -431,6 +436,8 @@ type ClaimerConfig struct { DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` // HTTP address for Claimer's telemetry service. @@ -612,7 +619,10 @@ func LoadClaimerConfig() (*ClaimerConfig, error) { // EvmreaderConfig holds configuration values for the evmreader service. type EvmreaderConfig struct { - // The default block to be used by EVM Reader and Claimer when requesting new blocks. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // PRT checks transaction readiness separately at 'latest'. + // Use 'finalized' in production. The node does not detect or roll back reorgs. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. // One of 'latest', 'pending', 'safe', 'finalized' BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` @@ -918,7 +928,10 @@ func LoadJsonrpcConfig() (*JsonrpcConfig, error) { // NodeConfig holds configuration values for the node service. type NodeConfig struct { - // The default block to be used by EVM Reader and Claimer when requesting new blocks. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // PRT checks transaction readiness separately at 'latest'. + // Use 'finalized' in production. The node does not detect or roll back reorgs. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. // One of 'latest', 'pending', 'safe', 'finalized' BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` @@ -928,8 +941,10 @@ type NodeConfig struct { // An unique identifier representing a blockchain network. BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` - // If set to true the node will send transactions using the legacy gas fee model - // (instead of EIP-1559). + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation or replace pending transactions. BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). @@ -943,6 +958,8 @@ type NodeConfig struct { DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` // If set to false, the node will not read inputs from the blockchain. @@ -1322,7 +1339,10 @@ func LoadNodeConfig() (*NodeConfig, error) { // PrtConfig holds configuration values for the prt service. type PrtConfig struct { - // The default block to be used by EVM Reader and Claimer when requesting new blocks. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // PRT checks transaction readiness separately at 'latest'. + // Use 'finalized' in production. The node does not detect or roll back reorgs. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. // One of 'latest', 'pending', 'safe', 'finalized' BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` @@ -1332,8 +1352,10 @@ type PrtConfig struct { // An unique identifier representing a blockchain network. BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` - // If set to true the node will send transactions using the legacy gas fee model - // (instead of EIP-1559). + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation or replace pending transactions. BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). @@ -1347,6 +1369,8 @@ type PrtConfig struct { DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` // HTTP address for PRT's telemetry service. diff --git a/internal/config/persistent.go b/internal/config/persistent.go new file mode 100644 index 000000000..10e83b9ce --- /dev/null +++ b/internal/config/persistent.go @@ -0,0 +1,130 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "encoding/json" + "fmt" + "math/big" + "slices" + + "github.com/cartesi/rollups-node/internal/model" +) + +// PersistentChainConfig identifies the chain and the observation policy used to +// write service state. A restart must not silently change either value. +type PersistentChainConfig struct { + DefaultBlock model.DefaultBlock + ChainID uint64 +} + +func (c PersistentChainConfig) Validate() error { + if c.ChainID == 0 { + return fmt.Errorf("ChainID must be greater than zero") + } + if !slices.Contains(model.DefaultBlockAllValues, c.DefaultBlock) { + return fmt.Errorf("invalid DefaultBlock %q", c.DefaultBlock) + } + return nil +} + +// UnmarshalJSON checks required fields. Plain struct decoding would accept a +// missing field or null as a valid zero value, including a disabled submitter. +func (c *PersistentChainConfig) UnmarshalJSON(data []byte) error { + var raw struct { + DefaultBlock *model.DefaultBlock + ChainID *uint64 + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.DefaultBlock == nil || raw.ChainID == nil { + return fmt.Errorf("persistent config requires non-null DefaultBlock and ChainID") + } + next := PersistentChainConfig{DefaultBlock: *raw.DefaultBlock, ChainID: *raw.ChainID} + if err := next.Validate(); err != nil { + return err + } + *c = next + return nil +} + +// CheckRequested rejects changes to a database's chain or observation policy. +// A stronger block policy does not repair data already read at a weaker policy. +func (c PersistentChainConfig) CheckRequested(requested PersistentChainConfig) error { + if err := requested.Validate(); err != nil { + return fmt.Errorf("invalid requested config: %w", err) + } + if err := c.Validate(); err != nil { + return fmt.Errorf("invalid saved config: %w", err) + } + if c.ChainID != requested.ChainID { + return fmt.Errorf("chain ID mismatch: database=%d, configured=%d; saved config was not changed", + c.ChainID, requested.ChainID) + } + if c.DefaultBlock != requested.DefaultBlock { + return fmt.Errorf("observation policy mismatch: database=%s, configured=%s; saved config was not changed", + c.DefaultBlock, requested.DefaultBlock) + } + return nil +} + +// PersistentSubmitterConfig adds a submission mode fixed when the service first +// starts. Mode transitions require claim and tournament reconciliation that is +// not supported yet. In particular, observing a Quorum claim is not a local vote. +type PersistentSubmitterConfig struct { + DefaultBlock model.DefaultBlock + ClaimSubmissionEnabled bool + ChainID uint64 +} + +func (c PersistentSubmitterConfig) chainConfig() PersistentChainConfig { + return PersistentChainConfig{DefaultBlock: c.DefaultBlock, ChainID: c.ChainID} +} + +func (c PersistentSubmitterConfig) Validate() error { + return c.chainConfig().Validate() +} + +func (c *PersistentSubmitterConfig) UnmarshalJSON(data []byte) error { + var chain PersistentChainConfig + if err := json.Unmarshal(data, &chain); err != nil { + return err + } + var raw struct{ ClaimSubmissionEnabled *bool } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.ClaimSubmissionEnabled == nil { + return fmt.Errorf("persistent config requires non-null ClaimSubmissionEnabled") + } + *c = PersistentSubmitterConfig{ + DefaultBlock: chain.DefaultBlock, ChainID: chain.ChainID, + ClaimSubmissionEnabled: *raw.ClaimSubmissionEnabled, + } + return nil +} + +func (c PersistentSubmitterConfig) CheckRequested(requested PersistentSubmitterConfig) error { + if err := c.chainConfig().CheckRequested(requested.chainConfig()); err != nil { + return err + } + if c.ClaimSubmissionEnabled != requested.ClaimSubmissionEnabled { + return fmt.Errorf("claim submission mode mismatch: database=%t, configured=%t; "+ + "mode changes on an existing database are not supported; saved config was not changed", + c.ClaimSubmissionEnabled, requested.ClaimSubmissionEnabled) + } + return nil +} + +// CheckNetworkChainID avoids truncating an invalid or oversized RPC chain ID. +func CheckNetworkChainID(network *big.Int, configured uint64) error { + if network == nil || network.Sign() <= 0 || !network.IsUint64() { + return fmt.Errorf("invalid network chain ID %v", network) + } + if network.Uint64() != configured { + return fmt.Errorf("chain ID mismatch: network=%s, configured=%d", network, configured) + } + return nil +} diff --git a/internal/config/persistent_test.go b/internal/config/persistent_test.go new file mode 100644 index 000000000..a25640629 --- /dev/null +++ b/internal/config/persistent_test.go @@ -0,0 +1,112 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "encoding/json" + "math" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" +) + +func TestPersistentConfigRejectsInvalidJSONFields(t *testing.T) { + for _, raw := range []string{ + `null`, `{}`, `[]`, ``, `{`, + `{"DefaultBlock":"FINALIZED"}`, + `{"ChainID":1}`, + `{"DefaultBlock":null,"ChainID":1}`, + `{"DefaultBlock":"FINALIZED","ChainID":null}`, + `{"DefaultBlock":"FINALIZED","ChainID":0}`, + `{"DefaultBlock":"FINALIZED","ChainID":-1}`, + `{"DefaultBlock":"FINALIZED","ChainID":18446744073709551616}`, + `{"DefaultBlock":"FINALIZED","ChainID":"1"}`, + `{"DefaultBlock":1,"ChainID":1}`, + `{"DefaultBlock":"finalized","ChainID":1}`, + `{"DefaultBlock":"INVALID","ChainID":1}`, + } { + t.Run(raw, func(t *testing.T) { + var chain PersistentChainConfig + require.Error(t, json.Unmarshal([]byte(raw), &chain)) + var submitter PersistentSubmitterConfig + require.Error(t, json.Unmarshal([]byte(raw), &submitter)) + }) + } + for _, mode := range []string{"", `,"ClaimSubmissionEnabled":null`, `,"ClaimSubmissionEnabled":"false"`} { + t.Run("mode"+mode, func(t *testing.T) { + raw := []byte(`{"DefaultBlock":"FINALIZED","ChainID":1` + mode + `}`) + var submitter PersistentSubmitterConfig + require.Error(t, json.Unmarshal(raw, &submitter)) + }) + } +} + +func TestPersistentConfigRoundTrip(t *testing.T) { + for _, policy := range model.DefaultBlockAllValues { + for _, enabled := range []bool{false, true} { + want := PersistentSubmitterConfig{ + DefaultBlock: policy, ClaimSubmissionEnabled: enabled, ChainID: math.MaxUint64, + } + raw, err := json.Marshal(want) + require.NoError(t, err) + var got PersistentSubmitterConfig + require.NoError(t, json.Unmarshal(raw, &got)) + require.Equal(t, want, got) + require.NoError(t, got.CheckRequested(want)) + var chain PersistentChainConfig + require.NoError(t, json.Unmarshal(raw, &chain)) + require.NoError(t, chain.CheckRequested(want.chainConfig())) + } + } +} + +func TestPersistentConfigAcceptsUnknownFields(t *testing.T) { + t.Parallel() + raw := []byte(`{"DefaultBlock":"FINALIZED","ChainID":42,"ClaimSubmissionEnabled":true,"ExtraSetting":{"enabled":false}}`) + var chain PersistentChainConfig + require.NoError(t, json.Unmarshal(raw, &chain)) + require.Equal(t, PersistentChainConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42}, chain) + var submitter PersistentSubmitterConfig + require.NoError(t, json.Unmarshal(raw, &submitter)) + require.Equal(t, PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: true, + }, submitter) +} + +func TestPersistentConfigRejectsRequestedChanges(t *testing.T) { + saved := PersistentSubmitterConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1} + for _, test := range []struct { + name string + change func(*PersistentSubmitterConfig) + want string + }{ + {"chain", func(c *PersistentSubmitterConfig) { c.ChainID = 2 }, "database=1, configured=2"}, + {"policy", func(c *PersistentSubmitterConfig) { c.DefaultBlock = model.DefaultBlock_Latest }, + "database=FINALIZED, configured=LATEST"}, + {"mode", func(c *PersistentSubmitterConfig) { c.ClaimSubmissionEnabled = true }, "database=false, configured=true"}, + {"invalid chain", func(c *PersistentSubmitterConfig) { c.ChainID = 0 }, "invalid requested config"}, + {"invalid policy", func(c *PersistentSubmitterConfig) { c.DefaultBlock = "" }, "invalid requested config"}, + } { + t.Run(test.name, func(t *testing.T) { + requested := saved + test.change(&requested) + require.ErrorContains(t, saved.CheckRequested(requested), test.want) + }) + } + requested := saved + saved.ClaimSubmissionEnabled = true + require.ErrorContains(t, saved.CheckRequested(requested), "database=true, configured=false") + saved.ChainID = 0 + require.ErrorContains(t, saved.CheckRequested(requested), "invalid saved config") +} + +func TestCheckNetworkChainID(t *testing.T) { + for _, invalid := range []*big.Int{nil, big.NewInt(0), big.NewInt(-1), new(big.Int).Lsh(big.NewInt(1), 64)} { + require.ErrorContains(t, CheckNetworkChainID(invalid, 1), "invalid network chain ID") + } + require.ErrorContains(t, CheckNetworkChainID(big.NewInt(2), 1), "network=2, configured=1") + require.NoError(t, CheckNetworkChainID(new(big.Int).SetUint64(math.MaxUint64), math.MaxUint64)) +} From a46bb4f93ee143e51abc142873c2ab4806d0123c Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:15 -0300 Subject: [PATCH 10/41] feat(repository)!: adopt v3 epoch state Adapt application storage to direct InputBox references and permit PRT claims to enter the staged state with their on-chain staging block. Select the newest claim barrier per application with the complete machine proof. Use the shared non-terminal status set for drain queries. Report a publication that loses to foreclosure as ErrEpochForeclosed instead of storing late claim or proof data. Include repository regression tests. Update the initial pre-release migration; this series targets new databases rather than adding an upgrade migration. BREAKING CHANGE: stored application and epoch state target the new v3 contracts and require the updated initial schema. Keep the output scan cursor monotonic while committing output execution updates. Initialize saved service settings with insert-only writes and validate the stored winner after concurrent startup. --- internal/repository/node_config_test.go | 59 +++++++ internal/repository/postgres/application.go | 39 ++--- internal/repository/postgres/bulk.go | 8 +- internal/repository/postgres/claimer.go | 76 ++++---- .../db/rollupsdb/public/table/application.go | 7 +- internal/repository/postgres/epoch.go | 72 ++++---- .../epoch_publication_foreclosure_test.go | 162 ++++++++++++++++++ .../postgres/epoch_unreconciled_test.go | 111 +++++++++--- .../repository/postgres/event_column_test.go | 49 ++++++ internal/repository/postgres/node_config.go | 10 ++ internal/repository/postgres/output.go | 6 +- .../repository/postgres/postgres_repo_test.go | 35 +++- .../000001_create_initial_schema.down.sql | 2 - .../000001_create_initial_schema.up.sql | 37 ++-- internal/repository/repository.go | 38 +++- .../repotest/application_test_cases.go | 62 +------ internal/repository/repotest/builders.go | 10 +- .../repository/repotest/claimer_test_cases.go | 120 +++++++++++++ .../repository/repotest/epoch_test_cases.go | 65 ++++--- .../repotest/node_config_test_cases.go | 70 ++++++++ .../repository/repotest/output_test_cases.go | 74 +++++--- 21 files changed, 828 insertions(+), 284 deletions(-) create mode 100644 internal/repository/node_config_test.go create mode 100644 internal/repository/postgres/epoch_publication_foreclosure_test.go create mode 100644 internal/repository/postgres/event_column_test.go diff --git a/internal/repository/node_config_test.go b/internal/repository/node_config_test.go new file mode 100644 index 000000000..4b3ba2c3b --- /dev/null +++ b/internal/repository/node_config_test.go @@ -0,0 +1,59 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type nodeConfigReadStub struct { + NodeConfigRepository + load func(context.Context, string) ([]byte, time.Time, time.Time, error) +} + +func (r nodeConfigReadStub) LoadNodeConfigRaw(ctx context.Context, key string) ([]byte, time.Time, time.Time, error) { + return r.load(ctx, key) +} + +func TestLoadNodeConfigResult(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + raw []byte + want string + }{ + {name: "nil payload", want: "has no JSON value"}, + {name: "empty payload", raw: []byte{}, want: "unmarshal node_config value failed"}, + {name: "invalid JSON", raw: []byte(`{`), want: "unmarshal node_config value failed"}, + {name: "valid payload", raw: []byte(`{"ChainID":42}`)}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + createdAt := time.Unix(1, 0) + updatedAt := createdAt.Add(time.Second) + repo := nodeConfigReadStub{load: func(ctx context.Context, key string) ([]byte, time.Time, time.Time, error) { + require.Equal(t, t.Context(), ctx) + require.Equal(t, "test-config", key) + return test.raw, createdAt, updatedAt, nil + }} + type testConfig struct{ ChainID uint64 } + got, err := LoadNodeConfig[testConfig](t.Context(), repo, "test-config") + if test.want != "" { + require.ErrorContains(t, err, test.want) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got, "a successful load must return a record") + require.Equal(t, "test-config", got.Key) + require.Equal(t, testConfig{ChainID: 42}, got.Value) + require.Equal(t, createdAt, got.CreatedAt) + require.Equal(t, updatedAt, got.UpdatedAt) + }) + } +} diff --git a/internal/repository/postgres/application.go b/internal/repository/postgres/application.go index da6d39bad..373299030 100644 --- a/internal/repository/postgres/application.go +++ b/internal/repository/postgres/application.go @@ -8,6 +8,7 @@ import ( "database/sql" "errors" "fmt" + "math" "github.com/ethereum/go-ethereum/common" "github.com/go-jet/jet/v2/postgres" @@ -40,7 +41,6 @@ func (r *PostgresRepository) CreateApplication( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.Enabled, table.Application.Status, @@ -73,7 +73,6 @@ func (r *PostgresRepository) CreateApplication( app.WithdrawalConfig.Log2MaxNumOfAccounts, app.WithdrawalConfig.AccountsDriveStartIndex, app.WithdrawalConfig.WithdrawalOutputBuilder, - app.DataAvailability, app.ConsensusType, app.Enabled, app.Status, @@ -187,7 +186,6 @@ func (r *PostgresRepository) GetApplication( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.Enabled, table.Application.Status, @@ -252,7 +250,6 @@ func (r *PostgresRepository) GetApplication( &app.WithdrawalConfig.Log2MaxNumOfAccounts, &app.WithdrawalConfig.AccountsDriveStartIndex, &app.WithdrawalConfig.WithdrawalOutputBuilder, - &app.DataAvailability, &app.ConsensusType, &app.Enabled, &app.Status, @@ -349,7 +346,6 @@ func (r *PostgresRepository) UpdateApplication( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.IinputboxBlock, ). @@ -367,7 +363,6 @@ func (r *PostgresRepository) UpdateApplication( app.WithdrawalConfig.Log2MaxNumOfAccounts, app.WithdrawalConfig.AccountsDriveStartIndex, app.WithdrawalConfig.WithdrawalOutputBuilder, - app.DataAvailability, app.ConsensusType, app.IInputBoxBlock, ). @@ -651,23 +646,15 @@ func getColumnForEvent(event model.MonitoredEvent) (postgres.ColumnFloat, error) return table.Application.LastInputCheckBlock, nil case model.MonitoredEvent_OutputExecuted: return table.Application.LastOutputCheckBlock, nil - case model.MonitoredEvent_CommitmentJoined: - fallthrough - case model.MonitoredEvent_MatchAdvanced: - fallthrough - case model.MonitoredEvent_MatchCreated: - fallthrough - case model.MonitoredEvent_MatchDeleted: - fallthrough - case model.MonitoredEvent_NewInnerTournament: + case model.MonitoredEvent_CommitmentJoined, model.MonitoredEvent_MatchAdvanced, + model.MonitoredEvent_MatchCreated, model.MonitoredEvent_MatchDeleted, model.MonitoredEvent_NewInnerTournament, + model.MonitoredEvent_LeafMatchSealed, model.MonitoredEvent_PartialBondRefund, model.MonitoredEvent_BondRecovered: return table.Application.LastTournamentCheckBlock, nil - case model.MonitoredEvent_ClaimSubmitted: - fallthrough - case model.MonitoredEvent_ClaimAccepted: - fallthrough - default: - return nil, fmt.Errorf("invalid monitored event type: %v", event) + case model.MonitoredEvent_ClaimSubmitted, model.MonitoredEvent_ClaimAccepted, + model.MonitoredEvent_Foreclosure, model.MonitoredEvent_Withdrawal, model.MonitoredEvent_AccountsDriveMerkleRootProved: + // These events use their own claim reconciliation or atomic marker writes. } + return nil, fmt.Errorf("invalid monitored event type: %v", event) } func (r *PostgresRepository) GetEventLastCheckBlock( @@ -823,6 +810,9 @@ func (r *PostgresRepository) ListApplications( p repository.Pagination, descending bool, ) ([]*model.Application, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } fromClause := table.Application.INNER_JOIN( table.ExecutionParameters, @@ -843,11 +833,6 @@ func (r *PostgresRepository) ListApplications( } conditions = append(conditions, table.Application.Status.IN(statuses...)) } - if f.DataAvailability != nil { - conditions = append(conditions, - SubstrBytea(table.Application.DataAvailability, 1, 4).EQ(postgres.Bytea(f.DataAvailability[:])), //nolint:mnd - ) - } if f.ConsensusType != nil { conditions = append(conditions, table.Application.ConsensusType.EQ(postgres.NewEnumValue(f.ConsensusType.String()))) } @@ -900,7 +885,6 @@ func (r *PostgresRepository) ListApplications( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.Enabled, table.Application.Status, @@ -981,7 +965,6 @@ func (r *PostgresRepository) ListApplications( &app.WithdrawalConfig.Log2MaxNumOfAccounts, &app.WithdrawalConfig.AccountsDriveStartIndex, &app.WithdrawalConfig.WithdrawalOutputBuilder, - &app.DataAvailability, &app.ConsensusType, &app.Enabled, &app.Status, diff --git a/internal/repository/postgres/bulk.go b/internal/repository/postgres/bulk.go index ea207894c..71f785ffa 100644 --- a/internal/repository/postgres/bulk.go +++ b/internal/repository/postgres/bulk.go @@ -682,7 +682,13 @@ func updateEpochClaim( return fmt.Errorf("SetEpochClaimAndInsertProofsTransaction failed: %w", err) } if cmd.RowsAffected() != 1 { - return fmt.Errorf("failed to update application %d epoch %d: no rows affected", e.ApplicationID, e.Index) + statusQuery := table.Epoch.SELECT(table.Epoch.Status).WHERE(postgres.AND( + table.Epoch.ApplicationID.EQ(postgres.Int64(e.ApplicationID)), + table.Epoch.Index.EQ(uint64Expr(e.Index)), + )) + statusSQL, statusArgs := statusQuery.Sql() + return fmt.Errorf("failed to update application %d epoch %d: %w", e.ApplicationID, e.Index, + classifyEpochPublicationMiss(tx.QueryRow(ctx, statusSQL, statusArgs...))) } return nil } diff --git a/internal/repository/postgres/claimer.go b/internal/repository/postgres/claimer.go index faebc34f1..5da7cb917 100644 --- a/internal/repository/postgres/claimer.go +++ b/internal/repository/postgres/claimer.go @@ -53,6 +53,10 @@ func (r *PostgresRepository) selectOldestClaimPerApp( table.Epoch.MachineHash, table.Epoch.TxBufferDataBlock, table.Epoch.TxBufferProof, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, table.Epoch.ClaimTransactionHash, table.Epoch.Status, table.Epoch.StagedAtBlock, @@ -74,7 +78,6 @@ func (r *PostgresRepository) selectOldestClaimPerApp( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.Enabled, table.Application.Status, @@ -127,6 +130,10 @@ func (r *PostgresRepository) selectOldestClaimPerApp( &epoch.MachineHash, &epoch.TxBufferDataBlock, &epoch.TxBufferProof, + &epoch.IflagsYDataBlock, + &epoch.IflagsYProof, + &epoch.HtifTohostDataBlock, + &epoch.HtifTohostProof, &epoch.ClaimTransactionHash, &epoch.Status, &epoch.StagedAtBlock, @@ -148,7 +155,6 @@ func (r *PostgresRepository) selectOldestClaimPerApp( &application.WithdrawalConfig.Log2MaxNumOfAccounts, &application.WithdrawalConfig.AccountsDriveStartIndex, &application.WithdrawalConfig.WithdrawalOutputBuilder, - &application.DataAvailability, &application.ConsensusType, &application.Enabled, &application.Status, @@ -191,10 +197,10 @@ func (r *PostgresRepository) selectNewestClaimBarrierPerApp( statusExprs = append(statusExprs, postgres.NewEnumValue(status.String())) } - // NOTE(mpolitzer): DISTINCT ON is a postgres extension. To implement - // this in SQLite there is an alternative using GROUP BY and HAVING - // clauses instead. - stmt := table.Epoch.SELECT( + // Select one barrier per eligible application through the epoch primary + // key. Sorting every historical accepted claim makes this query grow with + // the application's full history instead of the number of applications. + barrier := postgres.LATERAL(table.Epoch.SELECT( table.Epoch.ApplicationID, table.Epoch.Index, table.Epoch.FirstBlock, @@ -202,31 +208,25 @@ func (r *PostgresRepository) selectNewestClaimBarrierPerApp( table.Epoch.MachineHash, table.Epoch.TxBufferDataBlock, table.Epoch.TxBufferProof, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, table.Epoch.ClaimTransactionHash, table.Epoch.Status, table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt, - ). - DISTINCT(table.Epoch.ApplicationID). - FROM( - table.Epoch. - INNER_JOIN( - table.Application, - table.Epoch.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - table.Epoch.Status.IN(statusExprs...). - AND(table.Application.Enabled.EQ(postgres.Bool(true))). - AND(claimableOrForeclosedApplication()). - AND(table.Application.ConsensusType.NOT_EQ(enum.Consensus.Prt)), - ). - ORDER_BY( - table.Epoch.ApplicationID, - table.Epoch.Index.DESC(), - ) + ).WHERE( + table.Epoch.ApplicationID.EQ(table.Application.ID). + AND(table.Epoch.Status.IN(statusExprs...)), + ).ORDER_BY(table.Epoch.Index.DESC()).LIMIT(1)).AS("barrier") + stmt := postgres.SELECT(barrier.AllColumns()). + FROM(table.Application.CROSS_JOIN(barrier)). + WHERE(table.Application.Enabled.EQ(postgres.Bool(true)). + AND(claimableOrForeclosedApplication()). + AND(table.Application.ConsensusType.NOT_EQ(enum.Consensus.Prt))) sqlStr, args := stmt.Sql() rows, err := tx.Query(ctx, sqlStr, args...) @@ -246,6 +246,10 @@ func (r *PostgresRepository) selectNewestClaimBarrierPerApp( &epoch.MachineHash, &epoch.TxBufferDataBlock, &epoch.TxBufferProof, + &epoch.IflagsYDataBlock, + &epoch.IflagsYProof, + &epoch.HtifTohostDataBlock, + &epoch.HtifTohostProof, &epoch.ClaimTransactionHash, &epoch.Status, &epoch.StagedAtBlock, @@ -374,10 +378,10 @@ func (r *PostgresRepository) UpdateEpochWithSubmittedClaim( // source state may be CLAIM_SUBMITTED, CLAIM_STAGED, or CLAIM_COMPUTED — the // trigger enforces validity per the v3 state machine: // -// - CLAIM_STAGED → CLAIM_ACCEPTED is the normal v3 path (after the staging -// period elapses and acceptClaim is called). -// - CLAIM_COMPUTED → CLAIM_ACCEPTED is the deep reader-mode catch-up path -// (also PRT's terminal transition; the trigger forbids PRT from STAGED). +// - CLAIM_STAGED → CLAIM_ACCEPTED is the normal v3 path after the staging +// period elapses and the consensus accepts the staged result. +// - CLAIM_COMPUTED → CLAIM_ACCEPTED is the deep reader-mode catch-up path, +// including a PRT node that first observes an already accepted result. // - CLAIM_SUBMITTED → CLAIM_ACCEPTED is permitted by the trigger but not // reached by the v3 happy path. Kept for resilience. // @@ -455,14 +459,7 @@ func (r *PostgresRepository) UpdateEpochWithForeclosedClaim( WHERE( table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). AND(table.Epoch.Index.EQ(uint64Expr(index))). - AND(table.Epoch.Status.IN( - postgres.NewEnumValue(model.EpochStatus_Open.String()), - postgres.NewEnumValue(model.EpochStatus_Closed.String()), - postgres.NewEnumValue(model.EpochStatus_InputsProcessed.String()), - postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()), - postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()), - postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), - )). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions()...)). AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). AND(table.Application.ForecloseBlock.GT(uint64Expr(0))), ) @@ -698,8 +695,9 @@ func (r *PostgresRepository) UpdateEpochThroughStaging( // UpdateEpochReconciledStaged transitions an epoch from CLAIM_COMPUTED to // CLAIM_STAGED without setting a claim_transaction_hash. Used by the -// pre-submit reconciliation path when getClaim() reveals the chain has -// already staged our claim (e.g., across a restart or in reader mode). +// chain reconciliation path when the chain has already staged the local +// result. Authority/Quorum and PRT can use this path after a restart or in +// reader mode. func (r *PostgresRepository) UpdateEpochReconciledStaged( ctx context.Context, applicationID int64, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/application.go b/internal/repository/postgres/db/rollupsdb/public/table/application.go index 8aa5aa118..34217d390 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/application.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/application.go @@ -32,7 +32,6 @@ type applicationTable struct { WithdrawalLog2MaxNumOfAccounts postgres.ColumnInteger WithdrawalAccountsDriveStartIndex postgres.ColumnFloat WithdrawalOutputBuilder postgres.ColumnBytea - DataAvailability postgres.ColumnBytea ConsensusType postgres.ColumnString Enabled postgres.ColumnBool Status postgres.ColumnString @@ -108,7 +107,6 @@ func newApplicationTableImpl(schemaName, tableName, alias string) applicationTab WithdrawalLog2MaxNumOfAccountsColumn = postgres.IntegerColumn("withdrawal_log2_max_num_of_accounts") WithdrawalAccountsDriveStartIndexColumn = postgres.FloatColumn("withdrawal_accounts_drive_start_index") WithdrawalOutputBuilderColumn = postgres.ByteaColumn("withdrawal_output_builder") - DataAvailabilityColumn = postgres.ByteaColumn("data_availability") ConsensusTypeColumn = postgres.StringColumn("consensus_type") EnabledColumn = postgres.BoolColumn("enabled") StatusColumn = postgres.StringColumn("status") @@ -128,8 +126,8 @@ func newApplicationTableImpl(schemaName, tableName, alias string) applicationTab AccountsDriveMerkleRootColumn = postgres.ByteaColumn("accounts_drive_merkle_root") CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{IDColumn, NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, DataAvailabilityColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, DataAvailabilityColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} + allColumns = postgres.ColumnList{IDColumn, NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} defaultColumns = postgres.ColumnList{ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, EnabledColumn, StatusColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ForecloseBlockColumn, AccountsDriveProvedBlockColumn, CreatedAtColumn, UpdatedAtColumn} ) @@ -152,7 +150,6 @@ func newApplicationTableImpl(schemaName, tableName, alias string) applicationTab WithdrawalLog2MaxNumOfAccounts: WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndex: WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilder: WithdrawalOutputBuilderColumn, - DataAvailability: DataAvailabilityColumn, ConsensusType: ConsensusTypeColumn, Enabled: EnabledColumn, Status: StatusColumn, diff --git a/internal/repository/postgres/epoch.go b/internal/repository/postgres/epoch.go index 6cb5da893..d176fd098 100644 --- a/internal/repository/postgres/epoch.go +++ b/internal/repository/postgres/epoch.go @@ -384,11 +384,6 @@ func (r *PostgresRepository) HasUndrainedEpochsBeforeBlock( appID int64, blockBound uint64, ) (bool, error) { - terminalStatuses := []postgres.Expression{ - enum.EpochStatus.ClaimAccepted, - enum.EpochStatus.ClaimRejected, - enum.EpochStatus.ClaimForeclosed, - } stmt := table.Input. SELECT(table.Input.Index). FROM( @@ -401,7 +396,7 @@ func (r *PostgresRepository) HasUndrainedEpochsBeforeBlock( table.Input.EpochApplicationID.EQ(postgres.Int(appID)). AND(table.Input.BlockNumber.LT_EQ(uint64Expr(blockBound))). AND(table.Input.Status.EQ(enum.InputCompletionStatus.None)). - AND(table.Epoch.Status.NOT_IN(terminalStatuses...)), + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions()...)), ). LIMIT(1) @@ -423,14 +418,6 @@ func (r *PostgresRepository) ForecloseUnacceptedEpochsAtOrAfterBlock( appID int64, blockBound uint64, ) (int64, error) { - statuses := []postgres.Expression{ - enum.EpochStatus.Open, - enum.EpochStatus.Closed, - enum.EpochStatus.InputsProcessed, - enum.EpochStatus.ClaimComputed, - enum.EpochStatus.ClaimSubmitted, - enum.EpochStatus.ClaimStaged, - } updateStmt := table.Epoch. UPDATE(table.Epoch.Status). SET(enum.EpochStatus.ClaimForeclosed). @@ -439,7 +426,7 @@ func (r *PostgresRepository) ForecloseUnacceptedEpochsAtOrAfterBlock( table.Epoch.ApplicationID.EQ(postgres.Int64(appID)). AND(table.Epoch.FirstBlock.LT_EQ(uint64Expr(blockBound))). AND(table.Epoch.LastBlock.GT_EQ(uint64Expr(blockBound))). - AND(table.Epoch.Status.IN(statuses...)). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions()...)). AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). AND(table.Application.ForecloseBlock.GT(uint64Expr(0))). AND(table.Application.ConsensusType.NOT_EQ(enum.Consensus.Prt)), @@ -469,38 +456,17 @@ func (r *PostgresRepository) ForecloseUnacceptedEpochsAtOrAfterBlock( // valid same-block input that executed before Foreclosure. Authority/Quorum // never creates empty epoch rows, so `first_block <= blockBound` does not // introduce false positives. -// unreconciledEpochStatuses are the epoch statuses that still need claim work — -// every EpochStatus except the terminal CLAIM_ACCEPTED / CLAIM_REJECTED / -// CLAIM_FORECLOSED. It drives HasUnreconciledClaimsBeforeBlock's filter and -// MUST stay in sync with the partial-index predicate of "epoch_unreconciled_idx" -// in 000001_create_initial_schema.up.sql; if the two drift, the query silently -// stops matching the index and falls back to a full table scan. -// TestUnreconciledEpochStatusesAreNonTerminal guards this set when a new -// EpochStatus is added. -var unreconciledEpochStatuses = []model.EpochStatus{ - model.EpochStatus_Open, - model.EpochStatus_Closed, - model.EpochStatus_InputsProcessed, - model.EpochStatus_ClaimComputed, - model.EpochStatus_ClaimSubmitted, - model.EpochStatus_ClaimStaged, -} - func (r *PostgresRepository) HasUnreconciledClaimsBeforeBlock( ctx context.Context, appID int64, blockBound uint64, ) (bool, error) { - statuses := make([]postgres.Expression, len(unreconciledEpochStatuses)) - for i, s := range unreconciledEpochStatuses { - statuses[i] = postgres.NewEnumValue(string(s)) - } stmt := table.Epoch. SELECT(table.Epoch.Index). WHERE( table.Epoch.ApplicationID.EQ(postgres.Int(appID)). AND(table.Epoch.FirstBlock.LT_EQ(uint64Expr(blockBound))). - AND(table.Epoch.Status.IN(statuses...)), + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions()...)), ). LIMIT(1) @@ -513,6 +479,15 @@ func (r *PostgresRepository) HasUnreconciledClaimsBeforeBlock( return rows.Next(), rows.Err() } +func nonTerminalEpochStatusExpressions() []postgres.Expression { + statuses := model.NonTerminalEpochStatuses() + expressions := make([]postgres.Expression, len(statuses)) + for i, status := range statuses { + expressions[i] = postgres.NewEnumValue(status.String()) + } + return expressions +} + func (r *PostgresRepository) GetLastAcceptedEpochIndex( ctx context.Context, nameOrAddress string, @@ -876,7 +851,11 @@ func (r *PostgresRepository) UpdateEpochInputsProcessed( err := r.db.QueryRow(ctx, sqlStr, args...).Scan(&index) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return repository.ErrNoUpdate + statusQuery := table.Epoch.SELECT(table.Epoch.Status).FROM( + table.Epoch.INNER_JOIN(table.Application, table.Epoch.ApplicationID.EQ(table.Application.ID)), + ).WHERE(postgres.AND(whereClause, table.Epoch.Index.EQ(uint64Expr(epochIndex)))) + statusSQL, statusArgs := statusQuery.Sql() + return classifyEpochPublicationMiss(r.db.QueryRow(ctx, statusSQL, statusArgs...)) } return err } @@ -887,6 +866,23 @@ func (r *PostgresRepository) UpdateEpochInputsProcessed( return nil } +// classifyEpochPublicationMiss distinguishes a completed foreclosure from a +// missing row or another rejected state transition. The caller must select the +// status with the same application and epoch identity as the rejected update. +func classifyEpochPublicationMiss(row pgx.Row) error { + var status string + if err := row.Scan(&status); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNoUpdate + } + return fmt.Errorf("reading epoch status after rejected publication: %w", err) + } + if status == model.EpochStatus_ClaimForeclosed.String() { + return repository.ErrEpochForeclosed + } + return repository.ErrNoUpdate +} + func (r *PostgresRepository) ListEpochs( ctx context.Context, nameOrAddress string, diff --git a/internal/repository/postgres/epoch_publication_foreclosure_test.go b/internal/repository/postgres/epoch_publication_foreclosure_test.go new file mode 100644 index 000000000..dc9782b0f --- /dev/null +++ b/internal/repository/postgres/epoch_publication_foreclosure_test.go @@ -0,0 +1,162 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +func newEpochPublicationRepository(t *testing.T) repository.Repository { + t.Helper() + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + return repo +} + +func seedEpochPublication(t *testing.T, repo repository.Repository) (*Application, *Epoch) { + t.Helper() + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, repo) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 1).Build() + input := repotest.NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + require.NoError(t, repo.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {input}}, 10)) + require.NoError(t, repo.StoreAdvanceResult(t.Context(), app.ID, &AdvanceResult{ + EpochIndex: epoch.Index, InputIndex: input.Index, Status: InputCompletionStatus_Accepted, + StateProof: *repotest.DummyStateProof(), Outputs: [][]byte{[]byte("output")}, + })) + return app, epoch +} + +func TestEpochPublicationLosesToForeclosureWithoutWrites(t *testing.T) { + for _, claimPublication := range []bool{false, true} { + name := "closed state proof" + if claimPublication { + name = "inputs processed claim" + } + t.Run(name, func(t *testing.T) { + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + if claimPublication { + require.NoError(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + } + // The publisher holds this snapshot before PRT wins the terminal transition. + stale, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NoError(t, repo.UpdateApplicationForeclosure(t.Context(), app.ID, 100, repotest.UniqueHash(), 100)) + require.NoError(t, repo.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index)) + before, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + outputsBefore, _, err := repo.ListOutputs(t.Context(), app.Name, repository.OutputFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Len(t, outputsBefore, 1) + + if claimPublication { + stale.Commitment = new(repotest.UniqueHash()) + stale.CommitmentProof = make([]common.Hash, Log2EpochComputationHashLeafCount) + output := *outputsBefore[0] + output.Hash = new(repotest.UniqueHash()) + output.OutputHashesSiblings = []common.Hash{repotest.UniqueHash()} + err = repo.StoreClaimAndProofs(t.Context(), stale, []*Output{&output}) + } else { + proof := repotest.DummyStateProof() + proof.MachineHash = repotest.UniqueHash() + proof.TxBufferDataBlock = repotest.UniqueHash() + err = repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof) + } + require.ErrorIs(t, err, repository.ErrEpochForeclosed) + after, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, before, after, "a rejected publication must preserve status, proof fields, and timestamps") + outputsAfter, _, err := repo.ListOutputs(t.Context(), app.Name, repository.OutputFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, outputsBefore, outputsAfter, "claim rejection must occur before any output-proof write") + require.ErrorIs(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, &StateProof{}), + repository.ErrInvalidStateProof, "foreclosure must not bypass proof completeness checks") + }) + } +} + +func TestEpochPublicationDoesNotMisclassifyOtherFailures(t *testing.T) { + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + foreclosedApp, foreclosedEpoch := seedEpochPublication(t, repo) + require.NoError(t, repo.UpdateApplicationForeclosure(t.Context(), foreclosedApp.ID, 100, repotest.UniqueHash(), 100)) + require.NoError(t, repo.UpdateEpochWithForeclosedClaim(t.Context(), foreclosedApp.ID, foreclosedEpoch.Index)) + for _, test := range []struct { + name string + appID int64 + appName string + epoch uint64 + proofOnly bool + }{ + {name: "claim closed epoch", appID: app.ID, epoch: epoch.Index}, + {name: "claim missing epoch", appID: foreclosedApp.ID, epoch: 99}, + {name: "claim missing application", appID: -1, epoch: epoch.Index}, + {name: "proof missing epoch", appName: foreclosedApp.Name, epoch: 99, proofOnly: true}, + {name: "proof missing application", appName: "missing", epoch: epoch.Index, proofOnly: true}, + } { + t.Run(test.name, func(t *testing.T) { + var err error + if test.proofOnly { + err = repo.UpdateEpochInputsProcessed(t.Context(), test.appName, test.epoch, repotest.DummyStateProof()) + } else { + err = repo.StoreClaimAndProofs(t.Context(), &Epoch{ApplicationID: test.appID, Index: test.epoch}, nil) + } + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + }) + } + + // Normal publication still works in the presence of another foreclosed app. + require.NoError(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + err := repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof()) + require.ErrorIs(t, err, repository.ErrNoUpdate, "an already published proof is not foreclosure") + computed, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + computed.Commitment, computed.CommitmentProof = new(repotest.UniqueHash()), make([]common.Hash, Log2EpochComputationHashLeafCount) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), computed, nil)) + stored, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, EpochStatus_ClaimComputed, stored.Status) + require.Equal(t, computed.Commitment, stored.Commitment) + require.Equal(t, computed.CommitmentProof, stored.CommitmentProof) + + canceled, cancel := context.WithCancel(t.Context()) + cancel() + for _, err := range []error{ + repo.UpdateEpochInputsProcessed(canceled, app.Name, epoch.Index, repotest.DummyStateProof()), + repo.StoreClaimAndProofs(canceled, computed, nil), + } { + require.ErrorIs(t, err, context.Canceled) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + } +} + +type failedPublicationStatusRow struct{ err error } + +func (r failedPublicationStatusRow) Scan(...any) error { return r.err } + +func TestEpochPublicationStatusReadFailure(t *testing.T) { + failure := errors.New("status read failed") + err := classifyEpochPublicationMiss(failedPublicationStatusRow{err: failure}) + require.ErrorIs(t, err, failure) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + require.ErrorIs(t, classifyEpochPublicationMiss(failedPublicationStatusRow{err: pgx.ErrNoRows}), repository.ErrNoUpdate) +} diff --git a/internal/repository/postgres/epoch_unreconciled_test.go b/internal/repository/postgres/epoch_unreconciled_test.go index 448517a36..111dd5895 100644 --- a/internal/repository/postgres/epoch_unreconciled_test.go +++ b/internal/repository/postgres/epoch_unreconciled_test.go @@ -4,33 +4,104 @@ package postgres import ( + "fmt" + "slices" "testing" - "github.com/cartesi/rollups-node/internal/model" - "github.com/stretchr/testify/assert" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" ) -// TestUnreconciledEpochStatusesAreNonTerminal pins unreconciledEpochStatuses to -// exactly the non-terminal epoch statuses. HasUnreconciledClaimsBeforeBlock and -// the "epoch_unreconciled_idx" partial index share this set; if a new -// EpochStatus is added and not classified here, the index predicate and the -// query would silently drift and the query would fall back to a full scan. -func TestUnreconciledEpochStatusesAreNonTerminal(t *testing.T) { - terminal := map[model.EpochStatus]bool{ - model.EpochStatus_ClaimAccepted: true, - model.EpochStatus_ClaimRejected: true, - model.EpochStatus_ClaimForeclosed: true, +func TestUnreconciledIndexMatchesNonTerminalEpochStatuses(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + var predicate string + err := r.db.QueryRow(t.Context(), ` + SELECT pg_get_expr(indpred, indrelid) FROM pg_index + WHERE indexrelid = 'epoch_unreconciled_idx'::regclass`).Scan(&predicate) + require.NoError(t, err) + require.NotEmpty(t, predicate) + for _, status := range EpochStatusAllValues { + // Evaluate the actual migrated predicate, not a copied status list. + query := fmt.Sprintf(`SELECT %s FROM (SELECT $1::"EpochStatus" AS status) AS epoch`, predicate) + var indexed bool + require.NoError(t, r.db.QueryRow(t.Context(), query, status.String()).Scan(&indexed)) + require.Equal(t, slices.Contains(NonTerminalEpochStatuses(), status), indexed, status) } +} - var want []model.EpochStatus - for _, s := range model.EpochStatusAllValues { - if !terminal[s] { - want = append(want, s) +func TestForeclosureQueriesUseNonTerminalEpochStatuses(t *testing.T) { + r := newEpochPublicationRepository(t) + for _, action := range []struct { + name string + consensus Consensus + bulk bool + }{ + {name: "single Authority", consensus: Consensus_Authority}, + {name: "single PRT", consensus: Consensus_PRT}, + {name: "bulk Authority", consensus: Consensus_Authority, bulk: true}, + {name: "bulk Quorum", consensus: Consensus_Quorum, bulk: true}, + {name: "bulk excludes PRT", consensus: Consensus_PRT, bulk: true}, + } { + for _, status := range EpochStatusAllValues { + t.Run(action.name+"/"+status.String(), func(t *testing.T) { + app, epoch := seedForeclosureStatus(t, r, action.consensus, status) + nonTerminal := slices.Contains(NonTerminalEpochStatuses(), status) + pending, err := r.HasUnreconciledClaimsBeforeBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + require.Equal(t, nonTerminal, pending) + canForeclose := nonTerminal && (!action.bulk || action.consensus != Consensus_PRT) + if action.bulk { + count, err := r.ForecloseUnacceptedEpochsAtOrAfterBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + if canForeclose { + require.Equal(t, int64(1), count) + } else { + require.Zero(t, count) + } + } else { + err := r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index) + if canForeclose { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, repository.ErrNoUpdate) + } + } + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + wantStatus := status + if canForeclose { + wantStatus = EpochStatus_ClaimForeclosed + } + require.Equal(t, wantStatus, stored.Status) + pending, err = r.HasUnreconciledClaimsBeforeBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + require.Equal(t, nonTerminal && !canForeclose, pending) + }) } } +} - assert.ElementsMatch(t, want, unreconciledEpochStatuses, - "unreconciledEpochStatuses must be exactly the non-terminal epoch statuses. "+ - "If you added an EpochStatus, classify it here AND in the epoch_unreconciled_idx "+ - "predicate in 000001_create_initial_schema.up.sql.") +func seedForeclosureStatus(t *testing.T, r repository.Repository, consensus Consensus, status EpochStatus) (*Application, *Epoch) { + t.Helper() + app := repotest.NewApplicationBuilder().WithConsensus(consensus).Create(t.Context(), t, r) + initial := EpochStatus_Closed + if status == EpochStatus_Open { + initial = status + } + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(initial).WithBlocks(0, 9).WithInputBounds(0, 0).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, 10)) + if status == EpochStatus_ClaimStaged { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, EpochStatus_ClaimSubmitted) + require.NoError(t, r.UpdateEpochToStaged(t.Context(), app.ID, epoch.Index, 4)) + } else if status != initial && status != EpochStatus_ClaimForeclosed { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, status) + } + app.ForecloseBlock = 5 + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, app.ForecloseBlock, repotest.UniqueHash(), 10)) + if status == EpochStatus_ClaimForeclosed { + require.NoError(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index)) + } + return app, epoch } diff --git a/internal/repository/postgres/event_column_test.go b/internal/repository/postgres/event_column_test.go new file mode 100644 index 000000000..7942e1efb --- /dev/null +++ b/internal/repository/postgres/event_column_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +func TestGetColumnForEvent(t *testing.T) { + for _, test := range []struct { + event MonitoredEvent + column postgres.ColumnFloat + }{ + {MonitoredEvent_EpochSealed, table.Application.LastEpochCheckBlock}, + {MonitoredEvent_InputAdded, table.Application.LastInputCheckBlock}, + {MonitoredEvent_OutputExecuted, table.Application.LastOutputCheckBlock}, + {MonitoredEvent_CommitmentJoined, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchAdvanced, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchCreated, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchDeleted, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_NewInnerTournament, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_LeafMatchSealed, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_PartialBondRefund, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_BondRecovered, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_ClaimSubmitted, nil}, + {MonitoredEvent_ClaimAccepted, nil}, + {MonitoredEvent_Foreclosure, nil}, + {MonitoredEvent_Withdrawal, nil}, + {MonitoredEvent_AccountsDriveMerkleRootProved, nil}, + {MonitoredEvent("unknown"), nil}, + } { + t.Run(test.event.String(), func(t *testing.T) { + column, err := getColumnForEvent(test.event) + if test.column == nil { + require.EqualError(t, err, "invalid monitored event type: "+test.event.String()) + require.Nil(t, column) + } else { + require.NoError(t, err) + require.Equal(t, test.column, column) + } + }) + } +} diff --git a/internal/repository/postgres/node_config.go b/internal/repository/postgres/node_config.go index a3c303224..93d92df88 100644 --- a/internal/repository/postgres/node_config.go +++ b/internal/repository/postgres/node_config.go @@ -15,6 +15,16 @@ import ( "github.com/go-jet/jet/v2/postgres" ) +func (r *PostgresRepository) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + stmt := table.NodeConfig. + INSERT(table.NodeConfig.Key, table.NodeConfig.Value). + VALUES(key, postgres.Json(rawJSON)). + ON_CONFLICT(table.NodeConfig.Key).DO_NOTHING() + sqlStr, args := stmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + func (r *PostgresRepository) SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { insertStmt := table.NodeConfig. diff --git a/internal/repository/postgres/output.go b/internal/repository/postgres/output.go index b2c2dc6f1..d80f591d7 100644 --- a/internal/repository/postgres/output.go +++ b/internal/repository/postgres/output.go @@ -157,7 +157,7 @@ func (r *PostgresRepository) UpdateOutputsExecution( } } - // Update last claim check block + // Advance the output scan cursor without moving it backwards. appUpdateStmt := table.Application. UPDATE( table.Application.LastOutputCheckBlock, @@ -165,7 +165,9 @@ func (r *PostgresRepository) UpdateOutputsExecution( SET( uint64Expr(lastOutputCheckBlock), ). - WHERE(whereClause) + WHERE( + whereClause.AND(table.Application.LastOutputCheckBlock.LT(uint64Expr(lastOutputCheckBlock))), + ) sqlStr, args := appUpdateStmt.Sql() _, err = tx.Exec(ctx, sqlStr, args...) diff --git a/internal/repository/postgres/postgres_repo_test.go b/internal/repository/postgres/postgres_repo_test.go index a182beae6..e7badb604 100644 --- a/internal/repository/postgres/postgres_repo_test.go +++ b/internal/repository/postgres/postgres_repo_test.go @@ -53,6 +53,10 @@ func TestPostgresRepository(t *testing.T) { } func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { + const ( + advanceMaxCyclesColumn = "advance_max_cycles" + inspectMaxCyclesColumn = "inspect_max_cycles" + ) endpoint, err := db.GetTestDatabaseEndpoint() if err != nil { t.Skipf("Skipping: %v", err) @@ -103,6 +107,21 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { "UNEXPECTED_YIELD", }, labels) + rows, err = conn.Query(ctx, ` + SELECT enumlabel + FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + WHERE pg_type.typname = 'EpochStatus' + ORDER BY enumsortorder`) + require.NoError(t, err) + labels, err = pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + expectedEpochLabels := make([]string, len(model.EpochStatusAllValues)) + for i, status := range model.EpochStatusAllValues { + expectedEpochLabels[i] = status.String() + } + require.Equal(t, expectedEpochLabels, labels) + rows, err = conn.Query(ctx, ` SELECT column_name FROM information_schema.columns @@ -110,8 +129,8 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { require.NoError(t, err) columns, err := pgx.CollectRows(rows, pgx.RowTo[string]) require.NoError(t, err) - require.Contains(t, columns, "advance_max_cycles") - require.Contains(t, columns, "inspect_max_cycles") + require.Contains(t, columns, advanceMaxCyclesColumn) + require.Contains(t, columns, inspectMaxCyclesColumn) require.Contains(t, columns, "advance_inc_cycles") require.Contains(t, columns, "inspect_inc_cycles") @@ -131,7 +150,7 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { require.Contains(t, epochColumns, column) } - for _, column := range []string{"advance_max_cycles", "inspect_max_cycles"} { + for _, column := range []string{advanceMaxCyclesColumn, inspectMaxCyclesColumn} { var defaultValue string err := conn.QueryRow(ctx, ` SELECT column_default @@ -146,7 +165,7 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { require.NoError(t, err) t.Cleanup(repo.Close) app := repotest.NewApplicationBuilder().Create(ctx, t, repo) - for _, column := range []string{"advance_max_cycles", "inspect_max_cycles"} { + for _, column := range []string{advanceMaxCyclesColumn, inspectMaxCyclesColumn} { for _, value := range []int64{0, int64(model.MaxExecutionCycleSpan)} { _, err := conn.Exec(ctx, fmt.Sprintf( `UPDATE execution_parameters SET %s = $1 WHERE application_id = $2`, @@ -161,10 +180,10 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { }{ {"advance_inc_cycles", 0}, {"inspect_inc_cycles", 0}, - {"advance_max_cycles", -1}, - {"inspect_max_cycles", -1}, - {"advance_max_cycles", int64(model.MaxExecutionCycles)}, - {"inspect_max_cycles", int64(model.MaxExecutionCycles)}, + {advanceMaxCyclesColumn, -1}, + {inspectMaxCyclesColumn, -1}, + {advanceMaxCyclesColumn, int64(model.MaxExecutionCycles)}, + {inspectMaxCyclesColumn, int64(model.MaxExecutionCycles)}, } { _, err := conn.Exec(ctx, fmt.Sprintf( `UPDATE execution_parameters SET %s = $1 WHERE application_id = $2`, diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql index 553bd3ecf..eeb281cb3 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql @@ -68,7 +68,6 @@ DROP TABLE IF EXISTS "execution_parameters"; DROP TRIGGER IF EXISTS "application_validate_status_transition" ON "application"; DROP TRIGGER IF EXISTS "application_set_updated_at" ON "application"; -DROP INDEX IF EXISTS "application_data_availability_selector_idx"; DROP INDEX IF EXISTS "application_foreclosed_idx"; DROP TABLE IF EXISTS "application"; @@ -84,7 +83,6 @@ DROP TYPE IF EXISTS "EpochStatus"; DROP TYPE IF EXISTS "DefaultBlock"; DROP TYPE IF EXISTS "InputCompletionStatus"; DROP TYPE IF EXISTS "ApplicationStatus"; -DROP DOMAIN IF EXISTS "data_availability"; DROP DOMAIN IF EXISTS "hash"; DROP DOMAIN IF EXISTS "uint64"; DROP DOMAIN IF EXISTS "ethereum_address"; diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql index ef342341f..edfc73c2a 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql @@ -6,7 +6,6 @@ BEGIN; CREATE DOMAIN "ethereum_address" AS BYTEA CHECK (octet_length(VALUE) = 20); CREATE DOMAIN "uint64" AS NUMERIC(20, 0) CHECK (VALUE >= 0 AND VALUE <= 18446744073709551615); CREATE DOMAIN "hash" AS BYTEA CHECK (octet_length(VALUE) = 32); -CREATE DOMAIN "data_availability" AS BYTEA CHECK (octet_length(VALUE) >= 4); CREATE TYPE "ApplicationStatus" AS ENUM ( 'OK', @@ -106,7 +105,6 @@ CREATE TABLE "application" "withdrawal_log2_max_num_of_accounts" SMALLINT NOT NULL DEFAULT 0 CHECK ("withdrawal_log2_max_num_of_accounts" BETWEEN 0 AND 255), "withdrawal_accounts_drive_start_index" uint64 NOT NULL DEFAULT 0, "withdrawal_output_builder" ethereum_address NOT NULL DEFAULT '\x0000000000000000000000000000000000000000', - "data_availability" data_availability NOT NULL, "consensus_type" "Consensus" NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT true, "status" "ApplicationStatus" NOT NULL DEFAULT 'OK', @@ -161,7 +159,6 @@ CREATE TABLE "application" CONSTRAINT "application_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "application_data_availability_selector_idx" ON "application"(substring("data_availability" FROM 1 for 4)); -- Supports ListApplications(ForeclosureRecorded = true), used by the claimer's -- listEnabledForeclosedNonPRTApps once per tick. The filtered set is small -- (foreclosed apps), so a partial index keyed on foreclose_block > 0 keeps @@ -350,8 +347,8 @@ CREATE INDEX "epoch_status_idx" ON "epoch"("application_id", "status"); -- scan an index-only lookup; the bare epoch_status_idx covers the status -- filter but adds a per-row comparison on first_block. -- --- The status list below MUST match unreconciledEpochStatuses in --- internal/repository/postgres/epoch.go. If they drift, the query stops +-- The status list below MUST match NonTerminalEpochStatuses in +-- internal/model/models.go. If they drift, the query stops -- matching this partial index and silently degrades to a full table scan. CREATE INDEX "epoch_unreconciled_idx" ON "epoch"("application_id", "first_block") WHERE "status" IN ('OPEN','CLOSED','INPUTS_PROCESSED', @@ -364,8 +361,8 @@ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- The state machine is: -- OPEN → CLOSED → INPUTS_PROCESSED → CLAIM_COMPUTED -- CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_STAGED → CLAIM_ACCEPTED (v3 normal) --- CLAIM_COMPUTED → CLAIM_STAGED (restart recovery: chain at STAGED before we submitted) --- CLAIM_COMPUTED → CLAIM_ACCEPTED (PRT skips SUBMITTED; also valid in deep reader-mode catch-up) +-- CLAIM_COMPUTED → CLAIM_STAGED (chain already staged, including Dave/PRT) +-- CLAIM_COMPUTED → CLAIM_ACCEPTED (deep reader-mode catch-up, including PRT) -- CLAIM_COMPUTED → CLAIM_REJECTED (conflicting Quorum claim staged/accepted before we submitted) -- CLAIM_SUBMITTED → CLAIM_REJECTED (we submitted, then a different Quorum claim was staged/accepted) -- OPEN → CLAIM_FORECLOSED (guardian foreclosed before epoch could finish) @@ -383,9 +380,9 @@ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- transitioning to CLAIM_COMPUTED, this trigger additionally requires the -- PRT commitment and commitment proof. -- --- CLAIM_STAGED is NEVER valid for PRT apps (PRT settles via tournaments, --- not the staging flow). The trigger rejects this regardless of which --- transition led to it. +-- Dave/PRT also uses CLAIM_STAGED while its tournament result waits for +-- sentry agreement or the claim-staging period. Like Authority/Quorum, every +-- CLAIM_STAGED row must record the corresponding on-chain staging block. CREATE FUNCTION enforce_epoch_status_transition() RETURNS trigger AS $$ DECLARE valid_transitions text[][] := ARRAY[ @@ -441,25 +438,14 @@ BEGIN END IF; END IF; - -- Enforce CLAIM_STAGED is never valid for PRT consensus, and that - -- staged_at_block is set when entering CLAIM_STAGED. The - -- staged_requires_block table CHECK constraint also enforces the latter; - -- this trigger gives a clearer error message on the state-machine path. + -- staged_at_block is required when entering CLAIM_STAGED. The + -- staged_requires_block table CHECK constraint also enforces this; the + -- trigger gives a clearer error message on the state-machine path. IF NEW.status::text = 'CLAIM_STAGED' THEN IF NEW.staged_at_block IS NULL THEN RAISE EXCEPTION 'CLAIM_STAGED requires staged_at_block to be non-null'; END IF; - - SELECT a.consensus_type::text INTO app_consensus - FROM application a - WHERE a.id = NEW.application_id; - - IF app_consensus = 'PRT' THEN - RAISE EXCEPTION - 'CLAIM_STAGED is not valid for PRT consensus ' - '(PRT settles via tournaments, not the staging flow)'; - END IF; END IF; RETURN NEW; @@ -493,6 +479,9 @@ CREATE TABLE "input" ("status" <> 'EXCEPTION' AND "exception_data" IS NULL) ), CONSTRAINT "input_pkey" PRIMARY KEY ("epoch_application_id", "index"), + -- The state_hashes foreign key references this exact three-column key + -- to verify that the input belongs to the stated epoch. A plain index + -- cannot replace the unique key required by that foreign key. CONSTRAINT "input_epoch_index_unique" UNIQUE ("epoch_application_id", "epoch_index", "index"), CONSTRAINT "input_application_id_tx_hash_log_index_unique" UNIQUE ("epoch_application_id", "transaction_hash", "log_index"), CONSTRAINT "input_completed_hashes_check" CHECK ( diff --git a/internal/repository/repository.go b/internal/repository/repository.go index af649162f..970a7e5f0 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -17,6 +17,9 @@ import ( var ( ErrNotFound = errors.New("not found") ErrNoUpdate = errors.New("update did not take effect") + // ErrEpochForeclosed means a stale epoch publication lost to foreclosure. + // The stored epoch is CLAIM_FORECLOSED; no new claim or proof was stored. + ErrEpochForeclosed = errors.New("epoch publication superseded by foreclosure") // ErrApplicationNotRunnable means an advance result cannot be stored because // the application's durable status does not allow machine execution. ErrApplicationNotRunnable = errors.New("application is not runnable") @@ -45,12 +48,11 @@ type Pagination struct { } type ApplicationFilter struct { - Enabled *bool - Status *ApplicationStatus - Statuses []ApplicationStatus - DataAvailability *DataAvailabilitySelector - ConsensusType *Consensus - ConsensusTypes []Consensus + Enabled *bool + Status *ApplicationStatus + Statuses []ApplicationStatus + ConsensusType *Consensus + ConsensusTypes []Consensus // ForeclosureRecorded filters by the foreclose_block column: when non-nil // and true, returns only apps whose foreclosure has been observed and // recorded by the evmreader; when non-nil and false, returns only apps @@ -340,6 +342,7 @@ type BulkOperationsRepository interface { } type NodeConfigRepository interface { + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) } @@ -466,15 +469,36 @@ func SaveNodeConfig[T any]( return nil } +// InitializeNodeConfig saves only a missing key, then returns the stored value. +// Callers must validate that value against the requested configuration. A +// concurrent initializer may have won with a different value. +func InitializeNodeConfig[T any]( + ctx context.Context, repo NodeConfigRepository, nc *NodeConfig[T], +) (*NodeConfig[T], error) { + data, err := json.Marshal(nc.Value) + if err != nil { + return nil, fmt.Errorf("marshal initial node_config value: %w", err) + } + if err := repo.InitializeNodeConfigRaw(ctx, nc.Key, data); err != nil { + return nil, fmt.Errorf("initialize node_config %q: %w", nc.Key, err) + } + return LoadNodeConfig[T](ctx, repo, nc.Key) +} + +// LoadNodeConfig returns a non-nil record when err is nil. A missing JSON payload +// is an error, even if the repository reports a successful read. func LoadNodeConfig[T any]( ctx context.Context, repo NodeConfigRepository, key string, ) (*NodeConfig[T], error) { raw, createdAt, updatedAt, err := repo.LoadNodeConfigRaw(ctx, key) - if err != nil || raw == nil { + if err != nil { return nil, err } + if raw == nil { + return nil, fmt.Errorf("node config %q has no JSON value", key) + } var val T if err := json.Unmarshal(raw, &val); err != nil { return nil, fmt.Errorf("unmarshal node_config value failed: %w", err) diff --git a/internal/repository/repotest/application_test_cases.go b/internal/repository/repotest/application_test_cases.go index 988774286..54014ab5b 100644 --- a/internal/repository/repotest/application_test_cases.go +++ b/internal/repository/repotest/application_test_cases.go @@ -79,7 +79,6 @@ func (s *ApplicationSuite) TestGetApplication() { s.Equal(app.ConsensusType, got.ConsensusType) s.Equal(app.Enabled, got.Enabled) s.Equal(app.Status, got.Status) - s.Equal(app.DataAvailability, got.DataAvailability) s.False(got.CreatedAt.IsZero(), "CreatedAt should be set") s.False(got.UpdatedAt.IsZero(), "UpdatedAt should be set") }) @@ -183,29 +182,6 @@ func (s *ApplicationSuite) TestListApplications() { s.Equal(Consensus_PRT, apps[0].ConsensusType) }) - s.Run("FilterByDataAvailability", func() { - NewApplicationBuilder(). - WithDataAvailability(DataAvailability_InputBox[:]). - Create(s.Ctx, s.T(), s.Repo) - // Create another app with a different DA selector - otherDA := DataAvailabilitySelector{0xaa, 0xbb, 0xcc, 0xdd} - NewApplicationBuilder(). - WithDataAvailability(otherDA[:]). - Create(s.Ctx, s.T(), s.Repo) - - da := DataAvailability_InputBox - apps, total, err := s.Repo.ListApplications( - s.Ctx, - repository.ApplicationFilter{DataAvailability: &da}, - repository.Pagination{Limit: 10}, - false, - ) - s.Require().NoError(err) - s.Len(apps, 1) - s.Equal(uint64(1), total) - s.Equal(DataAvailability_InputBox[:], apps[0].DataAvailability[:4]) - }) - s.Run("Pagination", func() { for range 5 { NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) @@ -245,24 +221,23 @@ func (s *ApplicationSuite) TestListApplications() { // Descending: second created should be first s.Equal(a2.ID, apps[0].ID) s.Equal(a1.ID, apps[1].ID) + s.Equal(a2.IInputBoxAddress, apps[0].IInputBoxAddress) + s.Equal(a1.IInputBoxAddress, apps[1].IInputBoxAddress) }) s.Run("CombinedFilters", func() { - // Create apps with different combinations of enabled flag, status, consensus, and DA. + // Create apps with different combinations of enabled flag, status, and consensus. NewApplicationBuilder(). WithStatus(ApplicationStatus_OK). WithConsensus(Consensus_Authority). - WithDataAvailability(DataAvailability_InputBox[:]). Create(s.Ctx, s.T(), s.Repo) NewApplicationBuilder(). WithStatus(ApplicationStatus_OK). WithConsensus(Consensus_PRT). - WithDataAvailability(DataAvailability_InputBox[:]). Create(s.Ctx, s.T(), s.Repo) NewApplicationBuilder(). WithEnabled(false). WithConsensus(Consensus_Authority). - WithDataAvailability(DataAvailability_InputBox[:]). Create(s.Ctx, s.T(), s.Repo) enabled := true @@ -314,35 +289,6 @@ func (s *ApplicationSuite) TestListApplications() { s.Equal(uint64(1), total) s.NotEqual(foreclosed.ID, got[0].ID) }) - - s.Run("CombinedStateAndDataAvailability", func() { - NewApplicationBuilder(). - WithStatus(ApplicationStatus_OK). - WithDataAvailability(DataAvailability_InputBox[:]). - Create(s.Ctx, s.T(), s.Repo) - - otherDA := DataAvailabilitySelector{0xaa, 0xbb, 0xcc, 0xdd} - NewApplicationBuilder(). - WithStatus(ApplicationStatus_OK). - WithDataAvailability(otherDA[:]). - Create(s.Ctx, s.T(), s.Repo) - - status := ApplicationStatus_OK - da := DataAvailability_InputBox - apps, total, err := s.Repo.ListApplications( - s.Ctx, - repository.ApplicationFilter{ - Status: &status, - DataAvailability: &da, - }, - repository.Pagination{Limit: 10}, - false, - ) - s.Require().NoError(err) - s.Len(apps, 1) - s.Equal(uint64(1), total) - s.Equal(DataAvailability_InputBox[:], apps[0].DataAvailability[:4]) - }) } func (s *ApplicationSuite) TestUpdateApplicationStatus() { @@ -1091,12 +1037,14 @@ func (s *ApplicationSuite) TestUpdateApplication() { s.Run("UpdatesFields", func() { app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) app.EpochLength = 20 + app.IInputBoxAddress = UniqueAddress() err := s.Repo.UpdateApplication(s.Ctx, app) s.Require().NoError(err) got, err := s.Repo.GetApplication(s.Ctx, app.Name) s.Require().NoError(err) s.Equal(uint64(20), got.EpochLength) + s.Equal(app.IInputBoxAddress, got.IInputBoxAddress) }) // UpdateApplication must not touch status or foreclosure columns. Those diff --git a/internal/repository/repotest/builders.go b/internal/repository/repotest/builders.go index f21545ed8..aad1d883f 100644 --- a/internal/repository/repotest/builders.go +++ b/internal/repository/repotest/builders.go @@ -38,12 +38,12 @@ func nextID() uint64 { // UniqueAddress returns a unique Ethereum address for test isolation. func UniqueAddress() common.Address { - return common.BigToAddress(big.NewInt(int64(nextID()))) + return common.BigToAddress(new(big.Int).SetUint64(nextID())) } // UniqueHash returns a unique hash for test isolation. func UniqueHash() common.Hash { - return common.BigToHash(big.NewInt(int64(nextID()))) + return common.BigToHash(new(big.Int).SetUint64(nextID())) } // --------------------------------------------------------------------------- @@ -68,7 +68,6 @@ func NewApplicationBuilder() *ApplicationBuilder { EpochLength: 10, ClaimStagingPeriod: 7, WithdrawalConfig: defaultWithdrawalConfig(), - DataAvailability: DataAvailability_InputBox[:], ConsensusType: Consensus_Authority, Enabled: true, Status: ApplicationStatus_OK, @@ -111,11 +110,6 @@ func (b *ApplicationBuilder) WithClaimStagingPeriod(p uint64) *ApplicationBuilde return b } -func (b *ApplicationBuilder) WithDataAvailability(da []byte) *ApplicationBuilder { - b.app.DataAvailability = da - return b -} - func (b *ApplicationBuilder) WithWithdrawalConfig(wc WithdrawalConfig) *ApplicationBuilder { b.app.WithdrawalConfig = wc return b diff --git a/internal/repository/repotest/claimer_test_cases.go b/internal/repository/repotest/claimer_test_cases.go index 4c7674b8e..73047e450 100644 --- a/internal/repository/repotest/claimer_test_cases.go +++ b/internal/repository/repotest/claimer_test_cases.go @@ -68,6 +68,69 @@ func (s *ClaimerSuite) TestSelectClaimsToSubmitPerApp() { s.Contains(apps, app.ID) }) + s.Run("LoadsCompleteStateProofForClaimAndBarrier", func() { + const ( + firstEpochLastBlock uint64 = 9 + secondEpochFirstBlock uint64 = 10 + secondEpochLastBlock uint64 = 19 + firstInputBlock uint64 = 5 + secondInputBlock uint64 = 15 + thirdEpochFirstBlock uint64 = 20 + ) + + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, firstEpochLastBlock).WithInputBounds(0, 0).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(secondEpochFirstBlock, secondEpochLastBlock).WithInputBounds(1, 1).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, + app.IApplicationAddress.String(), + map[*Epoch][]*Input{ + epoch0: {NewInputBuilder().WithIndex(0).WithBlockNumber(firstInputBlock).Build()}, + epoch1: {NewInputBuilder().WithIndex(1).WithBlockNumber(secondInputBlock).Build()}, + }, + thirdEpochFirstBlock, + ) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch0, EpochStatus_ClaimComputed) + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, epoch0.Index, UniqueHash()) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch1, EpochStatus_ClaimComputed) + + barriers, computed, _, err := s.Repo.SelectClaimsToSubmitPerApp(s.Ctx) + s.Require().NoError(err) + s.Require().Contains(barriers, app.ID) + s.Require().Contains(computed, app.ID) + + assertStateProof := func(expected, actual *Epoch) { + s.Require().True(actual.HasCompleteStateProof()) + s.Equal(expected.MachineHash, actual.MachineHash) + s.Equal(expected.TxBufferDataBlock, actual.TxBufferDataBlock) + s.Equal(expected.TxBufferProof, actual.TxBufferProof) + s.Equal(expected.IflagsYDataBlock, actual.IflagsYDataBlock) + s.Equal(expected.IflagsYProof, actual.IflagsYProof) + s.Equal(expected.HtifTohostDataBlock, actual.HtifTohostDataBlock) + s.Equal(expected.HtifTohostProof, actual.HtifTohostProof) + } + + s.Equal(epoch0.Index, barriers[app.ID].Index) + s.Equal(EpochStatus_ClaimSubmitted, barriers[app.ID].Status) + assertStateProof(epoch0, barriers[app.ID]) + + s.Equal(epoch1.Index, computed[app.ID].Index) + s.Equal(EpochStatus_ClaimComputed, computed[app.ID].Status) + assertStateProof(epoch1, computed[app.ID]) + }) + s.Run("IncludesForeclosedComputedAppForTerminalization", func() { app := s.createAppWithClaimComputedEpoch() err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 100, UniqueHash(), 100) @@ -501,6 +564,63 @@ func (s *ClaimerSuite) TestSelectClaimsToAcceptPerApp() { }) } +//nolint:mnd // Explicit epoch and block values define the barrier ordering. +func (s *ClaimerSuite) TestNewestBarrierFiltersStatusBeforeLimit() { + s.Run("PreservesProofsAndForeclosedBarriers", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epochs := make([]*Epoch, 3) + inputs := make(map[*Epoch][]*Input, len(epochs)) + for i := range epochs { + index := uint64(i) + epochs[i] = NewEpochBuilder(app.ID).WithIndex(index).WithStatus(EpochStatus_Closed). + WithBlocks(index*10, index*10+9).WithInputBounds(index, index).Build() + inputs[epochs[i]] = []*Input{NewInputBuilder().WithIndex(index).WithBlockNumber(index*10 + 5).Build()} + } + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, app.Name, inputs, 30)) + for _, epoch := range epochs { + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, app.Name, epoch, EpochStatus_ClaimComputed) + } + for index := range uint64(2) { + s.Require().NoError(s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, index, UniqueHash())) + s.Require().NoError(s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, index, 40+index)) + } + acceptedTx := UniqueHash() + s.Require().NoError(s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, &acceptedTx)) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 100, UniqueHash(), 100)) + + submit, _, _, err := s.Repo.SelectClaimsToSubmitPerApp(s.Ctx) + s.Require().NoError(err) + stage, _, _, err := s.Repo.SelectClaimsToStagePerApp(s.Ctx) + s.Require().NoError(err) + accept, _, _, err := s.Repo.SelectClaimsToAcceptPerApp(s.Ctx) + s.Require().NoError(err) + for _, result := range []struct { + barriers map[int64]*Epoch + index uint64 + }{{submit, 1}, {stage, 0}, {accept, 0}} { + s.Require().Contains(result.barriers, app.ID) + actual := result.barriers[app.ID] + expected, err := s.Repo.GetEpoch(s.Ctx, app.Name, result.index) + s.Require().NoError(err) + s.Equal(expected.Index, actual.Index) + s.Equal(expected.Status, actual.Status) + s.Equal(expected.StagedAtBlock, actual.StagedAtBlock) + s.Equal(expected.ClaimTransactionHash, actual.ClaimTransactionHash) + s.Equal(expected.VirtualIndex, actual.VirtualIndex) + s.Equal(expected.CreatedAt, actual.CreatedAt) + s.Equal(expected.UpdatedAt, actual.UpdatedAt) + s.Require().True(actual.HasCompleteStateProof()) + s.Equal(expected.MachineHash, actual.MachineHash) + s.Equal(expected.TxBufferDataBlock, actual.TxBufferDataBlock) + s.Equal(expected.TxBufferProof, actual.TxBufferProof) + s.Equal(expected.IflagsYDataBlock, actual.IflagsYDataBlock) + s.Equal(expected.IflagsYProof, actual.IflagsYProof) + s.Equal(expected.HtifTohostDataBlock, actual.HtifTohostDataBlock) + s.Equal(expected.HtifTohostProof, actual.HtifTohostProof) + } + }) +} + func (s *ClaimerSuite) TestUpdateEpochWithSubmittedClaim() { s.Run("SetsClaimSubmitted", func() { app := s.createAppWithClaimComputedEpoch() diff --git a/internal/repository/repotest/epoch_test_cases.go b/internal/repository/repotest/epoch_test_cases.go index 3bb013dc7..b2d94deef 100644 --- a/internal/repository/repotest/epoch_test_cases.go +++ b/internal/repository/repotest/epoch_test_cases.go @@ -1188,20 +1188,10 @@ func (s *EpochSuite) TestEpochStatusTransitionTrigger() { s.Contains(err.Error(), "PRT") }) - // Verify the trigger rejects CLAIM_STAGED for PRT apps. PRT settles via - // tournaments and never goes through the staging contract path; an - // attempt to mark a PRT epoch as STAGED would be local data corruption. - // The trigger guard is the last line of defense against any caller - // that bypasses the higher-level claimer/PRT services. We advance the - // PRT epoch through CLAIM_SUBMITTED (a transition the trigger does - // permit, just never exercised in production for PRT) so that - // UpdateEpochToStaged sets the staged_at_block atomically and the - // PRT guard is the only remaining check that can reject the UPDATE. - s.Run("RejectsPRTStaged", func() { + newPRTComputedEpoch := func() (*Application, *Epoch) { app := NewApplicationBuilder(). WithConsensus(Consensus_PRT). Create(s.Ctx, s.T(), s.Repo) - epoch := NewEpochBuilder(app.ID). WithIndex(0).WithStatus(EpochStatus_Closed). WithBlocks(0, 9).WithInputBounds(0, 0). @@ -1215,11 +1205,40 @@ func (s *EpochSuite) TestEpochStatusTransitionTrigger() { AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, app.IApplicationAddress.String(), epoch, - EpochStatus_ClaimSubmitted) + EpochStatus_ClaimComputed) + return app, epoch + } - err = s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, epoch.Index, 42) - s.Require().Error(err) - s.Contains(err.Error(), "PRT") + // Dave/PRT stages a finished tournament result directly from + // CLAIM_COMPUTED. The repository records the staging block without a + // transaction hash. The acceptance evidence supplies that hash later. + s.Run("AllowsPRTStagedThenAccepted", func() { + const stagedAtBlock uint64 = 42 + + app, epoch := newPRTComputedEpoch() + + err := s.Repo.UpdateEpochReconciledStaged(s.Ctx, app.ID, epoch.Index, stagedAtBlock) + s.Require().NoError(err) + + staged, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimStaged, staged.Status) + s.Require().NotNil(staged.StagedAtBlock) + s.Equal(stagedAtBlock, *staged.StagedAtBlock) + s.Nil(staged.ClaimTransactionHash) + + acceptanceTransactionHash := UniqueHash() + err = s.Repo.UpdateEpochWithAcceptedClaim( + s.Ctx, app.ID, epoch.Index, &acceptanceTransactionHash) + s.Require().NoError(err) + + accepted, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, accepted.Status) + s.Require().NotNil(accepted.StagedAtBlock) + s.Equal(stagedAtBlock, *accepted.StagedAtBlock) + s.Require().NotNil(accepted.ClaimTransactionHash) + s.Equal(acceptanceTransactionHash, *accepted.ClaimTransactionHash) }) // Verify the trigger / CHECK constraint rejects any transition into @@ -1227,22 +1246,18 @@ func (s *EpochSuite) TestEpochStatusTransitionTrigger() { // only writes the Status column, so it cannot set staged_at_block // atomically — that is exactly the situation this invariant is meant // to catch. - s.Run("RejectsStagedWithoutBlock", func() { - seed := Seed(s.Ctx, s.T(), s.Repo) - - AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, - seed.App.IApplicationAddress.String(), seed.Epoch, - EpochStatus_ClaimComputed) + s.Run("RejectsPRTStagedWithoutBlock", func() { + app, epoch := newPRTComputedEpoch() // Sanity: staged_at_block is NULL on this freshly built row. got, err := s.Repo.GetEpoch( - s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Ctx, app.IApplicationAddress.String(), epoch.Index) s.Require().NoError(err) s.Require().Nil(got.StagedAtBlock) - seed.Epoch.Status = EpochStatus_ClaimStaged + epoch.Status = EpochStatus_ClaimStaged err = s.Repo.UpdateEpochStatus( - s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Ctx, app.IApplicationAddress.String(), epoch) s.Require().Error(err) // The trigger surfaces first with this exact phrasing; if a future // refactor disables the trigger, the CHECK constraint @@ -1286,6 +1301,8 @@ func (s *EpochSuite) TestDrainGates() { inputStatus := InputCompletionStatus_None switch target { + case EpochStatus_Open, EpochStatus_Closed: + // These states have no completed input. case EpochStatus_InputsProcessed, EpochStatus_ClaimComputed, EpochStatus_ClaimSubmitted, diff --git a/internal/repository/repotest/node_config_test_cases.go b/internal/repository/repotest/node_config_test_cases.go index b888e6a3e..fe57ccab0 100644 --- a/internal/repository/repotest/node_config_test_cases.go +++ b/internal/repository/repotest/node_config_test_cases.go @@ -5,11 +5,81 @@ package repotest import ( "encoding/json" + "fmt" + "sync" + "github.com/cartesi/rollups-node/internal/config" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" ) +const nodeConfigTestChainID = 31337 + +func (s *NodeConfigSuite) TestConcurrentConfigInitializationPreservesWinner() { + s.Run("opposite submission modes", func() { + const key = "concurrent-init" + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + for _, enabled := range []bool{false, true} { + wg.Go(func() { + <-start + requested := config.PersistentSubmitterConfig{ + DefaultBlock: DefaultBlock_Finalized, ChainID: nodeConfigTestChainID, ClaimSubmissionEnabled: enabled, + } + stored, err := repository.InitializeNodeConfig(s.Ctx, s.Repo, &NodeConfig[config.PersistentSubmitterConfig]{ + Key: key, Value: requested, + }) + if err == nil { + err = stored.Value.CheckRequested(requested) + } + errs <- err + }) + } + close(start) + wg.Wait() + close(errs) + var successes, conflicts int + for err := range errs { + if err == nil { + successes++ + } else { + s.Require().ErrorContains(err, "claim submission mode mismatch") + conflicts++ + } + } + s.Equal(1, successes) + s.Equal(1, conflicts) + winner, err := repository.LoadNodeConfig[config.PersistentSubmitterConfig](s.Ctx, s.Repo, key) + s.Require().NoError(err) + opposite := winner.Value + opposite.ClaimSubmissionEnabled = !opposite.ClaimSubmissionEnabled + retained, err := repository.InitializeNodeConfig(s.Ctx, s.Repo, &NodeConfig[config.PersistentSubmitterConfig]{ + Key: key, Value: opposite, + }) + s.Require().NoError(err) + s.Equal(winner.Value, retained.Value) + s.Equal(winner.CreatedAt, retained.CreatedAt) + s.Equal(winner.UpdatedAt, retained.UpdatedAt) + }) +} + +func (s *NodeConfigSuite) TestConfigInitializationDoesNotRepairInvalidSavedValue() { + for i, raw := range []string{`{}`, `null`, `{"ChainID":0,"DefaultBlock":"FINALIZED"}`} { + s.Run(fmt.Sprint(i), func() { + key := fmt.Sprintf("invalid-config-%d", i) + s.Require().NoError(s.Repo.SaveNodeConfigRaw(s.Ctx, key, []byte(raw))) + _, err := repository.InitializeNodeConfig(s.Ctx, s.Repo, &NodeConfig[config.PersistentChainConfig]{ + Key: key, Value: config.PersistentChainConfig{DefaultBlock: DefaultBlock_Finalized, ChainID: nodeConfigTestChainID}, + }) + s.Require().Error(err) + got, _, _, err := s.Repo.LoadNodeConfigRaw(s.Ctx, key) + s.Require().NoError(err) + s.JSONEq(raw, string(got)) + }) + } +} + type NodeConfigSuite struct { BaseSuite } diff --git a/internal/repository/repotest/output_test_cases.go b/internal/repository/repotest/output_test_cases.go index 996a887bf..fad749250 100644 --- a/internal/repository/repotest/output_test_cases.go +++ b/internal/repository/repotest/output_test_cases.go @@ -416,27 +416,47 @@ func (s *OutputSuite) TestListOutputs() { } func (s *OutputSuite) TestUpdateOutputsExecution() { - s.Run("UpdatesExecutionHash", func() { - seed := Seed(s.Ctx, s.T(), s.Repo) - - s.storeAdvanceResult(seed.App.ID, 0, 0, - [][]byte{[]byte("output-data")}, nil) + const rollbackCheckpoint = uint64(100) + for _, test := range []struct { + name string + previousBlock uint64 + requestedBlock uint64 + expectedBlock uint64 + }{ + {"ForwardCursor", 100, 200, 200}, + {"EqualCursor", 200, 200, 200}, + {"OlderCursor", 200, 100, 200}, + } { + s.Run(test.name, func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("output-data")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, test.previousBlock) + s.Require().NoError(err) - txHash := UniqueHash() - out := &Output{ - InputEpochApplicationID: seed.App.ID, - Index: 0, - ExecutionTransactionHash: &txHash, - } - err := s.Repo.UpdateOutputsExecution( - s.Ctx, seed.App.IApplicationAddress.String(), []*Output{out}, 100) - s.Require().NoError(err) + txHash := UniqueHash() + out := &Output{ + InputEpochApplicationID: seed.App.ID, + Index: 0, + ExecutionTransactionHash: &txHash, + } + err = s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), []*Output{out}, test.requestedBlock) + s.Require().NoError(err) - got, err := s.Repo.GetOutput(s.Ctx, seed.App.IApplicationAddress.String(), 0) - s.Require().NoError(err) - s.Require().NotNil(got.ExecutionTransactionHash) - s.Equal(txHash, *got.ExecutionTransactionHash) - }) + got, err := s.Repo.GetOutput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Require().NotNil(got.ExecutionTransactionHash) + s.Equal(txHash, *got.ExecutionTransactionHash, + "output updates must commit even when the cursor is already ahead") + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(test.expectedBlock, block) + }) + } // Regression guard: all output updates must be transactional. // Verify multiple outputs are updated atomically in a single call. @@ -497,6 +517,9 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { s.storeAdvanceResult(seed.App.ID, 0, 0, [][]byte{[]byte("o0"), []byte("o1")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, rollbackCheckpoint) + s.Require().NoError(err) txHash := UniqueHash() outputs := []*Output{ @@ -517,7 +540,7 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { }, } - err := s.Repo.UpdateOutputsExecution( + err = s.Repo.UpdateOutputsExecution( s.Ctx, seed.App.IApplicationAddress.String(), outputs, 200) s.Require().Error(err) @@ -535,6 +558,9 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { s.Ctx, seed.App.IApplicationAddress.String()) s.Require().NoError(err) s.Equal(uint64(0), count) + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(rollbackCheckpoint, block, "failed output updates must not advance the cursor") }) // Verify that a nil hash on the second output rolls back the first @@ -545,6 +571,9 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { s.storeAdvanceResult(seed.App.ID, 0, 0, [][]byte{[]byte("o0"), []byte("o1")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, rollbackCheckpoint) + s.Require().NoError(err) txHash := UniqueHash() outputs := []*Output{ @@ -560,7 +589,7 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { }, } - err := s.Repo.UpdateOutputsExecution( + err = s.Repo.UpdateOutputsExecution( s.Ctx, seed.App.IApplicationAddress.String(), outputs, 200) s.Require().Error(err) @@ -570,6 +599,9 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { s.Require().NoError(err) s.Nil(got.ExecutionTransactionHash, "output 0 should not have execution hash after rollback") + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(rollbackCheckpoint, block, "invalid output data must not advance the cursor") }) } From f2f6dbcf85e1f2ed62640383f8b6a057d9902196 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:16 -0300 Subject: [PATCH 11/41] feat(repository): store complete dispute state Store immutable dispute event identities beside current contract views anchored to an observation block. Add final enum/domain constraints, typed row mappings, bond events, and the matching generated tables. Scope match-advance reads to application, epoch, tournament, and match. Use transaction hash and log index to identify an event returned by that list. Keep pagination checks and remove the superseded row-update APIs. This defines the storage and read model. The next commit publishes a complete observation window with its cursor in one transaction. --- internal/repository/postgres/bond_event.go | 91 +++ internal/repository/postgres/commitment.go | 220 ++------ .../rollupsdb/public/enum/bonddisposition.go | 22 + .../db/rollupsdb/public/enum/bondeventtype.go | 18 + .../rollupsdb/public/enum/commitmentside.go | 18 + .../public/enum/innertournamentdisposition.go | 20 + .../db/rollupsdb/public/enum/matchphase.go | 22 + .../public/enum/matchtimeoutoutcome.go | 22 + .../rollupsdb/public/enum/tournamentkind.go | 18 + .../public/enum/tournamentstandingstate.go | 28 + .../db/rollupsdb/public/table/bond_events.go | 123 ++++ .../db/rollupsdb/public/table/commitments.go | 22 +- .../rollupsdb/public/table/match_advances.go | 75 +-- .../db/rollupsdb/public/table/matches.go | 165 ++++-- .../public/table/table_use_schema.go | 1 + .../db/rollupsdb/public/table/tournaments.go | 64 ++- internal/repository/postgres/epoch.go | 11 + internal/repository/postgres/match.go | 287 ++-------- .../repository/postgres/match_advanced.go | 245 ++------ .../postgres/pagination_range_test.go | 53 ++ internal/repository/postgres/prt_rows.go | 529 ++++++++++++++++++ .../000001_create_initial_schema.down.sql | 12 + .../000001_create_initial_schema.up.sql | 241 +++++++- internal/repository/postgres/tournament.go | 297 ++-------- internal/repository/repository.go | 35 +- internal/repository/repotest/builders.go | 45 +- .../repotest/match_advanced_test_cases.go | 8 +- .../repository/repotest/match_test_cases.go | 28 - .../repotest/tournament_test_cases.go | 40 +- 29 files changed, 1740 insertions(+), 1020 deletions(-) create mode 100644 internal/repository/postgres/bond_event.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go create mode 100644 internal/repository/postgres/db/rollupsdb/public/table/bond_events.go create mode 100644 internal/repository/postgres/pagination_range_test.go create mode 100644 internal/repository/postgres/prt_rows.go diff --git a/internal/repository/postgres/bond_event.go b/internal/repository/postgres/bond_event.go new file mode 100644 index 000000000..44cbf4956 --- /dev/null +++ b/internal/repository/postgres/bond_event.go @@ -0,0 +1,91 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func (r *PostgresRepository) GetBondEvent( + ctx context.Context, nameOrAddress string, txHash common.Hash, logIndex uint64, +) (*BondEvent, error) { + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt). + FROM(table.BondEvents.INNER_JOIN(table.Application, table.BondEvents.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.BondEvents.TxHash.EQ(postgres.Bytea(txHash.Bytes())).AND(table.BondEvents.LogIndex.EQ(uint64Expr(logIndex))), + )) + sqlStr, args := sel.Sql() + value, err := scanBondEvent(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return value, err +} + +func (r *PostgresRepository) ListBondEvents( + ctx context.Context, nameOrAddress string, f repository.BondEventFilter, p repository.Pagination, descending bool, +) ([]*BondEvent, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.BondEvents.INNER_JOIN(table.Application, table.BondEvents.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} + if f.EpochIndex != nil { + conditions = append(conditions, table.BondEvents.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + } + if f.TournamentAddress != nil { + conditions = append(conditions, table.BondEvents.TournamentAddress.EQ(postgres.Bytea(f.TournamentAddress.Bytes()))) + } + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + countStmt := table.BondEvents.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil || total == 0 { + return nil, total, err + } + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) + if descending { + sel = sel.ORDER_BY(table.BondEvents.BlockNumber.DESC(), table.BondEvents.LogIndex.DESC(), table.BondEvents.TxHash.DESC()) + } else { + sel = sel.ORDER_BY(table.BondEvents.BlockNumber.ASC(), table.BondEvents.LogIndex.ASC(), table.BondEvents.TxHash.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var values []*BondEvent + for rows.Next() { + value, err := scanBondEvent(rows) + if err != nil { + return nil, 0, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return values, total, nil +} diff --git a/internal/repository/postgres/commitment.go b/internal/repository/postgres/commitment.go index 58a0ba9a7..7c6fb9537 100644 --- a/internal/repository/postgres/commitment.go +++ b/internal/repository/postgres/commitment.go @@ -5,231 +5,105 @@ package postgres import ( "context" - "database/sql" "errors" + "fmt" + "math" - "github.com/ethereum/go-ethereum/common" - "github.com/go-jet/jet/v2/postgres" - - "github.com/cartesi/rollups-node/internal/model" + . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" ) -// ------------------------ CommitmentRepository Methods ------------------------ // - -func (r *PostgresRepository) CreateCommitment( - ctx context.Context, - nameOrAddress string, - c *model.Commitment, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - selectQuery := table.Application.SELECT( - table.Application.ID, - uint64Expr(c.EpochIndex), - postgres.Bytea(c.TournamentAddress.Bytes()), - postgres.Bytea(c.Commitment.Bytes()), - postgres.Bytea(c.FinalStateHash.Bytes()), - postgres.Bytea(c.SubmitterAddress.Bytes()), - uint64Expr(c.BlockNumber), - postgres.Bytea(c.TxHash.Bytes()), - ).WHERE( - whereClause, - ) - - insertStmt := table.Commitments.INSERT( - table.Commitments.ApplicationID, - table.Commitments.EpochIndex, - table.Commitments.TournamentAddress, - table.Commitments.Commitment, - table.Commitments.FinalStateHash, - table.Commitments.SubmitterAddress, - table.Commitments.BlockNumber, - table.Commitments.TxHash, - ).QUERY( - selectQuery, - ) - - sqlStr, args := insertStmt.Sql() +func (r *PostgresRepository) CreateCommitment(ctx context.Context, nameOrAddress string, value *Commitment) error { + if value == nil { + return fmt.Errorf("cannot create a nil commitment") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := commitmentValues(value) + values[0] = applicationID + stmt := table.Commitments.INSERT(commitmentColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() _, err := r.db.Exec(ctx, sqlStr, args...) - return err } func (r *PostgresRepository) GetCommitment( - ctx context.Context, - nameOrAddress string, - epochIndex uint64, - tournamentAddress string, - commitmentHex string, -) (*model.Commitment, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - tournamentAddr := common.HexToAddress(tournamentAddress) - commitment := common.HexToHash(commitmentHex) - - sel := table.Commitments. - SELECT( - table.Commitments.ApplicationID, - table.Commitments.EpochIndex, - table.Commitments.TournamentAddress, - table.Commitments.Commitment, - table.Commitments.FinalStateHash, - table.Commitments.SubmitterAddress, - table.Commitments.BlockNumber, - table.Commitments.TxHash, - table.Commitments.CreatedAt, - table.Commitments.UpdatedAt, - ). - FROM( - table.Commitments. - INNER_JOIN(table.Application, - table.Commitments.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - whereClause. - AND(table.Commitments.EpochIndex.EQ(uint64Expr(epochIndex))). - AND(table.Commitments.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))). - AND(table.Commitments.Commitment.EQ(postgres.Bytea(commitment.Bytes()))), - ) - + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, commitmentHex string, +) (*Commitment, error) { + sel := table.Commitments.SELECT(commitmentColumns, table.Commitments.CreatedAt, table.Commitments.UpdatedAt). + FROM(table.Commitments.INNER_JOIN(table.Application, table.Commitments.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Commitments.EpochIndex.EQ(uint64Expr(epochIndex)). + AND(table.Commitments.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes()))). + AND(table.Commitments.Commitment.EQ(postgres.Bytea(common.HexToHash(commitmentHex).Bytes()))), + )) sqlStr, args := sel.Sql() - row := r.db.QueryRow(ctx, sqlStr, args...) - - var c model.Commitment - err := row.Scan( - &c.ApplicationID, - &c.EpochIndex, - &c.TournamentAddress, - &c.Commitment, - &c.FinalStateHash, - &c.SubmitterAddress, - &c.BlockNumber, - &c.TxHash, - &c.CreatedAt, - &c.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { + value, err := scanCommitment(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - if err != nil { - return nil, err - } - return &c, nil + return value, err } func (r *PostgresRepository) ListCommitments( - ctx context.Context, - nameOrAddress string, - f repository.CommitmentFilter, - p repository.Pagination, - descending bool, -) ([]*model.Commitment, uint64, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - fromClause := table.Commitments. - INNER_JOIN(table.Application, - table.Commitments.ApplicationID.EQ(table.Application.ID), - ) - - conditions := []postgres.BoolExpression{whereClause} + ctx context.Context, nameOrAddress string, f repository.CommitmentFilter, p repository.Pagination, descending bool, +) ([]*Commitment, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Commitments.INNER_JOIN(table.Application, table.Commitments.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} if f.EpochIndex != nil { conditions = append(conditions, table.Commitments.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) } if f.TournamentAddress != nil { - tournamentAddr := common.HexToAddress(*f.TournamentAddress) - conditions = append(conditions, table.Commitments.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))) + conditions = append(conditions, + table.Commitments.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(*f.TournamentAddress).Bytes()))) } - tx, err := beginReadTx(ctx, r.db) if err != nil { return nil, 0, err } defer tx.Rollback(ctx) //nolint:errcheck - - countStmt := table.Commitments.SELECT(postgres.COUNT(postgres.STAR)). - FROM(fromClause).WHERE(postgres.AND(conditions...)) + countStmt := table.Commitments.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) total, err := countFromTx(ctx, tx, countStmt) - if err != nil { - return nil, 0, err - } - if total == 0 { - return nil, 0, nil + if err != nil || total == 0 { + return nil, total, err } - - sel := table.Commitments. - SELECT( - table.Commitments.ApplicationID, - table.Commitments.EpochIndex, - table.Commitments.TournamentAddress, - table.Commitments.Commitment, - table.Commitments.FinalStateHash, - table.Commitments.SubmitterAddress, - table.Commitments.BlockNumber, - table.Commitments.TxHash, - table.Commitments.CreatedAt, - table.Commitments.UpdatedAt, - ). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + sel := table.Commitments.SELECT(commitmentColumns, table.Commitments.CreatedAt, table.Commitments.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) if descending { sel = sel.ORDER_BY( - table.Commitments.ApplicationID.DESC(), - table.Commitments.EpochIndex.DESC(), - table.Commitments.TournamentAddress.DESC(), - table.Commitments.Commitment.DESC()) + table.Commitments.EpochIndex.DESC(), table.Commitments.TournamentAddress.DESC(), table.Commitments.Commitment.DESC()) } else { sel = sel.ORDER_BY( - table.Commitments.ApplicationID.ASC(), - table.Commitments.EpochIndex.ASC(), - table.Commitments.TournamentAddress.ASC(), - table.Commitments.Commitment.ASC()) + table.Commitments.EpochIndex.ASC(), table.Commitments.TournamentAddress.ASC(), table.Commitments.Commitment.ASC()) } - - // Apply pagination if p.Limit > 0 { sel = sel.LIMIT(int64(p.Limit)) } if p.Offset > 0 { sel = sel.OFFSET(int64(p.Offset)) } - sqlStr, args := sel.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { return nil, 0, err } defer rows.Close() - - var commitments []*model.Commitment + var values []*Commitment for rows.Next() { - var c model.Commitment - err := rows.Scan( - &c.ApplicationID, - &c.EpochIndex, - &c.TournamentAddress, - &c.Commitment, - &c.FinalStateHash, - &c.SubmitterAddress, - &c.BlockNumber, - &c.TxHash, - &c.CreatedAt, - &c.UpdatedAt, - ) + value, err := scanCommitment(rows) if err != nil { return nil, 0, err } - commitments = append(commitments, &c) + values = append(values, value) } if err := rows.Err(); err != nil { return nil, 0, err } - - return commitments, total, nil + return values, total, nil } diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go b/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go new file mode 100644 index 000000000..809e4b5d4 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var BondDisposition = &struct { + TournamentRunning postgres.StringExpression + NoWinner postgres.StringExpression + Recoverable postgres.StringExpression + Recovered postgres.StringExpression +}{ + TournamentRunning: postgres.NewEnumValue("TOURNAMENT_RUNNING"), + NoWinner: postgres.NewEnumValue("NO_WINNER"), + Recoverable: postgres.NewEnumValue("RECOVERABLE"), + Recovered: postgres.NewEnumValue("RECOVERED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go b/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go new file mode 100644 index 000000000..aed36dcd2 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var BondEventType = &struct { + PartialBondRefund postgres.StringExpression + BondRecovered postgres.StringExpression +}{ + PartialBondRefund: postgres.NewEnumValue("PARTIAL_BOND_REFUND"), + BondRecovered: postgres.NewEnumValue("BOND_RECOVERED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go b/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go new file mode 100644 index 000000000..2732af031 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var CommitmentSide = &struct { + One postgres.StringExpression + Two postgres.StringExpression +}{ + One: postgres.NewEnumValue("ONE"), + Two: postgres.NewEnumValue("TWO"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go b/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go new file mode 100644 index 000000000..09089eb1e --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go @@ -0,0 +1,20 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var InnerTournamentDisposition = &struct { + Unsettled postgres.StringExpression + Winner postgres.StringExpression + Eliminable postgres.StringExpression +}{ + Unsettled: postgres.NewEnumValue("UNSETTLED"), + Winner: postgres.NewEnumValue("WINNER"), + Eliminable: postgres.NewEnumValue("ELIMINABLE"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go b/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go new file mode 100644 index 000000000..204a1ba82 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var MatchPhase = &struct { + Uninitialized postgres.StringExpression + Bisecting postgres.StringExpression + ReadyToSeal postgres.StringExpression + Sealed postgres.StringExpression +}{ + Uninitialized: postgres.NewEnumValue("UNINITIALIZED"), + Bisecting: postgres.NewEnumValue("BISECTING"), + ReadyToSeal: postgres.NewEnumValue("READY_TO_SEAL"), + Sealed: postgres.NewEnumValue("SEALED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go b/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go new file mode 100644 index 000000000..2dd230bee --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var MatchTimeoutOutcome = &struct { + None postgres.StringExpression + OneWins postgres.StringExpression + TwoWins postgres.StringExpression + EliminateBoth postgres.StringExpression +}{ + None: postgres.NewEnumValue("NONE"), + OneWins: postgres.NewEnumValue("ONE_WINS"), + TwoWins: postgres.NewEnumValue("TWO_WINS"), + EliminateBoth: postgres.NewEnumValue("ELIMINATE_BOTH"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go new file mode 100644 index 000000000..20b2f5107 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var TournamentKind = &struct { + Leaf postgres.StringExpression + NonLeaf postgres.StringExpression +}{ + Leaf: postgres.NewEnumValue("LEAF"), + NonLeaf: postgres.NewEnumValue("NON_LEAF"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go new file mode 100644 index 000000000..483feed12 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go @@ -0,0 +1,28 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var TournamentStandingState = &struct { + MatchesActive postgres.StringExpression + AwaitingClosure postgres.StringExpression + RootWinner postgres.StringExpression + RootFailed postgres.StringExpression + InnerWinner postgres.StringExpression + InnerEliminableNoWinner postgres.StringExpression + InnerEliminableWinnerExpired postgres.StringExpression +}{ + MatchesActive: postgres.NewEnumValue("MATCHES_ACTIVE"), + AwaitingClosure: postgres.NewEnumValue("AWAITING_CLOSURE"), + RootWinner: postgres.NewEnumValue("ROOT_WINNER"), + RootFailed: postgres.NewEnumValue("ROOT_FAILED"), + InnerWinner: postgres.NewEnumValue("INNER_WINNER"), + InnerEliminableNoWinner: postgres.NewEnumValue("INNER_ELIMINABLE_NO_WINNER"), + InnerEliminableWinnerExpired: postgres.NewEnumValue("INNER_ELIMINABLE_WINNER_EXPIRED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go b/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go new file mode 100644 index 000000000..2da37d50d --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go @@ -0,0 +1,123 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var BondEvents = newBondEventsTable("public", "bond_events", "") + +type bondEventsTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + Type postgres.ColumnString + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + Recipient postgres.ColumnBytea + Value postgres.ColumnFloat + Success postgres.ColumnBool + Commitment postgres.ColumnBytea + Claimer postgres.ColumnBytea + Payment postgres.ColumnFloat + Burned postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type BondEventsTable struct { + bondEventsTable + + EXCLUDED bondEventsTable +} + +// AS creates new BondEventsTable with assigned alias +func (a BondEventsTable) AS(alias string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new BondEventsTable with assigned schema name +func (a BondEventsTable) FromSchema(schemaName string) *BondEventsTable { + return newBondEventsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new BondEventsTable with assigned table prefix +func (a BondEventsTable) WithPrefix(prefix string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new BondEventsTable with assigned table suffix +func (a BondEventsTable) WithSuffix(suffix string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newBondEventsTable(schemaName, tableName, alias string) *BondEventsTable { + return &BondEventsTable{ + bondEventsTable: newBondEventsTableImpl(schemaName, tableName, alias), + EXCLUDED: newBondEventsTableImpl("", "excluded", ""), + } +} + +func newBondEventsTableImpl(schemaName, tableName, alias string) bondEventsTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + TypeColumn = postgres.StringColumn("type") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + RecipientColumn = postgres.ByteaColumn("recipient") + ValueColumn = postgres.FloatColumn("value") + SuccessColumn = postgres.BoolColumn("success") + CommitmentColumn = postgres.ByteaColumn("commitment") + ClaimerColumn = postgres.ByteaColumn("claimer") + PaymentColumn = postgres.FloatColumn("payment") + BurnedColumn = postgres.FloatColumn("burned") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, TypeColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, RecipientColumn, ValueColumn, SuccessColumn, CommitmentColumn, ClaimerColumn, PaymentColumn, BurnedColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{EpochIndexColumn, TournamentAddressColumn, TypeColumn, BlockNumberColumn, RecipientColumn, ValueColumn, SuccessColumn, CommitmentColumn, ClaimerColumn, PaymentColumn, BurnedColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return bondEventsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + Type: TypeColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + Recipient: RecipientColumn, + Value: ValueColumn, + Success: SuccessColumn, + Commitment: CommitmentColumn, + Claimer: ClaimerColumn, + Payment: PaymentColumn, + Burned: BurnedColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/commitments.go b/internal/repository/postgres/db/rollupsdb/public/table/commitments.go index 6f2aec2fd..2b55d01dc 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/commitments.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/commitments.go @@ -25,6 +25,12 @@ type commitmentsTable struct { SubmitterAddress postgres.ColumnBytea BlockNumber postgres.ColumnFloat TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Claimer postgres.ColumnBytea + ClockRunning postgres.ColumnBool + ClockDeadline postgres.ColumnFloat + ClockAllowance postgres.ColumnFloat CreatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz @@ -76,10 +82,16 @@ func newCommitmentsTableImpl(schemaName, tableName, alias string) commitmentsTab SubmitterAddressColumn = postgres.ByteaColumn("submitter_address") BlockNumberColumn = postgres.FloatColumn("block_number") TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + ClaimerColumn = postgres.ByteaColumn("claimer") + ClockRunningColumn = postgres.BoolColumn("clock_running") + ClockDeadlineColumn = postgres.FloatColumn("clock_deadline") + ClockAllowanceColumn = postgres.FloatColumn("clock_allowance") CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, CommitmentColumn, FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, CreatedAtColumn, UpdatedAtColumn} + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, CommitmentColumn, FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, AsOfBlockColumn, ClaimerColumn, ClockRunningColumn, ClockDeadlineColumn, ClockAllowanceColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, AsOfBlockColumn, ClaimerColumn, ClockRunningColumn, ClockDeadlineColumn, ClockAllowanceColumn, CreatedAtColumn, UpdatedAtColumn} defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} ) @@ -95,6 +107,12 @@ func newCommitmentsTableImpl(schemaName, tableName, alias string) commitmentsTab SubmitterAddress: SubmitterAddressColumn, BlockNumber: BlockNumberColumn, TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Claimer: ClaimerColumn, + ClockRunning: ClockRunningColumn, + ClockDeadline: ClockDeadlineColumn, + ClockAllowance: ClockAllowanceColumn, CreatedAt: CreatedAtColumn, UpdatedAt: UpdatedAtColumn, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go b/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go index b31317d96..db20d41cd 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go @@ -17,16 +17,19 @@ type matchAdvancesTable struct { postgres.Table // Columns - ApplicationID postgres.ColumnInteger - EpochIndex postgres.ColumnFloat - TournamentAddress postgres.ColumnBytea - IDHash postgres.ColumnBytea - OtherParent postgres.ColumnBytea - LeftNode postgres.ColumnBytea - BlockNumber postgres.ColumnFloat - TxHash postgres.ColumnBytea - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + IDHash postgres.ColumnBytea + OtherParent postgres.ColumnBytea + LeftNode postgres.ColumnBytea + SegmentStartPosition postgres.ColumnFloat + EliminableAt postgres.ColumnFloat + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -68,35 +71,41 @@ func newMatchAdvancesTable(schemaName, tableName, alias string) *MatchAdvancesTa func newMatchAdvancesTableImpl(schemaName, tableName, alias string) matchAdvancesTable { var ( - ApplicationIDColumn = postgres.IntegerColumn("application_id") - EpochIndexColumn = postgres.FloatColumn("epoch_index") - TournamentAddressColumn = postgres.ByteaColumn("tournament_address") - IDHashColumn = postgres.ByteaColumn("id_hash") - OtherParentColumn = postgres.ByteaColumn("other_parent") - LeftNodeColumn = postgres.ByteaColumn("left_node") - BlockNumberColumn = postgres.FloatColumn("block_number") - TxHashColumn = postgres.ByteaColumn("tx_hash") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, OtherParentColumn, LeftNodeColumn, BlockNumberColumn, TxHashColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{LeftNodeColumn, BlockNumberColumn, TxHashColumn, CreatedAtColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + IDHashColumn = postgres.ByteaColumn("id_hash") + OtherParentColumn = postgres.ByteaColumn("other_parent") + LeftNodeColumn = postgres.ByteaColumn("left_node") + SegmentStartPositionColumn = postgres.FloatColumn("segment_start_position") + EliminableAtColumn = postgres.FloatColumn("eliminable_at") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, OtherParentColumn, LeftNodeColumn, SegmentStartPositionColumn, EliminableAtColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{EpochIndexColumn, TournamentAddressColumn, IDHashColumn, OtherParentColumn, LeftNodeColumn, SegmentStartPositionColumn, EliminableAtColumn, BlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} ) return matchAdvancesTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - ApplicationID: ApplicationIDColumn, - EpochIndex: EpochIndexColumn, - TournamentAddress: TournamentAddressColumn, - IDHash: IDHashColumn, - OtherParent: OtherParentColumn, - LeftNode: LeftNodeColumn, - BlockNumber: BlockNumberColumn, - TxHash: TxHashColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + IDHash: IDHashColumn, + OtherParent: OtherParentColumn, + LeftNode: LeftNodeColumn, + SegmentStartPosition: SegmentStartPositionColumn, + EliminableAt: EliminableAtColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/matches.go b/internal/repository/postgres/db/rollupsdb/public/table/matches.go index 4b854d27e..8986e4688 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/matches.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/matches.go @@ -17,21 +17,44 @@ type matchesTable struct { postgres.Table // Columns - ApplicationID postgres.ColumnInteger - EpochIndex postgres.ColumnFloat - TournamentAddress postgres.ColumnBytea - IDHash postgres.ColumnBytea - CommitmentOne postgres.ColumnBytea - CommitmentTwo postgres.ColumnBytea - LeftOfTwo postgres.ColumnBytea - BlockNumber postgres.ColumnFloat - TxHash postgres.ColumnBytea - Winner postgres.ColumnString - DeletionReason postgres.ColumnString - DeletionBlockNumber postgres.ColumnFloat - DeletionTxHash postgres.ColumnBytea - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + IDHash postgres.ColumnBytea + CommitmentOne postgres.ColumnBytea + CommitmentTwo postgres.ColumnBytea + LeftOfTwo postgres.ColumnBytea + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + EliminableAt postgres.ColumnFloat + SealEliminableAt postgres.ColumnFloat + SealBlockNumber postgres.ColumnFloat + SealTxHash postgres.ColumnBytea + SealLogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Phase postgres.ColumnString + TimeoutOutcome postgres.ColumnString + DeferredCharge postgres.ColumnFloat + RevealingParent postgres.ColumnBytea + WaitingLeft postgres.ColumnBytea + WaitingRight postgres.ColumnBytea + SegmentStartPosition postgres.ColumnFloat + SegmentStartCycle postgres.ColumnFloat + CurrentHeight postgres.ColumnFloat + Responder postgres.ColumnString + AgreeState postgres.ColumnBytea + DivergencePosition postgres.ColumnFloat + DivergenceCycle postgres.ColumnFloat + FinalStateOne postgres.ColumnBytea + FinalStateTwo postgres.ColumnBytea + Winner postgres.ColumnString + DeletionReason postgres.ColumnString + DeletionBlockNumber postgres.ColumnFloat + DeletionTxHash postgres.ColumnBytea + DeletionLogIndex postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -73,45 +96,91 @@ func newMatchesTable(schemaName, tableName, alias string) *MatchesTable { func newMatchesTableImpl(schemaName, tableName, alias string) matchesTable { var ( - ApplicationIDColumn = postgres.IntegerColumn("application_id") - EpochIndexColumn = postgres.FloatColumn("epoch_index") - TournamentAddressColumn = postgres.ByteaColumn("tournament_address") - IDHashColumn = postgres.ByteaColumn("id_hash") - CommitmentOneColumn = postgres.ByteaColumn("commitment_one") - CommitmentTwoColumn = postgres.ByteaColumn("commitment_two") - LeftOfTwoColumn = postgres.ByteaColumn("left_of_two") - BlockNumberColumn = postgres.FloatColumn("block_number") - TxHashColumn = postgres.ByteaColumn("tx_hash") - WinnerColumn = postgres.StringColumn("winner") - DeletionReasonColumn = postgres.StringColumn("deletion_reason") - DeletionBlockNumberColumn = postgres.FloatColumn("deletion_block_number") - DeletionTxHashColumn = postgres.ByteaColumn("deletion_tx_hash") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, CreatedAtColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{DeletionBlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + IDHashColumn = postgres.ByteaColumn("id_hash") + CommitmentOneColumn = postgres.ByteaColumn("commitment_one") + CommitmentTwoColumn = postgres.ByteaColumn("commitment_two") + LeftOfTwoColumn = postgres.ByteaColumn("left_of_two") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + EliminableAtColumn = postgres.FloatColumn("eliminable_at") + SealEliminableAtColumn = postgres.FloatColumn("seal_eliminable_at") + SealBlockNumberColumn = postgres.FloatColumn("seal_block_number") + SealTxHashColumn = postgres.ByteaColumn("seal_tx_hash") + SealLogIndexColumn = postgres.FloatColumn("seal_log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + PhaseColumn = postgres.StringColumn("phase") + TimeoutOutcomeColumn = postgres.StringColumn("timeout_outcome") + DeferredChargeColumn = postgres.FloatColumn("deferred_charge") + RevealingParentColumn = postgres.ByteaColumn("revealing_parent") + WaitingLeftColumn = postgres.ByteaColumn("waiting_left") + WaitingRightColumn = postgres.ByteaColumn("waiting_right") + SegmentStartPositionColumn = postgres.FloatColumn("segment_start_position") + SegmentStartCycleColumn = postgres.FloatColumn("segment_start_cycle") + CurrentHeightColumn = postgres.FloatColumn("current_height") + ResponderColumn = postgres.StringColumn("responder") + AgreeStateColumn = postgres.ByteaColumn("agree_state") + DivergencePositionColumn = postgres.FloatColumn("divergence_position") + DivergenceCycleColumn = postgres.FloatColumn("divergence_cycle") + FinalStateOneColumn = postgres.ByteaColumn("final_state_one") + FinalStateTwoColumn = postgres.ByteaColumn("final_state_two") + WinnerColumn = postgres.StringColumn("winner") + DeletionReasonColumn = postgres.StringColumn("deletion_reason") + DeletionBlockNumberColumn = postgres.FloatColumn("deletion_block_number") + DeletionTxHashColumn = postgres.ByteaColumn("deletion_tx_hash") + DeletionLogIndexColumn = postgres.FloatColumn("deletion_log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, EliminableAtColumn, SealEliminableAtColumn, SealBlockNumberColumn, SealTxHashColumn, SealLogIndexColumn, AsOfBlockColumn, PhaseColumn, TimeoutOutcomeColumn, DeferredChargeColumn, RevealingParentColumn, WaitingLeftColumn, WaitingRightColumn, SegmentStartPositionColumn, SegmentStartCycleColumn, CurrentHeightColumn, ResponderColumn, AgreeStateColumn, DivergencePositionColumn, DivergenceCycleColumn, FinalStateOneColumn, FinalStateTwoColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, DeletionLogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, EliminableAtColumn, SealEliminableAtColumn, SealBlockNumberColumn, SealTxHashColumn, SealLogIndexColumn, AsOfBlockColumn, PhaseColumn, TimeoutOutcomeColumn, DeferredChargeColumn, RevealingParentColumn, WaitingLeftColumn, WaitingRightColumn, SegmentStartPositionColumn, SegmentStartCycleColumn, CurrentHeightColumn, ResponderColumn, AgreeStateColumn, DivergencePositionColumn, DivergenceCycleColumn, FinalStateOneColumn, FinalStateTwoColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, DeletionLogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{DeletionBlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} ) return matchesTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - ApplicationID: ApplicationIDColumn, - EpochIndex: EpochIndexColumn, - TournamentAddress: TournamentAddressColumn, - IDHash: IDHashColumn, - CommitmentOne: CommitmentOneColumn, - CommitmentTwo: CommitmentTwoColumn, - LeftOfTwo: LeftOfTwoColumn, - BlockNumber: BlockNumberColumn, - TxHash: TxHashColumn, - Winner: WinnerColumn, - DeletionReason: DeletionReasonColumn, - DeletionBlockNumber: DeletionBlockNumberColumn, - DeletionTxHash: DeletionTxHashColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + IDHash: IDHashColumn, + CommitmentOne: CommitmentOneColumn, + CommitmentTwo: CommitmentTwoColumn, + LeftOfTwo: LeftOfTwoColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + EliminableAt: EliminableAtColumn, + SealEliminableAt: SealEliminableAtColumn, + SealBlockNumber: SealBlockNumberColumn, + SealTxHash: SealTxHashColumn, + SealLogIndex: SealLogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Phase: PhaseColumn, + TimeoutOutcome: TimeoutOutcomeColumn, + DeferredCharge: DeferredChargeColumn, + RevealingParent: RevealingParentColumn, + WaitingLeft: WaitingLeftColumn, + WaitingRight: WaitingRightColumn, + SegmentStartPosition: SegmentStartPositionColumn, + SegmentStartCycle: SegmentStartCycleColumn, + CurrentHeight: CurrentHeightColumn, + Responder: ResponderColumn, + AgreeState: AgreeStateColumn, + DivergencePosition: DivergencePositionColumn, + DivergenceCycle: DivergenceCycleColumn, + FinalStateOne: FinalStateOneColumn, + FinalStateTwo: FinalStateTwoColumn, + Winner: WinnerColumn, + DeletionReason: DeletionReasonColumn, + DeletionBlockNumber: DeletionBlockNumberColumn, + DeletionTxHash: DeletionTxHashColumn, + DeletionLogIndex: DeletionLogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go b/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go index 93fa66ad1..a2000afc3 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go @@ -11,6 +11,7 @@ package table // this method only once at the beginning of the program. func UseSchema(schema string) { Application = Application.FromSchema(schema) + BondEvents = BondEvents.FromSchema(schema) Commitments = Commitments.FromSchema(schema) Epoch = Epoch.FromSchema(schema) ExecutionParameters = ExecutionParameters.FromSchema(schema) diff --git a/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go b/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go index 7f7a2507e..18d56ab2c 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go @@ -26,9 +26,29 @@ type tournamentsTable struct { Level postgres.ColumnInteger Log2step postgres.ColumnInteger Height postgres.ColumnInteger + InitialHash postgres.ColumnBytea + BaseCycle postgres.ColumnFloat + Kind postgres.ColumnString + StartInstant postgres.ColumnFloat + Allowance postgres.ColumnFloat + CreationBlockNumber postgres.ColumnFloat + CreationTxHash postgres.ColumnBytea + CreationLogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Standing postgres.ColumnString + AcceptsJoins postgres.ColumnBool + Candidate postgres.ColumnBytea WinnerCommitment postgres.ColumnBytea FinalStateHash postgres.ColumnBytea FinishedAtBlock postgres.ColumnFloat + ParentCommitment postgres.ColumnBytea + WinnerExpiresAt postgres.ColumnFloat + InnerDisposition postgres.ColumnString + InnerParentCommitment postgres.ColumnBytea + InnerPausedAllowance postgres.ColumnFloat + BondDisposition postgres.ColumnString + BondClaimer postgres.ColumnBytea + BondPayment postgres.ColumnFloat CreatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz @@ -81,13 +101,33 @@ func newTournamentsTableImpl(schemaName, tableName, alias string) tournamentsTab LevelColumn = postgres.IntegerColumn("level") Log2stepColumn = postgres.IntegerColumn("log2step") HeightColumn = postgres.IntegerColumn("height") + InitialHashColumn = postgres.ByteaColumn("initial_hash") + BaseCycleColumn = postgres.FloatColumn("base_cycle") + KindColumn = postgres.StringColumn("kind") + StartInstantColumn = postgres.FloatColumn("start_instant") + AllowanceColumn = postgres.FloatColumn("allowance") + CreationBlockNumberColumn = postgres.FloatColumn("creation_block_number") + CreationTxHashColumn = postgres.ByteaColumn("creation_tx_hash") + CreationLogIndexColumn = postgres.FloatColumn("creation_log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + StandingColumn = postgres.StringColumn("standing") + AcceptsJoinsColumn = postgres.BoolColumn("accepts_joins") + CandidateColumn = postgres.ByteaColumn("candidate") WinnerCommitmentColumn = postgres.ByteaColumn("winner_commitment") FinalStateHashColumn = postgres.ByteaColumn("final_state_hash") FinishedAtBlockColumn = postgres.FloatColumn("finished_at_block") + ParentCommitmentColumn = postgres.ByteaColumn("parent_commitment") + WinnerExpiresAtColumn = postgres.FloatColumn("winner_expires_at") + InnerDispositionColumn = postgres.StringColumn("inner_disposition") + InnerParentCommitmentColumn = postgres.ByteaColumn("inner_parent_commitment") + InnerPausedAllowanceColumn = postgres.FloatColumn("inner_paused_allowance") + BondDispositionColumn = postgres.StringColumn("bond_disposition") + BondClaimerColumn = postgres.ByteaColumn("bond_claimer") + BondPaymentColumn = postgres.FloatColumn("bond_payment") CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, AddressColumn, ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, CreatedAtColumn, UpdatedAtColumn} + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, AddressColumn, ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, InitialHashColumn, BaseCycleColumn, KindColumn, StartInstantColumn, AllowanceColumn, CreationBlockNumberColumn, CreationTxHashColumn, CreationLogIndexColumn, AsOfBlockColumn, StandingColumn, AcceptsJoinsColumn, CandidateColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, ParentCommitmentColumn, WinnerExpiresAtColumn, InnerDispositionColumn, InnerParentCommitmentColumn, InnerPausedAllowanceColumn, BondDispositionColumn, BondClaimerColumn, BondPaymentColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, InitialHashColumn, BaseCycleColumn, KindColumn, StartInstantColumn, AllowanceColumn, CreationBlockNumberColumn, CreationTxHashColumn, CreationLogIndexColumn, AsOfBlockColumn, StandingColumn, AcceptsJoinsColumn, CandidateColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, ParentCommitmentColumn, WinnerExpiresAtColumn, InnerDispositionColumn, InnerParentCommitmentColumn, InnerPausedAllowanceColumn, BondDispositionColumn, BondClaimerColumn, BondPaymentColumn, CreatedAtColumn, UpdatedAtColumn} defaultColumns = postgres.ColumnList{FinishedAtBlockColumn, CreatedAtColumn, UpdatedAtColumn} ) @@ -104,9 +144,29 @@ func newTournamentsTableImpl(schemaName, tableName, alias string) tournamentsTab Level: LevelColumn, Log2step: Log2stepColumn, Height: HeightColumn, + InitialHash: InitialHashColumn, + BaseCycle: BaseCycleColumn, + Kind: KindColumn, + StartInstant: StartInstantColumn, + Allowance: AllowanceColumn, + CreationBlockNumber: CreationBlockNumberColumn, + CreationTxHash: CreationTxHashColumn, + CreationLogIndex: CreationLogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Standing: StandingColumn, + AcceptsJoins: AcceptsJoinsColumn, + Candidate: CandidateColumn, WinnerCommitment: WinnerCommitmentColumn, FinalStateHash: FinalStateHashColumn, FinishedAtBlock: FinishedAtBlockColumn, + ParentCommitment: ParentCommitmentColumn, + WinnerExpiresAt: WinnerExpiresAtColumn, + InnerDisposition: InnerDispositionColumn, + InnerParentCommitment: InnerParentCommitmentColumn, + InnerPausedAllowance: InnerPausedAllowanceColumn, + BondDisposition: BondDispositionColumn, + BondClaimer: BondClaimerColumn, + BondPayment: BondPaymentColumn, CreatedAt: CreatedAtColumn, UpdatedAt: UpdatedAtColumn, diff --git a/internal/repository/postgres/epoch.go b/internal/repository/postgres/epoch.go index d176fd098..4f582861d 100644 --- a/internal/repository/postgres/epoch.go +++ b/internal/repository/postgres/epoch.go @@ -8,6 +8,7 @@ import ( "database/sql" "errors" "fmt" + "math" "sort" "github.com/cartesi/rollups-node/internal/model" @@ -890,6 +891,9 @@ func (r *PostgresRepository) ListEpochs( p repository.Pagination, descending bool, ) ([]*model.Epoch, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) @@ -916,6 +920,13 @@ func (r *PostgresRepository) ListEpochs( if f.BeforeBlock != nil { conditions = append(conditions, table.Epoch.LastBlock.LT(uint64Expr(*f.BeforeBlock))) } + if f.HasTournament != nil { + if *f.HasTournament { + conditions = append(conditions, table.Epoch.TournamentAddress.IS_NOT_NULL()) + } else { + conditions = append(conditions, table.Epoch.TournamentAddress.IS_NULL()) + } + } tx, err := beginReadTx(ctx, r.db) if err != nil { diff --git a/internal/repository/postgres/match.go b/internal/repository/postgres/match.go index bfb84b201..73fa801ac 100644 --- a/internal/repository/postgres/match.go +++ b/internal/repository/postgres/match.go @@ -5,296 +5,103 @@ package postgres import ( "context" - "database/sql" "errors" + "fmt" + "math" - "github.com/ethereum/go-ethereum/common" - "github.com/go-jet/jet/v2/postgres" - - "github.com/cartesi/rollups-node/internal/model" + . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" ) -// ------------------------ MatchRepository Methods ------------------------ // - -func (r *PostgresRepository) CreateMatch( - ctx context.Context, - nameOrAddress string, - m *model.Match, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - selectQuery := table.Application.SELECT( - table.Application.ID, - uint64Expr(m.EpochIndex), - postgres.Bytea(m.TournamentAddress.Bytes()), - postgres.Bytea(m.IDHash.Bytes()), - postgres.Bytea(m.CommitmentOne.Bytes()), - postgres.Bytea(m.CommitmentTwo.Bytes()), - postgres.Bytea(m.LeftOfTwo.Bytes()), - uint64Expr(m.BlockNumber), - postgres.Bytea(m.TxHash.Bytes()), - postgres.NewEnumValue(m.Winner.String()), - postgres.NewEnumValue(m.DeletionReason.String()), - uint64Expr(m.DeletionBlockNumber), - postgres.Bytea(m.DeletionTxHash.Bytes()), - ).WHERE( - whereClause, - ) - - insertStmt := table.Matches.INSERT( - table.Matches.ApplicationID, - table.Matches.EpochIndex, - table.Matches.TournamentAddress, - table.Matches.IDHash, - table.Matches.CommitmentOne, - table.Matches.CommitmentTwo, - table.Matches.LeftOfTwo, - table.Matches.BlockNumber, - table.Matches.TxHash, - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - ).QUERY( - selectQuery, - ) - - sqlStr, args := insertStmt.Sql() +func (r *PostgresRepository) CreateMatch(ctx context.Context, nameOrAddress string, value *Match) error { + if value == nil { + return fmt.Errorf("cannot create a nil match") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := matchValues(value) + values[0] = applicationID + stmt := table.Matches.INSERT(matchColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() _, err := r.db.Exec(ctx, sqlStr, args...) - return err } -func (r *PostgresRepository) UpdateMatch( - ctx context.Context, - nameOrAddress string, - m *model.Match, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - updateStmt := table.Matches. - UPDATE( - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - ). - SET( - m.Winner, - m.DeletionReason, - m.DeletionBlockNumber, - postgres.Bytea(m.DeletionTxHash.Bytes()), - ). - FROM( - table.Application, - ). - WHERE( - whereClause. - AND(table.Matches.ApplicationID.EQ(postgres.Int(m.ApplicationID))). - AND(table.Matches.EpochIndex.EQ(uint64Expr(m.EpochIndex))). - AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(m.TournamentAddress.Bytes()))). - AND(table.Matches.IDHash.EQ(postgres.Bytea(m.IDHash.Bytes()))), - ) - - sqlStr, args := updateStmt.Sql() - cmd, err := r.db.Exec(ctx, sqlStr, args...) - if err != nil { - return err - } - if cmd.RowsAffected() == 0 { - return repository.ErrNotFound - } - return nil -} - func (r *PostgresRepository) GetMatch( - ctx context.Context, - nameOrAddress string, - epochIndex uint64, - tournamentAddress string, - idHashHex string, -) (*model.Match, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - tournamentAddr := common.HexToAddress(tournamentAddress) - idHash := common.HexToHash(idHashHex) - - sel := table.Matches. - SELECT( - table.Matches.ApplicationID, - table.Matches.EpochIndex, - table.Matches.TournamentAddress, - table.Matches.IDHash, - table.Matches.CommitmentOne, - table.Matches.CommitmentTwo, - table.Matches.LeftOfTwo, - table.Matches.BlockNumber, - table.Matches.TxHash, - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - table.Matches.CreatedAt, - table.Matches.UpdatedAt, - ). - FROM( - table.Matches. - INNER_JOIN(table.Application, - table.Matches.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - whereClause. - AND(table.Matches.EpochIndex.EQ(uint64Expr(epochIndex))). - AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))). - AND(table.Matches.IDHash.EQ(postgres.Bytea(idHash.Bytes()))), - ) - + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string, +) (*Match, error) { + sel := table.Matches.SELECT(matchColumns, table.Matches.CreatedAt, table.Matches.UpdatedAt). + FROM(table.Matches.INNER_JOIN(table.Application, table.Matches.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Matches.EpochIndex.EQ(uint64Expr(epochIndex)). + AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes()))). + AND(table.Matches.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes()))), + )) sqlStr, args := sel.Sql() - row := r.db.QueryRow(ctx, sqlStr, args...) - - var m model.Match - err := row.Scan( - &m.ApplicationID, - &m.EpochIndex, - &m.TournamentAddress, - &m.IDHash, - &m.CommitmentOne, - &m.CommitmentTwo, - &m.LeftOfTwo, - &m.BlockNumber, - &m.TxHash, - &m.Winner, - &m.DeletionReason, - &m.DeletionBlockNumber, - &m.DeletionTxHash, - &m.CreatedAt, - &m.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { + value, err := scanMatch(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - if err != nil { - return nil, err - } - return &m, nil + return value, err } func (r *PostgresRepository) ListMatches( - ctx context.Context, - nameOrAddress string, - f repository.MatchFilter, - p repository.Pagination, - descending bool, -) ([]*model.Match, uint64, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - fromClause := table.Matches. - INNER_JOIN(table.Application, - table.Matches.ApplicationID.EQ(table.Application.ID), - ) - - conditions := []postgres.BoolExpression{whereClause} + ctx context.Context, nameOrAddress string, f repository.MatchFilter, p repository.Pagination, descending bool, +) ([]*Match, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Matches.INNER_JOIN(table.Application, table.Matches.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} if f.EpochIndex != nil { conditions = append(conditions, table.Matches.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) } if f.TournamentAddress != nil { - tournamentAddr := common.HexToAddress(*f.TournamentAddress) - conditions = append(conditions, table.Matches.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))) + conditions = append(conditions, + table.Matches.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(*f.TournamentAddress).Bytes()))) } - tx, err := beginReadTx(ctx, r.db) if err != nil { return nil, 0, err } defer tx.Rollback(ctx) //nolint:errcheck - - countStmt := table.Matches.SELECT(postgres.COUNT(postgres.STAR)). - FROM(fromClause).WHERE(postgres.AND(conditions...)) + countStmt := table.Matches.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) total, err := countFromTx(ctx, tx, countStmt) - if err != nil { - return nil, 0, err + if err != nil || total == 0 { + return nil, total, err } - if total == 0 { - return nil, 0, nil - } - - sel := table.Matches. - SELECT( - table.Matches.ApplicationID, - table.Matches.EpochIndex, - table.Matches.TournamentAddress, - table.Matches.IDHash, - table.Matches.CommitmentOne, - table.Matches.CommitmentTwo, - table.Matches.LeftOfTwo, - table.Matches.BlockNumber, - table.Matches.TxHash, - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - table.Matches.CreatedAt, - table.Matches.UpdatedAt, - ). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + sel := table.Matches.SELECT(matchColumns, table.Matches.CreatedAt, table.Matches.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) if descending { - sel = sel.ORDER_BY(table.Matches.EpochIndex.DESC()) + sel = sel.ORDER_BY(table.Matches.EpochIndex.DESC(), table.Matches.TournamentAddress.DESC(), table.Matches.IDHash.DESC()) } else { - sel = sel.ORDER_BY(table.Matches.EpochIndex.ASC()) + sel = sel.ORDER_BY(table.Matches.EpochIndex.ASC(), table.Matches.TournamentAddress.ASC(), table.Matches.IDHash.ASC()) } - - // Apply pagination if p.Limit > 0 { sel = sel.LIMIT(int64(p.Limit)) } if p.Offset > 0 { sel = sel.OFFSET(int64(p.Offset)) } - sqlStr, args := sel.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { return nil, 0, err } defer rows.Close() - - var matches []*model.Match + var values []*Match for rows.Next() { - var m model.Match - err := rows.Scan( - &m.ApplicationID, - &m.EpochIndex, - &m.TournamentAddress, - &m.IDHash, - &m.CommitmentOne, - &m.CommitmentTwo, - &m.LeftOfTwo, - &m.BlockNumber, - &m.TxHash, - &m.Winner, - &m.DeletionReason, - &m.DeletionBlockNumber, - &m.DeletionTxHash, - &m.CreatedAt, - &m.UpdatedAt, - ) + value, err := scanMatch(rows) if err != nil { return nil, 0, err } - matches = append(matches, &m) + values = append(values, value) } if err := rows.Err(); err != nil { return nil, 0, err } - - return matches, total, nil + return values, total, nil } diff --git a/internal/repository/postgres/match_advanced.go b/internal/repository/postgres/match_advanced.go index bde2c3086..b0a8e47aa 100644 --- a/internal/repository/postgres/match_advanced.go +++ b/internal/repository/postgres/match_advanced.go @@ -5,241 +5,106 @@ package postgres import ( "context" - "database/sql" "errors" + "fmt" + "math" - "github.com/ethereum/go-ethereum/common" - "github.com/go-jet/jet/v2/postgres" - - "github.com/cartesi/rollups-node/internal/model" + . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" ) -// ------------------------ MatchAdvancedRepository Methods ------------------------ // - -func (r *PostgresRepository) CreateMatchAdvanced( - ctx context.Context, - nameOrAddress string, - m *model.MatchAdvanced, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - selectQuery := table.Application.SELECT( - table.Application.ID, - uint64Expr(m.EpochIndex), - postgres.Bytea(m.TournamentAddress.Bytes()), - postgres.Bytea(m.IDHash.Bytes()), - postgres.Bytea(m.OtherParent.Bytes()), - postgres.Bytea(m.LeftNode.Bytes()), - uint64Expr(m.BlockNumber), - postgres.Bytea(m.TxHash.Bytes()), - ).WHERE( - whereClause, - ) - - insertStmt := table.MatchAdvances.INSERT( - table.MatchAdvances.ApplicationID, - table.MatchAdvances.EpochIndex, - table.MatchAdvances.TournamentAddress, - table.MatchAdvances.IDHash, - table.MatchAdvances.OtherParent, - table.MatchAdvances.LeftNode, - table.MatchAdvances.BlockNumber, - table.MatchAdvances.TxHash, - ).QUERY( - selectQuery, - ) - - sqlStr, args := insertStmt.Sql() +func (r *PostgresRepository) CreateMatchAdvanced(ctx context.Context, nameOrAddress string, value *MatchAdvanced) error { + if value == nil { + return fmt.Errorf("cannot create a nil matchAdvance") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := matchAdvanceValues(value) + values[0] = applicationID + stmt := table.MatchAdvances.INSERT(matchAdvanceColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() _, err := r.db.Exec(ctx, sqlStr, args...) - return err } func (r *PostgresRepository) GetMatchAdvanced( - ctx context.Context, - nameOrAddress string, - epochIndex uint64, - tournamentAddress string, - idHashHex string, - parentHex string, -) (*model.MatchAdvanced, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - tournamentAddr := common.HexToAddress(tournamentAddress) - idHash := common.HexToHash(idHashHex) - parent := common.HexToHash(parentHex) - - sel := table.MatchAdvances. - SELECT( - table.MatchAdvances.ApplicationID, - table.MatchAdvances.EpochIndex, - table.MatchAdvances.TournamentAddress, - table.MatchAdvances.IDHash, - table.MatchAdvances.OtherParent, - table.MatchAdvances.LeftNode, - table.MatchAdvances.BlockNumber, - table.MatchAdvances.TxHash, - table.MatchAdvances.CreatedAt, - table.MatchAdvances.UpdatedAt, - ). - FROM( - table.MatchAdvances. - INNER_JOIN(table.Application, - table.MatchAdvances.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - whereClause. - AND(table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex))). - AND(table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))). - AND(table.MatchAdvances.IDHash.EQ(postgres.Bytea(idHash.Bytes()))). - AND(table.MatchAdvances.OtherParent.EQ(postgres.Bytea(parent.Bytes()))), - ) - + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, txHash common.Hash, logIndex uint64, +) (*MatchAdvanced, error) { + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt). + FROM(table.MatchAdvances.INNER_JOIN(table.Application, table.MatchAdvances.ApplicationID.EQ(table.Application.ID))). + WHERE(postgres.AND( + getWhereClauseFromNameOrAddress(nameOrAddress), + table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex)), + table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes())), + table.MatchAdvances.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes())), + table.MatchAdvances.TxHash.EQ(postgres.Bytea(txHash.Bytes())), + table.MatchAdvances.LogIndex.EQ(uint64Expr(logIndex)), + )) sqlStr, args := sel.Sql() - row := r.db.QueryRow(ctx, sqlStr, args...) - - var m model.MatchAdvanced - err := row.Scan( - &m.ApplicationID, - &m.EpochIndex, - &m.TournamentAddress, - &m.IDHash, - &m.OtherParent, - &m.LeftNode, - &m.BlockNumber, - &m.TxHash, - &m.CreatedAt, - &m.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { + value, err := scanMatchAdvanced(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - if err != nil { - return nil, err - } - return &m, nil + return value, err } func (r *PostgresRepository) ListMatchAdvances( - ctx context.Context, - nameOrAddress string, - epochIndex uint64, - tournamentAddress string, - idHashHex string, - p repository.Pagination, - descending bool, -) ([]*model.MatchAdvanced, uint64, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - fromClause := table.MatchAdvances. - INNER_JOIN(table.Application, - table.MatchAdvances.ApplicationID.EQ(table.Application.ID), - ) - - conditions := []postgres.BoolExpression{whereClause} - conditions = append(conditions, table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex))) - - tAddr := common.HexToAddress(tournamentAddress) - conditions = append(conditions, table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(tAddr.Bytes()))) - - idHash := common.HexToHash(idHashHex) - conditions = append(conditions, table.MatchAdvances.IDHash.EQ(postgres.Bytea(idHash.Bytes()))) - + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, p repository.Pagination, descending bool, +) ([]*MatchAdvanced, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.MatchAdvances.INNER_JOIN(table.Application, table.MatchAdvances.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{ + getWhereClauseFromNameOrAddress(nameOrAddress), + table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex)), + table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes())), + table.MatchAdvances.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes())), + } tx, err := beginReadTx(ctx, r.db) if err != nil { return nil, 0, err } defer tx.Rollback(ctx) //nolint:errcheck - - countStmt := table.MatchAdvances. - SELECT(postgres.COUNT(postgres.STAR)). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + countStmt := table.MatchAdvances.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) total, err := countFromTx(ctx, tx, countStmt) - if err != nil { - return nil, 0, err - } - if total == 0 { - return nil, 0, nil + if err != nil || total == 0 { + return nil, total, err } - - sel := table.MatchAdvances. - SELECT( - table.MatchAdvances.ApplicationID, - table.MatchAdvances.EpochIndex, - table.MatchAdvances.TournamentAddress, - table.MatchAdvances.IDHash, - table.MatchAdvances.OtherParent, - table.MatchAdvances.LeftNode, - table.MatchAdvances.BlockNumber, - table.MatchAdvances.TxHash, - table.MatchAdvances.CreatedAt, - table.MatchAdvances.UpdatedAt, - ). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) if descending { - sel = sel.ORDER_BY( - table.MatchAdvances.ApplicationID.DESC(), - table.MatchAdvances.EpochIndex.DESC(), - table.MatchAdvances.TournamentAddress.DESC(), - table.MatchAdvances.IDHash.DESC(), - table.MatchAdvances.OtherParent.DESC()) + sel = sel.ORDER_BY(table.MatchAdvances.BlockNumber.DESC(), table.MatchAdvances.LogIndex.DESC(), table.MatchAdvances.TxHash.DESC()) } else { - sel = sel.ORDER_BY( - table.MatchAdvances.ApplicationID.ASC(), - table.MatchAdvances.EpochIndex.ASC(), - table.MatchAdvances.TournamentAddress.ASC(), - table.MatchAdvances.IDHash.ASC(), - table.MatchAdvances.OtherParent.ASC()) + sel = sel.ORDER_BY(table.MatchAdvances.BlockNumber.ASC(), table.MatchAdvances.LogIndex.ASC(), table.MatchAdvances.TxHash.ASC()) } - - // Apply pagination if p.Limit > 0 { sel = sel.LIMIT(int64(p.Limit)) } if p.Offset > 0 { sel = sel.OFFSET(int64(p.Offset)) } - sqlStr, args := sel.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { return nil, 0, err } defer rows.Close() - - var matchAdvances []*model.MatchAdvanced + var values []*MatchAdvanced for rows.Next() { - var m model.MatchAdvanced - err := rows.Scan( - &m.ApplicationID, - &m.EpochIndex, - &m.TournamentAddress, - &m.IDHash, - &m.OtherParent, - &m.LeftNode, - &m.BlockNumber, - &m.TxHash, - &m.CreatedAt, - &m.UpdatedAt, - ) + value, err := scanMatchAdvanced(rows) if err != nil { return nil, 0, err } - matchAdvances = append(matchAdvances, &m) + values = append(values, value) } if err := rows.Err(); err != nil { return nil, 0, err } - - return matchAdvances, total, nil + return values, total, nil } diff --git a/internal/repository/postgres/pagination_range_test.go b/internal/repository/postgres/pagination_range_test.go new file mode 100644 index 000000000..559b24927 --- /dev/null +++ b/internal/repository/postgres/pagination_range_test.go @@ -0,0 +1,53 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "math" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func TestApplicationAndEpochPaginationIntegerBounds(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, epoch.LastBlock)) + for _, method := range []struct { + name string + list func(repository.Pagination) (int, uint64, error) + }{ + {"applications", func(p repository.Pagination) (int, uint64, error) { + rows, total, err := r.ListApplications(t.Context(), repository.ApplicationFilter{}, p, false) + return len(rows), total, err + }}, + {"epochs", func(p repository.Pagination) (int, uint64, error) { + rows, total, err := r.ListEpochs(t.Context(), app.Name, repository.EpochFilter{}, p, false) + return len(rows), total, err + }}, + } { + t.Run(method.name, func(t *testing.T) { + for _, p := range []repository.Pagination{{Limit: math.MaxInt64}, {Offset: math.MaxInt64}} { + count, total, err := method.list(p) + require.NoError(t, err) + require.Equal(t, uint64(1), total) + if p.Offset == 0 { + require.Equal(t, 1, count) + } else { + require.Zero(t, count) + } + } + for _, p := range []repository.Pagination{{Limit: math.MaxInt64 + 1}, {Offset: math.MaxInt64 + 1}} { + count, total, err := method.list(p) + require.ErrorContains(t, err, "pagination exceeds PostgreSQL integer range") + require.Zero(t, count) + require.Zero(t, total) + } + }) + } +} diff --git a/internal/repository/postgres/prt_rows.go b/internal/repository/postgres/prt_rows.go new file mode 100644 index 000000000..2c1168f42 --- /dev/null +++ b/internal/repository/postgres/prt_rows.go @@ -0,0 +1,529 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func nullableAddressBytes(value *common.Address) any { + if value == nil { + return nil + } + return value.Bytes() +} + +var tournamentColumns = postgres.ColumnList{ + table.Tournaments.ApplicationID, + table.Tournaments.EpochIndex, + table.Tournaments.Address, + table.Tournaments.ParentTournamentAddress, + table.Tournaments.ParentMatchIDHash, + table.Tournaments.MaxLevel, + table.Tournaments.Level, + table.Tournaments.Log2step, + table.Tournaments.Height, + table.Tournaments.InitialHash, + table.Tournaments.BaseCycle, + table.Tournaments.Kind, + table.Tournaments.StartInstant, + table.Tournaments.Allowance, + table.Tournaments.CreationBlockNumber, + table.Tournaments.CreationTxHash, + table.Tournaments.CreationLogIndex, + table.Tournaments.AsOfBlock, + table.Tournaments.Standing, + table.Tournaments.AcceptsJoins, + table.Tournaments.Candidate, + table.Tournaments.WinnerCommitment, + table.Tournaments.FinalStateHash, + table.Tournaments.FinishedAtBlock, + table.Tournaments.ParentCommitment, + table.Tournaments.WinnerExpiresAt, + table.Tournaments.InnerDisposition, + table.Tournaments.InnerParentCommitment, + table.Tournaments.InnerPausedAllowance, + table.Tournaments.BondDisposition, + table.Tournaments.BondClaimer, + table.Tournaments.BondPayment, +} + +func tournamentValues(v *Tournament) []any { + var creationBlock, creationTx, creationLog any + if v.CreationEvent != nil { + creationBlock = uint64Expr(v.CreationEvent.BlockNumber) + creationTx = v.CreationEvent.TxHash.Bytes() + creationLog = uint64Expr(v.CreationEvent.LogIndex) + } + var innerDisposition, innerParent, innerAllowance any + if v.Snapshot.InnerResult != nil { + innerDisposition = v.Snapshot.InnerResult.Disposition + innerParent = hashToBytes(v.Snapshot.InnerResult.ParentCommitment) + innerAllowance = uint64Expr(v.Snapshot.InnerResult.PausedAllowance) + } + var bondPayment any + if v.Snapshot.BondRecovery.Payment != nil { + bondPayment = *v.Snapshot.BondRecovery.Payment + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.Address.Bytes(), + nullableAddressBytes(v.ParentTournamentAddress), + hashToBytes(v.ParentMatchIDHash), + uint64Expr(v.MaxLevel), + uint64Expr(v.Level), + uint64Expr(v.Log2Step), + uint64Expr(v.Height), + v.InitialHash.Bytes(), + v.BaseCycle, + v.Kind, + uint64Expr(v.StartInstant), + uint64Expr(v.Allowance), + creationBlock, + creationTx, + creationLog, + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Standing, + v.Snapshot.AcceptsJoins, + hashToBytes(v.Snapshot.Candidate), + hashToBytes(v.Snapshot.WinnerCommitment), + hashToBytes(v.Snapshot.FinalStateHash), + uint64Expr(v.Snapshot.FinishedAtBlock), + hashToBytes(v.Snapshot.ParentCommitment), + uint64Expr(v.Snapshot.WinnerExpiresAt), + innerDisposition, + innerParent, + innerAllowance, + v.Snapshot.BondRecovery.Disposition, + nullableAddressBytes(v.Snapshot.BondRecovery.Claimer), + bondPayment, + } +} + +func scanTournament(row pgx.Row) (*Tournament, error) { + var v Tournament + var creationBlock, creationLog *uint64 + var creationTx *common.Hash + var innerDisposition *InnerTournamentDisposition + var innerParent *common.Hash + var innerAllowance *uint64 + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.Address, + &v.ParentTournamentAddress, + &v.ParentMatchIDHash, + &v.MaxLevel, + &v.Level, + &v.Log2Step, + &v.Height, + &v.InitialHash, + &v.BaseCycle, + &v.Kind, + &v.StartInstant, + &v.Allowance, + &creationBlock, + &creationTx, + &creationLog, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Standing, + &v.Snapshot.AcceptsJoins, + &v.Snapshot.Candidate, + &v.Snapshot.WinnerCommitment, + &v.Snapshot.FinalStateHash, + &v.Snapshot.FinishedAtBlock, + &v.Snapshot.ParentCommitment, + &v.Snapshot.WinnerExpiresAt, + &innerDisposition, + &innerParent, + &innerAllowance, + &v.Snapshot.BondRecovery.Disposition, + &v.Snapshot.BondRecovery.Claimer, + &v.Snapshot.BondRecovery.Payment, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if creationBlock != nil { + v.CreationEvent = &TournamentCreationEvent{BlockNumber: *creationBlock, TxHash: *creationTx, LogIndex: *creationLog} + } + if innerDisposition != nil { + v.Snapshot.InnerResult = &TournamentInnerResult{ + Disposition: *innerDisposition, ParentCommitment: innerParent, PausedAllowance: *innerAllowance, + } + } + return &v, nil +} + +var commitmentColumns = postgres.ColumnList{ + table.Commitments.ApplicationID, + table.Commitments.EpochIndex, + table.Commitments.TournamentAddress, + table.Commitments.Commitment, + table.Commitments.FinalStateHash, + table.Commitments.SubmitterAddress, + table.Commitments.BlockNumber, + table.Commitments.TxHash, + table.Commitments.LogIndex, + table.Commitments.AsOfBlock, + table.Commitments.Claimer, + table.Commitments.ClockRunning, + table.Commitments.ClockDeadline, + table.Commitments.ClockAllowance, +} + +func commitmentValues(v *Commitment) []any { + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.Commitment.Bytes(), + v.FinalStateHash.Bytes(), + v.SubmitterAddress.Bytes(), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Claimer.Bytes(), + v.Snapshot.ClockRunning, + uint64Expr(v.Snapshot.ClockDeadline), + uint64Expr(v.Snapshot.ClockAllowance), + } +} + +func scanCommitment(row pgx.Row) (*Commitment, error) { + var v Commitment + + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.Commitment, + &v.FinalStateHash, + &v.SubmitterAddress, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Claimer, + &v.Snapshot.ClockRunning, + &v.Snapshot.ClockDeadline, + &v.Snapshot.ClockAllowance, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + + return &v, nil +} + +var matchColumns = postgres.ColumnList{ + table.Matches.ApplicationID, + table.Matches.EpochIndex, + table.Matches.TournamentAddress, + table.Matches.IDHash, + table.Matches.CommitmentOne, + table.Matches.CommitmentTwo, + table.Matches.LeftOfTwo, + table.Matches.BlockNumber, + table.Matches.TxHash, + table.Matches.LogIndex, + table.Matches.EliminableAt, + table.Matches.SealEliminableAt, + table.Matches.SealBlockNumber, + table.Matches.SealTxHash, + table.Matches.SealLogIndex, + table.Matches.AsOfBlock, + table.Matches.Phase, + table.Matches.TimeoutOutcome, + table.Matches.DeferredCharge, + table.Matches.RevealingParent, + table.Matches.WaitingLeft, + table.Matches.WaitingRight, + table.Matches.SegmentStartPosition, + table.Matches.SegmentStartCycle, + table.Matches.CurrentHeight, + table.Matches.Responder, + table.Matches.AgreeState, + table.Matches.DivergencePosition, + table.Matches.DivergenceCycle, + table.Matches.FinalStateOne, + table.Matches.FinalStateTwo, + table.Matches.Winner, + table.Matches.DeletionReason, + table.Matches.DeletionBlockNumber, + table.Matches.DeletionTxHash, + table.Matches.DeletionLogIndex, +} + +func matchValues(v *Match) []any { + var sealEliminable, sealBlock, sealTx, sealLog any + if v.LeafSeal != nil { + sealEliminable = uint64Expr(v.LeafSeal.EliminableAt) + sealBlock = uint64Expr(v.LeafSeal.BlockNumber) + sealTx = v.LeafSeal.TxHash.Bytes() + sealLog = uint64Expr(v.LeafSeal.LogIndex) + } + var revealingParent, waitingLeft, waitingRight, segmentPosition, segmentCycle, currentHeight, responder any + if s := v.Snapshot.Bisection; s != nil { + revealingParent, waitingLeft, waitingRight = s.RevealingParent.Bytes(), s.WaitingLeft.Bytes(), s.WaitingRight.Bytes() + segmentPosition, segmentCycle, responder = s.SegmentStartPosition, s.SegmentStartCycle, s.Responder + if s.CurrentHeight != nil { + currentHeight = uint64Expr(*s.CurrentHeight) + } + } + var agreeState, divergencePosition, divergenceCycle, finalStateOne, finalStateTwo any + if s := v.Snapshot.Sealed; s != nil { + agreeState, finalStateOne, finalStateTwo = s.AgreeState.Bytes(), s.FinalStateOne.Bytes(), s.FinalStateTwo.Bytes() + divergencePosition, divergenceCycle = s.DivergencePosition, s.DivergenceCycle + } + var deletionLog any + if v.DeletionLogIndex != nil { + deletionLog = uint64Expr(*v.DeletionLogIndex) + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.IDHash.Bytes(), + v.CommitmentOne.Bytes(), + v.CommitmentTwo.Bytes(), + v.LeftOfTwo.Bytes(), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + uint64Expr(v.EliminableAt), + sealEliminable, + sealBlock, + sealTx, + sealLog, + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Phase, + v.Snapshot.TimeoutOutcome, + uint64Expr(v.Snapshot.DeferredCharge), + revealingParent, + waitingLeft, + waitingRight, + segmentPosition, + segmentCycle, + currentHeight, + responder, + agreeState, + divergencePosition, + divergenceCycle, + finalStateOne, + finalStateTwo, + v.Winner, + v.DeletionReason, + uint64Expr(v.DeletionBlockNumber), + hashToBytes(v.DeletionTxHash), + deletionLog, + } +} + +func scanMatch(row pgx.Row) (*Match, error) { + var v Match + var sealEliminable, sealBlock, sealLog *uint64 + var sealTx *common.Hash + var revealingParent, waitingLeft, waitingRight *common.Hash + var segmentPosition, segmentCycle *Uint256 + var currentHeight *uint64 + var responder *CommitmentSide + var agreeState, finalStateOne, finalStateTwo *common.Hash + var divergencePosition, divergenceCycle *Uint256 + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.IDHash, + &v.CommitmentOne, + &v.CommitmentTwo, + &v.LeftOfTwo, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.EliminableAt, + &sealEliminable, + &sealBlock, + &sealTx, + &sealLog, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Phase, + &v.Snapshot.TimeoutOutcome, + &v.Snapshot.DeferredCharge, + &revealingParent, + &waitingLeft, + &waitingRight, + &segmentPosition, + &segmentCycle, + ¤tHeight, + &responder, + &agreeState, + &divergencePosition, + &divergenceCycle, + &finalStateOne, + &finalStateTwo, + &v.Winner, + &v.DeletionReason, + &v.DeletionBlockNumber, + &v.DeletionTxHash, + &v.DeletionLogIndex, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if sealBlock != nil { + v.LeafSeal = &LeafMatchSeal{EliminableAt: *sealEliminable, BlockNumber: *sealBlock, TxHash: *sealTx, LogIndex: *sealLog} + } + if revealingParent != nil { + v.Snapshot.Bisection = &MatchBisectionSnapshot{ + RevealingParent: *revealingParent, WaitingLeft: *waitingLeft, WaitingRight: *waitingRight, + SegmentStartPosition: *segmentPosition, SegmentStartCycle: *segmentCycle, CurrentHeight: currentHeight, Responder: *responder, + } + } + if agreeState != nil { + v.Snapshot.Sealed = &MatchSealedSnapshot{ + AgreeState: *agreeState, DivergencePosition: *divergencePosition, DivergenceCycle: *divergenceCycle, + FinalStateOne: *finalStateOne, FinalStateTwo: *finalStateTwo, + } + } + return &v, nil +} + +var matchAdvanceColumns = postgres.ColumnList{ + table.MatchAdvances.ApplicationID, + table.MatchAdvances.EpochIndex, + table.MatchAdvances.TournamentAddress, + table.MatchAdvances.IDHash, + table.MatchAdvances.OtherParent, + table.MatchAdvances.LeftNode, + table.MatchAdvances.SegmentStartPosition, + table.MatchAdvances.EliminableAt, + table.MatchAdvances.BlockNumber, + table.MatchAdvances.TxHash, + table.MatchAdvances.LogIndex, +} + +func matchAdvanceValues(v *MatchAdvanced) []any { + + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.IDHash.Bytes(), + v.OtherParent.Bytes(), + v.LeftNode.Bytes(), + v.SegmentStartPosition, + uint64Expr(v.EliminableAt), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + } +} + +func scanMatchAdvanced(row pgx.Row) (*MatchAdvanced, error) { + var v MatchAdvanced + + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.IDHash, + &v.OtherParent, + &v.LeftNode, + &v.SegmentStartPosition, + &v.EliminableAt, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + + return &v, nil +} + +var bondEventColumns = postgres.ColumnList{ + table.BondEvents.ApplicationID, + table.BondEvents.EpochIndex, + table.BondEvents.TournamentAddress, + table.BondEvents.Type, + table.BondEvents.BlockNumber, + table.BondEvents.TxHash, + table.BondEvents.LogIndex, + table.BondEvents.Recipient, + table.BondEvents.Value, + table.BondEvents.Success, + table.BondEvents.Commitment, + table.BondEvents.Claimer, + table.BondEvents.Payment, + table.BondEvents.Burned, +} + +func bondEventValues(v *BondEvent) []any { + var recipient, value, success, commitment, claimer, payment, burned any + if v.Refund != nil { + recipient, value, success = v.Refund.Recipient.Bytes(), v.Refund.Value, v.Refund.Success + } + if v.Recovery != nil { + commitment, claimer = v.Recovery.Commitment.Bytes(), v.Recovery.Claimer.Bytes() + payment, burned = v.Recovery.Payment, v.Recovery.Burned + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.Type, + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + recipient, + value, + success, + commitment, + claimer, + payment, + burned, + } +} + +func scanBondEvent(row pgx.Row) (*BondEvent, error) { + var v BondEvent + var recipient, claimer *common.Address + var value, payment, burned *Uint256 + var success *bool + var commitment *common.Hash + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.Type, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &recipient, + &value, + &success, + &commitment, + &claimer, + &payment, + &burned, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if recipient != nil { + v.Refund = &PartialBondRefund{Recipient: *recipient, Value: *value, Success: *success} + } + if commitment != nil { + v.Recovery = &BondRecovered{Commitment: *commitment, Claimer: *claimer, Payment: *payment, Burned: *burned} + } + return &v, nil +} diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql index eeb281cb3..bd6f506d9 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql @@ -7,6 +7,8 @@ DROP TRIGGER IF EXISTS "state_hashes_set_updated_at" ON "state_hashes"; DROP INDEX IF EXISTS "state_hashes_input_index_idx"; DROP TABLE IF EXISTS "state_hashes"; +DROP TABLE IF EXISTS "bond_events"; + DROP TRIGGER IF EXISTS "match_advances_set_updated_at" ON "match_advances"; DROP INDEX IF EXISTS "match_advances_block_number_idx"; DROP TABLE IF EXISTS "match_advances"; @@ -23,6 +25,7 @@ DROP TABLE IF EXISTS "commitments"; DROP TRIGGER IF EXISTS "tournaments_set_updated_at" ON "tournaments"; DROP INDEX IF EXISTS "tournaments_parent_match_nonroot_idx"; +DROP INDEX IF EXISTS "tournaments_address_idx"; DROP INDEX IF EXISTS "unique_root_per_epoch_idx"; DROP TABLE IF EXISTS "tournaments"; @@ -76,6 +79,14 @@ DROP FUNCTION IF EXISTS "update_updated_at_column"; DROP FUNCTION IF EXISTS "check_hash_siblings"; DROP TYPE IF EXISTS "WinnerCommitment"; +DROP TYPE IF EXISTS "TournamentKind"; +DROP TYPE IF EXISTS "TournamentStandingState"; +DROP TYPE IF EXISTS "MatchPhase"; +DROP TYPE IF EXISTS "CommitmentSide"; +DROP TYPE IF EXISTS "MatchTimeoutOutcome"; +DROP TYPE IF EXISTS "InnerTournamentDisposition"; +DROP TYPE IF EXISTS "BondDisposition"; +DROP TYPE IF EXISTS "BondEventType"; DROP TYPE IF EXISTS "MatchDeletionReason"; DROP TYPE IF EXISTS "Consensus"; DROP TYPE IF EXISTS "SnapshotPolicy"; @@ -85,6 +96,7 @@ DROP TYPE IF EXISTS "InputCompletionStatus"; DROP TYPE IF EXISTS "ApplicationStatus"; DROP DOMAIN IF EXISTS "hash"; DROP DOMAIN IF EXISTS "uint64"; +DROP DOMAIN IF EXISTS "uint256"; DROP DOMAIN IF EXISTS "ethereum_address"; COMMIT; diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql index edfc73c2a..0d2d9e704 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql @@ -632,6 +632,66 @@ CREATE TABLE "node_config" CREATE TRIGGER "config_set_updated_at" BEFORE UPDATE ON "node_config" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE DOMAIN uint256 AS NUMERIC CHECK ( + VALUE >= 0 AND VALUE = trunc(VALUE) AND VALUE <= + 115792089237316195423570985008687907853269984665640564039457584007913129639935 +); + +CREATE TYPE "TournamentKind" AS ENUM ( + 'LEAF', + 'NON_LEAF' +); + +CREATE TYPE "TournamentStandingState" AS ENUM ( + 'MATCHES_ACTIVE', + 'AWAITING_CLOSURE', + 'ROOT_WINNER', + 'ROOT_FAILED', + 'INNER_WINNER', + 'INNER_ELIMINABLE_NO_WINNER', + 'INNER_ELIMINABLE_WINNER_EXPIRED' +); + +CREATE TYPE "MatchPhase" AS ENUM ( + 'UNINITIALIZED', + 'BISECTING', + 'READY_TO_SEAL', + 'SEALED' +); + +CREATE TYPE "CommitmentSide" AS ENUM ( + 'ONE', + 'TWO' +); + +CREATE TYPE "MatchTimeoutOutcome" AS ENUM ( + 'NONE', + 'ONE_WINS', + 'TWO_WINS', + 'ELIMINATE_BOTH' +); + +CREATE TYPE "InnerTournamentDisposition" AS ENUM ( + 'UNSETTLED', + 'WINNER', + 'ELIMINABLE' +); + +CREATE TYPE "BondDisposition" AS ENUM ( + 'TOURNAMENT_RUNNING', + 'NO_WINNER', + 'RECOVERABLE', + 'RECOVERED' +); + +CREATE TYPE "BondEventType" AS ENUM ( + 'PARTIAL_BOND_REFUND', + 'BOND_RECOVERED' +); + +-- Tournament, commitment, and match views describe current state at their +-- as_of_block. These rows are not a historical snapshot API. Event facts are +-- immutable and are published atomically with each current-state window. CREATE TABLE "tournaments" ( "application_id" INT4 NOT NULL, @@ -643,9 +703,29 @@ CREATE TABLE "tournaments" "level" INT NOT NULL CHECK("level" >= 0), "log2step" INT NOT NULL CHECK("log2step" >= 0), "height" INT NOT NULL CHECK("height" >= 0), + "initial_hash" hash NOT NULL, + "base_cycle" uint256 NOT NULL, + "kind" "TournamentKind" NOT NULL, + "start_instant" uint64 NOT NULL, + "allowance" uint64 NOT NULL, + "creation_block_number" uint64, + "creation_tx_hash" hash, + "creation_log_index" uint64, + "as_of_block" uint64 NOT NULL, + "standing" "TournamentStandingState" NOT NULL, + "accepts_joins" BOOLEAN NOT NULL, + "candidate" hash, "winner_commitment" hash, "final_state_hash" hash, - "finished_at_block" uint64 DEFAULT 0, + "finished_at_block" uint64 NOT NULL DEFAULT 0, + "parent_commitment" hash, + "winner_expires_at" uint64 NOT NULL, + "inner_disposition" "InnerTournamentDisposition", + "inner_parent_commitment" hash, + "inner_paused_allowance" uint64, + "bond_disposition" "BondDisposition" NOT NULL, + "bond_claimer" ethereum_address, + "bond_payment" uint256, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "tournaments_pkey" PRIMARY KEY ("application_id","epoch_index","address"), @@ -658,13 +738,61 @@ CREATE TABLE "tournaments" OR ("level" > 0 AND "parent_tournament_address" IS NOT NULL AND "parent_match_id_hash" IS NOT NULL) ), - CONSTRAINT "tournaments_max_level_gte_level_check" CHECK ("max_level" >= "level") + CONSTRAINT "tournaments_max_level_gte_level_check" CHECK ("max_level" >= "level"), + CONSTRAINT "tournaments_creation_event_check" CHECK ( + ("level" = 0 AND num_nonnulls("creation_block_number", "creation_tx_hash", "creation_log_index") = 0) + OR + ("level" > 0 AND num_nonnulls("creation_block_number", "creation_tx_hash", "creation_log_index") = 3) + ), + CONSTRAINT "tournaments_current_winner_check" CHECK ( + ("standing" IN ('ROOT_WINNER', 'INNER_WINNER') AND "candidate" IS NOT NULL + AND "winner_commitment" IS NOT NULL AND "winner_commitment" = "candidate" AND "final_state_hash" IS NOT NULL) + OR + ("standing" NOT IN ('ROOT_WINNER', 'INNER_WINNER') AND "winner_commitment" IS NULL AND "final_state_hash" IS NULL) + ), + CONSTRAINT "tournaments_current_result_check" CHECK ( + ("standing" IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE') AND "finished_at_block" = 0) + OR + ("standing" NOT IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE') AND "finished_at_block" > 0 + AND "finished_at_block" <= "as_of_block") + ), + CONSTRAINT "tournaments_inner_winner_check" CHECK ( + ("standing" = 'INNER_WINNER' AND "level" > 0 AND "parent_commitment" IS NOT NULL + AND "winner_expires_at" > "as_of_block") + OR + ("standing" <> 'INNER_WINNER' AND "parent_commitment" IS NULL AND "winner_expires_at" = 0) + ), + CONSTRAINT "tournaments_standing_level_check" CHECK ( + ("level" = 0 AND "standing" IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE', 'ROOT_WINNER', 'ROOT_FAILED')) + OR + ("level" > 0 AND "standing" NOT IN ('ROOT_WINNER', 'ROOT_FAILED')) + ), + CONSTRAINT "tournaments_inner_result_check" CHECK ( + ("level" = 0 AND num_nonnulls("inner_disposition", "inner_parent_commitment", "inner_paused_allowance") = 0) + OR + ("level" > 0 AND "inner_disposition" IS NOT NULL AND "inner_paused_allowance" IS NOT NULL AND ( + ("inner_disposition" = 'WINNER' AND "inner_parent_commitment" IS NOT NULL AND "inner_paused_allowance" > 0) + OR + ("inner_disposition" <> 'WINNER' AND "inner_parent_commitment" IS NULL AND "inner_paused_allowance" = 0) + )) + ), + CONSTRAINT "tournaments_bond_recovery_check" CHECK ( + ("bond_disposition" = 'RECOVERABLE' AND "bond_claimer" IS NOT NULL AND "bond_payment" IS NOT NULL) + OR + ("bond_disposition" <> 'RECOVERABLE' AND "bond_claimer" IS NULL AND "bond_payment" IS NULL) + ), + CONSTRAINT "tournaments_creation_event_key" UNIQUE ("application_id", "creation_tx_hash", "creation_log_index") ); CREATE UNIQUE INDEX "unique_root_per_epoch_idx" ON "tournaments"("application_id","epoch_index") WHERE "level" = 0; +-- A tournament address identifies one epoch within an application. This key +-- also supports tournament lookups that do not specify an epoch. +CREATE UNIQUE INDEX "tournaments_address_idx" + ON "tournaments"("application_id","address"); + CREATE INDEX "tournaments_parent_match_nonroot_idx" ON "tournaments"("application_id","epoch_index","parent_tournament_address","parent_match_id_hash") WHERE "level" > 0; @@ -683,10 +811,18 @@ CREATE TABLE "commitments" "submitter_address" ethereum_address NOT NULL, "block_number" uint64 NOT NULL, "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "as_of_block" uint64 NOT NULL, + "claimer" ethereum_address NOT NULL, + "clock_running" BOOLEAN NOT NULL, + "clock_deadline" uint64 NOT NULL, + "clock_allowance" uint64 NOT NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "commitments_pkey" PRIMARY KEY ("application_id","epoch_index","tournament_address","commitment"), + CONSTRAINT "commitments_event_key" UNIQUE ("application_id", "tx_hash", "log_index"), + CONSTRAINT "commitments_paused_clock_check" CHECK ("clock_running" OR "clock_deadline" = 0), CONSTRAINT "commitments_tournament_fkey" FOREIGN KEY ("application_id","epoch_index","tournament_address") REFERENCES "tournaments"("application_id","epoch_index","address") @@ -711,15 +847,68 @@ CREATE TABLE "matches" "left_of_two" hash NOT NULL, "block_number" uint64 NOT NULL, "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "eliminable_at" uint64 NOT NULL, + "seal_eliminable_at" uint64, + "seal_block_number" uint64, + "seal_tx_hash" hash, + "seal_log_index" uint64, + "as_of_block" uint64 NOT NULL, + "phase" "MatchPhase" NOT NULL, + "timeout_outcome" "MatchTimeoutOutcome" NOT NULL, + "deferred_charge" uint64 NOT NULL, + "revealing_parent" hash, + "waiting_left" hash, + "waiting_right" hash, + "segment_start_position" uint256, + "segment_start_cycle" uint256, + "current_height" uint64, + "responder" "CommitmentSide", + "agree_state" hash, + "divergence_position" uint256, + "divergence_cycle" uint256, + "final_state_one" hash, + "final_state_two" hash, "winner" "WinnerCommitment" NOT NULL, "deletion_reason" "MatchDeletionReason" NOT NULL, - "deletion_block_number" uint64 DEFAULT 0, + "deletion_block_number" uint64 NOT NULL DEFAULT 0, "deletion_tx_hash" hash, + "deletion_log_index" uint64, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "matches_pkey" PRIMARY KEY ("application_id","epoch_index","tournament_address","id_hash"), + CONSTRAINT "matches_deletion_columns_set_together" CHECK ( + ("deletion_reason" = 'NOT_DELETED' AND "deletion_block_number" = 0 + AND "deletion_tx_hash" IS NULL AND "deletion_log_index" IS NULL) + OR + ("deletion_reason" <> 'NOT_DELETED' AND "deletion_block_number" > 0 + AND "deletion_tx_hash" IS NOT NULL AND "deletion_log_index" IS NOT NULL) + ), + + CONSTRAINT "matches_seal_tuple_check" CHECK ( + num_nonnulls("seal_eliminable_at", "seal_block_number", "seal_tx_hash", "seal_log_index") IN (0, 4) + ), + CONSTRAINT "matches_event_key" UNIQUE ("application_id", "tx_hash", "log_index"), + CONSTRAINT "matches_seal_event_key" UNIQUE ("application_id", "seal_tx_hash", "seal_log_index"), + CONSTRAINT "matches_deletion_event_key" UNIQUE ("application_id", "deletion_tx_hash", "deletion_log_index"), + CONSTRAINT "matches_current_phase_check" CHECK ( + ("phase" IN ('BISECTING', 'READY_TO_SEAL') AND "deletion_reason" = 'NOT_DELETED' + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder") = 6 + AND num_nonnulls("agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 0 + AND (("phase" = 'BISECTING' AND "current_height" IS NOT NULL) + OR ("phase" = 'READY_TO_SEAL' AND "current_height" IS NULL))) + OR + ("phase" = 'SEALED' AND "deletion_reason" = 'NOT_DELETED' + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder", "current_height") = 0 + AND num_nonnulls("agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 5) + OR + ("phase" = 'UNINITIALIZED' AND "deletion_reason" <> 'NOT_DELETED' AND "timeout_outcome" = 'NONE' AND "deferred_charge" = 0 + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder", "current_height", + "agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 0) + ), + CONSTRAINT "matches_tournament_fkey" FOREIGN KEY ("application_id","epoch_index","tournament_address") REFERENCES "tournaments"("application_id","epoch_index","address") @@ -758,12 +947,15 @@ CREATE TABLE "match_advances" "id_hash" hash NOT NULL, -- keccak256(abi.encode(one,two)) "other_parent" hash NOT NULL, "left_node" hash NOT NULL, + "segment_start_position" uint256 NOT NULL, + "eliminable_at" uint64 NOT NULL, "block_number" uint64 NOT NULL, "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "match_advances_pkey" - PRIMARY KEY ("application_id","epoch_index","tournament_address","id_hash","other_parent"), + PRIMARY KEY ("application_id","tx_hash","log_index"), CONSTRAINT "match_advances_matches_fkey" FOREIGN KEY ("application_id","epoch_index","tournament_address","id_hash") @@ -772,12 +964,51 @@ CREATE TABLE "match_advances" ); CREATE INDEX "match_advances_block_number_idx" - ON "match_advances"("application_id","epoch_index","tournament_address","id_hash","block_number"); + ON "match_advances"("application_id","epoch_index","tournament_address","id_hash","block_number","log_index","tx_hash"); CREATE TRIGGER "match_advances_set_updated_at" BEFORE UPDATE ON "match_advances" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TABLE "bond_events" +( + "application_id" INT4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "tournament_address" ethereum_address NOT NULL, + "type" "BondEventType" NOT NULL, + "block_number" uint64 NOT NULL, + "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "recipient" ethereum_address, + "value" uint256, + "success" BOOLEAN, + "commitment" hash, + "claimer" ethereum_address, + "payment" uint256, + "burned" uint256, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "bond_events_pkey" PRIMARY KEY ("application_id", "tx_hash", "log_index"), + CONSTRAINT "bond_events_tournament_fkey" FOREIGN KEY ("application_id", "epoch_index", "tournament_address") + REFERENCES "tournaments"("application_id", "epoch_index", "address") ON DELETE CASCADE, + CONSTRAINT "bond_events_variant_check" CHECK ( + ("type" = 'PARTIAL_BOND_REFUND' AND num_nonnulls("recipient", "value", "success") = 3 + AND num_nonnulls("commitment", "claimer", "payment", "burned") = 0) + OR + ("type" = 'BOND_RECOVERED' AND num_nonnulls("recipient", "value", "success") = 0 + AND num_nonnulls("commitment", "claimer", "payment", "burned") = 4) + ) +); + +CREATE INDEX "bond_events_application_order_idx" + ON "bond_events"("application_id", "block_number", "log_index", "tx_hash"); + +CREATE INDEX "bond_events_tournament_order_idx" + ON "bond_events"("application_id", "epoch_index", "tournament_address", "block_number", "log_index", "tx_hash"); + +CREATE TRIGGER "bond_events_set_updated_at" BEFORE UPDATE ON "bond_events" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + CREATE TABLE "state_hashes" ( "input_epoch_application_id" int4 NOT NULL, diff --git a/internal/repository/postgres/tournament.go b/internal/repository/postgres/tournament.go index 7281dad18..e86a750e7 100644 --- a/internal/repository/postgres/tournament.go +++ b/internal/repository/postgres/tournament.go @@ -5,227 +5,57 @@ package postgres import ( "context" - "database/sql" "errors" "fmt" + "math" - "github.com/ethereum/go-ethereum/common" - "github.com/go-jet/jet/v2/postgres" - - "github.com/cartesi/rollups-node/internal/model" + . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" ) -// ------------------------ TournamentRepository Methods ------------------------ // - -func (r *PostgresRepository) CreateTournament( - ctx context.Context, - nameOrAddress string, - t *model.Tournament, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - insertStmt := table.Tournaments. - INSERT( - table.Tournaments.ApplicationID, - table.Tournaments.EpochIndex, - table.Tournaments.Address, - table.Tournaments.ParentTournamentAddress, - table.Tournaments.ParentMatchIDHash, - table.Tournaments.MaxLevel, - table.Tournaments.Level, - table.Tournaments.Log2step, - table.Tournaments.Height, - table.Tournaments.WinnerCommitment, - table.Tournaments.FinalStateHash, - table.Tournaments.FinishedAtBlock, - ) - - parentAddress := postgres.NULL - if t.ParentTournamentAddress != nil { - parentAddress = postgres.Bytea(t.ParentTournamentAddress.Bytes()) - } - parentMatch := postgres.NULL - if t.ParentMatchIDHash != nil { - parentMatch = postgres.Bytea(t.ParentMatchIDHash.Bytes()) - } - winnerCommitment := postgres.NULL - if t.WinnerCommitment != nil { - winnerCommitment = postgres.Bytea(t.WinnerCommitment.Bytes()) - } - finalState := postgres.NULL - if t.FinalStateHash != nil { - finalState = postgres.Bytea(t.FinalStateHash.Bytes()) +func (r *PostgresRepository) CreateTournament(ctx context.Context, nameOrAddress string, value *Tournament) error { + if value == nil { + return fmt.Errorf("cannot create a nil tournament") } - - selectQuery := table.Application.SELECT( - table.Application.ID, - uint64Expr(t.EpochIndex), - postgres.Bytea(t.Address.Bytes()), - parentAddress, - parentMatch, - uint64Expr(t.MaxLevel), - uint64Expr(t.Level), - uint64Expr(t.Log2Step), - uint64Expr(t.Height), - winnerCommitment, - finalState, - uint64Expr(t.FinishedAtBlock), - ).WHERE( - whereClause, - ) - - // Tournament addresses come from the chain and may be observed again after - // an interrupted shutdown. Ignore only an exact replay of the tournament - // identity; other conflicts, such as a different root for the same epoch, - // must still surface as errors. - sqlStr, args := insertStmt. - QUERY(selectQuery). - ON_CONFLICT( - table.Tournaments.ApplicationID, - table.Tournaments.EpochIndex, - table.Tournaments.Address, - ). - DO_NOTHING(). - Sql() - _, err := r.db.Exec(ctx, sqlStr, args...) - - return err -} - -func (r *PostgresRepository) UpdateTournament( - ctx context.Context, - nameOrAddress string, - t *model.Tournament, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - winnerCommitment := postgres.NULL - if t.WinnerCommitment != nil { - winnerCommitment = postgres.Bytea(t.WinnerCommitment.Bytes()) - } - finalState := postgres.NULL - if t.FinalStateHash != nil { - finalState = postgres.Bytea(t.FinalStateHash.Bytes()) - } - - updateStmt := table.Tournaments. - UPDATE( - table.Tournaments.WinnerCommitment, - table.Tournaments.FinalStateHash, - table.Tournaments.FinishedAtBlock, - ). - SET( - winnerCommitment, - finalState, - t.FinishedAtBlock, - ). - FROM( - table.Application, - ). - WHERE(postgres.AND( - whereClause, - table.Tournaments.ApplicationID.EQ(postgres.Int(t.ApplicationID)), - table.Tournaments.EpochIndex.EQ(uint64Expr(t.EpochIndex)), - table.Tournaments.Address.EQ(postgres.Bytea(t.Address.Bytes())), - )) - - sqlStr, args := updateStmt.Sql() - cmd, err := r.db.Exec(ctx, sqlStr, args...) + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + stmt := tournamentUpsert(value, applicationID) + sqlStr, args := stmt.Sql() + command, err := r.db.Exec(ctx, sqlStr, args...) if err != nil { return err } - if cmd.RowsAffected() == 0 { - return repository.ErrNotFound + if command.RowsAffected() == 0 { + return fmt.Errorf("%w: tournament %s", repository.ErrTournamentEventConflict, value.Address) } return nil } -func (r *PostgresRepository) GetTournament( - ctx context.Context, - nameOrAddress string, - address string, -) (*model.Tournament, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - tournamentAddress := common.HexToAddress(address) - sel := table.Tournaments. - SELECT( - table.Tournaments.ApplicationID, - table.Tournaments.EpochIndex, - table.Tournaments.Address, - table.Tournaments.ParentTournamentAddress, - table.Tournaments.ParentMatchIDHash, - table.Tournaments.MaxLevel, - table.Tournaments.Level, - table.Tournaments.Log2step, - table.Tournaments.Height, - table.Tournaments.WinnerCommitment, - table.Tournaments.FinalStateHash, - table.Tournaments.FinishedAtBlock, - table.Tournaments.CreatedAt, - table.Tournaments.UpdatedAt, - ). - FROM( - table.Tournaments. - INNER_JOIN(table.Application, - table.Tournaments.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - whereClause. - AND(table.Tournaments.Address.EQ(postgres.Bytea(tournamentAddress.Bytes()))), - ) - +func (r *PostgresRepository) GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) { + sel := table.Tournaments.SELECT(tournamentColumns, table.Tournaments.CreatedAt, table.Tournaments.UpdatedAt). + FROM(table.Tournaments.INNER_JOIN(table.Application, table.Tournaments.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Tournaments.Address.EQ(postgres.Bytea(common.HexToAddress(address).Bytes())), + )) sqlStr, args := sel.Sql() - row := r.db.QueryRow(ctx, sqlStr, args...) - - var t model.Tournament - err := row.Scan( - &t.ApplicationID, - &t.EpochIndex, - &t.Address, - &t.ParentTournamentAddress, - &t.ParentMatchIDHash, - &t.MaxLevel, - &t.Level, - &t.Log2Step, - &t.Height, - &t.WinnerCommitment, - &t.FinalStateHash, - &t.FinishedAtBlock, - &t.CreatedAt, - &t.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { + value, err := scanTournament(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - if err != nil { - return nil, err - } - return &t, nil + return value, err } func (r *PostgresRepository) ListTournaments( - ctx context.Context, - nameOrAddress string, - f repository.TournamentFilter, - p repository.Pagination, - descending bool, -) ([]*model.Tournament, uint64, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - fromClause := table.Tournaments. - INNER_JOIN(table.Application, - table.Tournaments.ApplicationID.EQ(table.Application.ID), - ) - - conditions := []postgres.BoolExpression{whereClause} + ctx context.Context, nameOrAddress string, f repository.TournamentFilter, p repository.Pagination, descending bool, +) ([]*Tournament, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Tournaments.INNER_JOIN(table.Application, table.Tournaments.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} if f.EpochIndex != nil { conditions = append(conditions, table.Tournaments.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) } @@ -238,90 +68,45 @@ func (r *PostgresRepository) ListTournaments( if f.ParentMatchIDHash != nil { conditions = append(conditions, table.Tournaments.ParentMatchIDHash.EQ(postgres.Bytea(f.ParentMatchIDHash.Bytes()))) } - tx, err := beginReadTx(ctx, r.db) if err != nil { return nil, 0, err } defer tx.Rollback(ctx) //nolint:errcheck - - countStmt := table.Tournaments.SELECT(postgres.COUNT(postgres.STAR)). - FROM(fromClause).WHERE(postgres.AND(conditions...)) + countStmt := table.Tournaments.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) total, err := countFromTx(ctx, tx, countStmt) - if err != nil { - return nil, 0, err - } - if total == 0 { - return nil, 0, nil + if err != nil || total == 0 { + return nil, total, err } - - sel := table.Tournaments. - SELECT( - table.Tournaments.ApplicationID, - table.Tournaments.EpochIndex, - table.Tournaments.Address, - table.Tournaments.ParentTournamentAddress, - table.Tournaments.ParentMatchIDHash, - table.Tournaments.MaxLevel, - table.Tournaments.Level, - table.Tournaments.Log2step, - table.Tournaments.Height, - table.Tournaments.WinnerCommitment, - table.Tournaments.FinalStateHash, - table.Tournaments.FinishedAtBlock, - table.Tournaments.CreatedAt, - table.Tournaments.UpdatedAt, - ). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + sel := table.Tournaments.SELECT(tournamentColumns, table.Tournaments.CreatedAt, table.Tournaments.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) if descending { - sel = sel.ORDER_BY(table.Tournaments.EpochIndex.DESC(), table.Tournaments.Level.DESC()) + sel = sel.ORDER_BY(table.Tournaments.EpochIndex.DESC(), table.Tournaments.Level.DESC(), table.Tournaments.Address.DESC()) } else { - sel = sel.ORDER_BY(table.Tournaments.EpochIndex.ASC(), table.Tournaments.Level.ASC()) + sel = sel.ORDER_BY(table.Tournaments.EpochIndex.ASC(), table.Tournaments.Level.ASC(), table.Tournaments.Address.ASC()) } - if p.Limit > 0 { sel = sel.LIMIT(int64(p.Limit)) } if p.Offset > 0 { sel = sel.OFFSET(int64(p.Offset)) } - sqlStr, args := sel.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { return nil, 0, err } defer rows.Close() - - var tournaments []*model.Tournament + var values []*Tournament for rows.Next() { - var t model.Tournament - err := rows.Scan( - &t.ApplicationID, - &t.EpochIndex, - &t.Address, - &t.ParentTournamentAddress, - &t.ParentMatchIDHash, - &t.MaxLevel, - &t.Level, - &t.Log2Step, - &t.Height, - &t.WinnerCommitment, - &t.FinalStateHash, - &t.FinishedAtBlock, - &t.CreatedAt, - &t.UpdatedAt, - ) + value, err := scanTournament(rows) if err != nil { return nil, 0, err } - tournaments = append(tournaments, &t) + values = append(values, value) } if err := rows.Err(); err != nil { return nil, 0, err } - - return tournaments, total, nil + return values, total, nil } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 970a7e5f0..e0a261ad7 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -32,6 +32,9 @@ var ( // ErrInvalidStateProof means an advance result or epoch publication did not // include the complete three-leaf machine state proof. ErrInvalidStateProof = errors.New("invalid machine state proof") + // ErrTournamentEventConflict means an observation contradicts an already + // stored immutable tournament fact or would replace a newer snapshot. + ErrTournamentEventConflict = errors.New("tournament observation conflicts with stored data") // ErrInputLogIdentityConflict indicates an input insert conflicted with a // stored row on the L1 log identity (transaction_hash, log_index) under a @@ -71,9 +74,10 @@ func ExecutableApplicationsFilter() ApplicationFilter { } type EpochFilter struct { - Status []EpochStatus - BeforeBlock *uint64 - IndexRange *Range + Status []EpochStatus + BeforeBlock *uint64 + IndexRange *Range + HasTournament *bool } type InputFilter struct { @@ -128,6 +132,11 @@ type MatchFilter struct { TournamentAddress *string } +type BondEventFilter struct { + EpochIndex *uint64 + TournamentAddress *common.Address +} + type WithdrawalFilter struct { AccountIndex *uint64 } @@ -305,11 +314,10 @@ type StateHashRepository interface { } type TournamentRepository interface { - // CreateTournament is idempotent only for an exact - // (application_id, epoch_index, address) replay. Other constraint conflicts - // remain errors. + // CreateTournament preserves immutable facts for an existing + // (application_id, epoch_index, address) and can refresh its current snapshot. + // Conflicting facts and older snapshots return ErrTournamentEventConflict. CreateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error - UpdateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) ListTournaments(ctx context.Context, nameOrAddress string, f TournamentFilter, p Pagination, descending bool) ([]*Tournament, uint64, error) @@ -323,15 +331,21 @@ type CommitmentRepository interface { type MatchRepository interface { CreateMatch(ctx context.Context, nameOrAddress string, m *Match) error - UpdateMatch(ctx context.Context, nameOrAddress string, m *Match) error GetMatch(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string) (*Match, error) ListMatches(ctx context.Context, nameOrAddress string, f MatchFilter, p Pagination, descending bool) ([]*Match, uint64, error) } type MatchAdvancedRepository interface { CreateMatchAdvanced(ctx context.Context, nameOrAddress string, m *MatchAdvanced) error - GetMatchAdvanced(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string, parentHex string) (*MatchAdvanced, error) - ListMatchAdvances(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string, p Pagination, descending bool) ([]*MatchAdvanced, uint64, error) + GetMatchAdvanced(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, txHash common.Hash, logIndex uint64) (*MatchAdvanced, error) + ListMatchAdvances(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, p Pagination, descending bool) ([]*MatchAdvanced, uint64, error) +} + +type BondEventRepository interface { + GetBondEvent(ctx context.Context, nameOrAddress string, txHash common.Hash, logIndex uint64) (*BondEvent, error) + ListBondEvents(ctx context.Context, nameOrAddress string, f BondEventFilter, p Pagination, descending bool) ([]*BondEvent, uint64, error) } type BulkOperationsRepository interface { @@ -446,6 +460,7 @@ type Repository interface { CommitmentRepository MatchRepository MatchAdvancedRepository + BondEventRepository BulkOperationsRepository NodeConfigRepository ClaimerRepository diff --git a/internal/repository/repotest/builders.go b/internal/repository/repotest/builders.go index aad1d883f..245c338b2 100644 --- a/internal/repository/repotest/builders.go +++ b/internal/repository/repotest/builders.go @@ -300,6 +300,16 @@ func NewTournamentBuilder(appID int64) *TournamentBuilder { Level: 0, Log2Step: 20, Height: 2, + Kind: TournamentKindNonLeaf, + InitialHash: UniqueHash(), + StartInstant: 1, + Allowance: 100, + Snapshot: TournamentSnapshot{ + AsOfBlock: 100, + Standing: TournamentStandingAwaitingClosure, + AcceptsJoins: true, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionTournamentRunning}, + }, }, } } @@ -316,12 +326,19 @@ func (b *TournamentBuilder) WithAddress(addr common.Address) *TournamentBuilder func (b *TournamentBuilder) WithLevel(l uint64) *TournamentBuilder { b.tournament.Level = l + if l == b.tournament.MaxLevel-1 { + b.tournament.Kind = TournamentKindLeaf + } else { + b.tournament.Kind = TournamentKindNonLeaf + } return b } func (b *TournamentBuilder) WithParent(addr common.Address, matchIDHash common.Hash) *TournamentBuilder { b.tournament.ParentTournamentAddress = &addr b.tournament.ParentMatchIDHash = &matchIDHash + b.tournament.CreationEvent = &TournamentCreationEvent{BlockNumber: 100, TxHash: UniqueHash()} + b.tournament.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentUnsettled} return b } @@ -340,6 +357,7 @@ type CommitmentBuilder struct { } func NewCommitmentBuilder(appID int64) *CommitmentBuilder { + submitter := UniqueAddress() return &CommitmentBuilder{ commitment: &Commitment{ ApplicationID: appID, @@ -347,9 +365,14 @@ func NewCommitmentBuilder(appID int64) *CommitmentBuilder { TournamentAddress: UniqueAddress(), Commitment: UniqueHash(), FinalStateHash: UniqueHash(), - SubmitterAddress: UniqueAddress(), + SubmitterAddress: submitter, BlockNumber: 100, TxHash: UniqueHash(), + Snapshot: CommitmentSnapshot{ + AsOfBlock: 100, + Claimer: submitter, + ClockAllowance: 100, + }, }, } } @@ -397,6 +420,18 @@ func NewMatchBuilder(appID int64) *MatchBuilder { TxHash: UniqueHash(), Winner: WinnerCommitment_NONE, DeletionReason: MatchDeletionReason_NOT_DELETED, + Snapshot: MatchSnapshot{ + AsOfBlock: 100, + Phase: MatchPhaseBisecting, + TimeoutOutcome: MatchTimeoutNone, + Bisection: &MatchBisectionSnapshot{ + RevealingParent: UniqueHash(), + WaitingLeft: UniqueHash(), + WaitingRight: UniqueHash(), + CurrentHeight: new(uint64(2)), + Responder: CommitmentSideOne, + }, + }, }, } } @@ -431,8 +466,14 @@ func (b *MatchBuilder) WithCommitmentTwo(h common.Hash) *MatchBuilder { return b } -func (b *MatchBuilder) WithDeletionReason(r MatchDeletionReason) *MatchBuilder { +func (b *MatchBuilder) WithDeletion(r MatchDeletionReason, block uint64, txHash common.Hash) *MatchBuilder { b.match.DeletionReason = r + b.match.DeletionBlockNumber = block + b.match.DeletionTxHash = new(txHash) + b.match.DeletionLogIndex = new(uint64(0)) + b.match.Snapshot = MatchSnapshot{ + AsOfBlock: max(b.match.Snapshot.AsOfBlock, block), Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, + } return b } diff --git a/internal/repository/repotest/match_advanced_test_cases.go b/internal/repository/repotest/match_advanced_test_cases.go index 09c900655..eb7e52fbb 100644 --- a/internal/repository/repotest/match_advanced_test_cases.go +++ b/internal/repository/repotest/match_advanced_test_cases.go @@ -4,8 +4,6 @@ package repotest import ( - "encoding/hex" - "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/common" ) @@ -85,8 +83,7 @@ func (s *MatchAdvancedSuite) TestGetMatchAdvanced() { got, err := s.Repo.GetMatchAdvanced( s.Ctx, seed.App.IApplicationAddress.String(), - 0, tournAddr.String(), matchIDHash.Hex(), - hex.EncodeToString(ma.OtherParent[:])) + 0, tournAddr.String(), matchIDHash.Hex(), ma.TxHash, ma.LogIndex) s.Require().NoError(err) s.Equal(ma.IDHash, got.IDHash) s.Equal(ma.OtherParent, got.OtherParent) @@ -97,8 +94,7 @@ func (s *MatchAdvancedSuite) TestGetMatchAdvanced() { nonExistent := UniqueHash() got, err := s.Repo.GetMatchAdvanced( s.Ctx, seed.App.IApplicationAddress.String(), - 0, tournAddr.String(), matchIDHash.Hex(), - hex.EncodeToString(nonExistent[:])) + 0, tournAddr.String(), matchIDHash.Hex(), nonExistent, 0) s.Require().NoError(err) s.Nil(got) }) diff --git a/internal/repository/repotest/match_test_cases.go b/internal/repository/repotest/match_test_cases.go index 6f5515c46..6ef6d9c21 100644 --- a/internal/repository/repotest/match_test_cases.go +++ b/internal/repository/repotest/match_test_cases.go @@ -4,7 +4,6 @@ package repotest import ( - . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/common" ) @@ -99,33 +98,6 @@ func (s *MatchSuite) TestGetMatch() { }) } -func (s *MatchSuite) TestUpdateMatch() { - s.Run("UpdatesWinner", func() { - setup := s.setupTournamentWithCommitments() - match := NewMatchBuilder(setup.seed.App.ID). - WithEpochIndex(0). - WithTournamentAddress(setup.tournAddr). - WithCommitmentOne(setup.commitHash1). - WithCommitmentTwo(setup.commitHash2). - Build() - - err := s.Repo.CreateMatch( - s.Ctx, setup.seed.App.IApplicationAddress.String(), match) - s.Require().NoError(err) - - match.Winner = WinnerCommitment_ONE - err = s.Repo.UpdateMatch( - s.Ctx, setup.seed.App.IApplicationAddress.String(), match) - s.Require().NoError(err) - - got, err := s.Repo.GetMatch( - s.Ctx, setup.seed.App.IApplicationAddress.String(), - 0, setup.tournAddr.String(), match.IDHash.Hex()) - s.Require().NoError(err) - s.Equal(WinnerCommitment_ONE, got.Winner) - }) -} - func (s *MatchSuite) TestListMatches() { s.Run("EmptyResult", func() { seed := Seed(s.Ctx, s.T(), s.Repo) diff --git a/internal/repository/repotest/tournament_test_cases.go b/internal/repository/repotest/tournament_test_cases.go index 8dcddcfbb..24e60f9bc 100644 --- a/internal/repository/repotest/tournament_test_cases.go +++ b/internal/repository/repotest/tournament_test_cases.go @@ -39,7 +39,6 @@ func (s *TournamentSuite) TestCreateTournament() { s.Ctx, seed.App.IApplicationAddress.String(), first)) replay := *first - replay.MaxLevel++ err := s.Repo.CreateTournament( s.Ctx, seed.App.IApplicationAddress.String(), &replay) s.Require().NoError(err) @@ -51,6 +50,21 @@ func (s *TournamentSuite) TestCreateTournament() { s.Equal(first.MaxLevel, got.MaxLevel, "an exact replay must not overwrite the first observation") }) + s.Run("ConflictingDescriptorIsAnError", func() { + seed := s.seedWithEpoch() + first := NewTournamentBuilder(seed.App.ID).Build() + s.Require().NoError(s.Repo.CreateTournament(s.Ctx, seed.App.Name, first)) + + conflicting := *first + conflicting.MaxLevel++ + s.Require().Error(s.Repo.CreateTournament(s.Ctx, seed.App.Name, &conflicting)) + + got, err := s.Repo.GetTournament(s.Ctx, seed.App.Name, first.Address.String()) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(first.MaxLevel, got.MaxLevel) + }) + s.Run("DifferentRootForSameEpochIsAnError", func() { seed := s.seedWithEpoch() first := NewTournamentBuilder(seed.App.ID). @@ -92,30 +106,6 @@ func (s *TournamentSuite) TestGetTournament() { }) } -func (s *TournamentSuite) TestUpdateTournament() { - s.Run("UpdatesFields", func() { - seed := s.seedWithEpoch() - tournament := NewTournamentBuilder(seed.App.ID). - WithEpochIndex(0).Build() - - err := s.Repo.CreateTournament( - s.Ctx, seed.App.IApplicationAddress.String(), tournament) - s.Require().NoError(err) - - winnerHash := UniqueHash() - tournament.WinnerCommitment = &winnerHash - err = s.Repo.UpdateTournament( - s.Ctx, seed.App.IApplicationAddress.String(), tournament) - s.Require().NoError(err) - - got, err := s.Repo.GetTournament( - s.Ctx, seed.App.IApplicationAddress.String(), tournament.Address.String()) - s.Require().NoError(err) - s.Require().NotNil(got.WinnerCommitment) - s.Equal(winnerHash, *got.WinnerCommitment) - }) -} - func (s *TournamentSuite) TestListTournaments() { s.Run("EmptyResult", func() { seed := s.seedWithEpoch() From f516278138b52b539ba095a4293ea0c129c4e11e Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:16 -0300 Subject: [PATCH 12/41] feat(repository): publish dispute windows Publish every root and child tournament batch for one application in one transaction. Store event facts, current views, and the observation cursor together, with parent batches before child batches. Lock the application before child rows. Accept identical event replay and reject conflicting immutable facts or older snapshots. Replace the old partial-write path rather than keeping two publication mechanisms. Tests cover replay, conflicts, constraints, rollback, and the rule that a failed window cannot advance the cursor or leave partial projections. --- internal/repository/postgres/bulk.go | 196 -------- .../postgres/passive_observer_test.go | 460 ++++++++++++++++++ .../000001_create_initial_schema.up.sql | 4 +- .../repository/postgres/tournament_events.go | 374 ++++++++++++++ .../postgres/tournament_events_test.go | 199 ++++++++ .../postgres/tournament_integrity_test.go | 274 +++++++++++ internal/repository/repository.go | 16 +- .../repository/repotest/bulk_test_cases.go | 111 ++--- 8 files changed, 1379 insertions(+), 255 deletions(-) create mode 100644 internal/repository/postgres/passive_observer_test.go create mode 100644 internal/repository/postgres/tournament_events.go create mode 100644 internal/repository/postgres/tournament_events_test.go create mode 100644 internal/repository/postgres/tournament_integrity_test.go diff --git a/internal/repository/postgres/bulk.go b/internal/repository/postgres/bulk.go index 71f785ffa..6a15fdbbe 100644 --- a/internal/repository/postgres/bulk.go +++ b/internal/repository/postgres/bulk.go @@ -758,159 +758,6 @@ func (r *PostgresRepository) StoreClaimAndProofs(ctx context.Context, epoch *mod return tx.Commit(ctx) } -func insertCommitments(ctx context.Context, tx pgx.Tx, appID int64, commitments []*model.Commitment) error { - if len(commitments) < 1 { - return nil - } - - stmt := table.Commitments.INSERT( - table.Commitments.ApplicationID, - table.Commitments.EpochIndex, - table.Commitments.TournamentAddress, - table.Commitments.Commitment, - table.Commitments.FinalStateHash, - table.Commitments.SubmitterAddress, - table.Commitments.BlockNumber, - table.Commitments.TxHash, - ) - for _, c := range commitments { - stmt = stmt.VALUES( - appID, - c.EpochIndex, - c.TournamentAddress, - c.Commitment[:], - c.FinalStateHash[:], - c.SubmitterAddress[:], - c.BlockNumber, - c.TxHash[:], - ) - } - - sqlStr, args := stmt.Sql() - _, err := tx.Exec(ctx, sqlStr, args...) - return err -} - -func insertMatches(ctx context.Context, tx pgx.Tx, appID int64, matches []*model.Match) error { - if len(matches) < 1 { - return nil - } - - stmt := table.Matches.INSERT( - table.Matches.ApplicationID, - table.Matches.EpochIndex, - table.Matches.TournamentAddress, - table.Matches.IDHash, - table.Matches.CommitmentOne, - table.Matches.CommitmentTwo, - table.Matches.LeftOfTwo, - table.Matches.BlockNumber, - table.Matches.TxHash, - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - ) - for _, m := range matches { - stmt = stmt.VALUES( - appID, - m.EpochIndex, - m.TournamentAddress[:], - m.IDHash[:], - m.CommitmentOne[:], - m.CommitmentTwo[:], - m.LeftOfTwo, - m.BlockNumber, - m.TxHash, - m.Winner, - m.DeletionReason, - m.DeletionBlockNumber, - m.DeletionTxHash, - ) - } - - sqlStr, args := stmt.Sql() - _, err := tx.Exec(ctx, sqlStr, args...) - return err -} - -func insertMatchAdvanced(ctx context.Context, tx pgx.Tx, appID int64, matchAdvanced []*model.MatchAdvanced) error { - if len(matchAdvanced) < 1 { - return nil - } - - stmt := table.MatchAdvances.INSERT( - table.MatchAdvances.ApplicationID, - table.MatchAdvances.EpochIndex, - table.MatchAdvances.TournamentAddress, - table.MatchAdvances.IDHash, - table.MatchAdvances.OtherParent, - table.MatchAdvances.LeftNode, - table.MatchAdvances.BlockNumber, - table.MatchAdvances.TxHash, - ) - for _, ma := range matchAdvanced { - stmt = stmt.VALUES( - appID, - ma.EpochIndex, - ma.TournamentAddress, - ma.IDHash, - ma.OtherParent, - ma.LeftNode, - ma.BlockNumber, - ma.TxHash, - ) - } - - sqlStr, args := stmt.Sql() - _, err := tx.Exec(ctx, sqlStr, args...) - return err -} - -func updateMatches(ctx context.Context, tx pgx.Tx, appID int64, matches []*model.Match) error { - if len(matches) == 0 { - return nil - } - - batch := &pgx.Batch{} - for _, m := range matches { - updStmt := table.Matches.UPDATE( - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - ).SET( - m.Winner, - m.DeletionReason, - m.DeletionBlockNumber, - m.DeletionTxHash, - ).WHERE( - table.Matches.ApplicationID.EQ(postgres.Int64(appID)). - AND(table.Matches.EpochIndex.EQ(uint64Expr(m.EpochIndex))). - AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(m.TournamentAddress.Bytes()))). - AND(table.Matches.IDHash.EQ(postgres.Bytea(m.IDHash.Bytes()))), - ) - sqlStr, args := updStmt.Sql() - batch.Queue(sqlStr, args...) - } - - br := tx.SendBatch(ctx, batch) - - for _, m := range matches { - cmd, err := br.Exec() - if err != nil { - br.Close() - return err - } - if cmd.RowsAffected() == 0 { - br.Close() - return fmt.Errorf("no match found for update: app %d, epoch %d, tournament %s, idHash %s", - m.ApplicationID, m.EpochIndex, m.TournamentAddress.Hex(), m.IDHash.Hex()) - } - } - return br.Close() -} - func updateLastProcessedBlock(ctx context.Context, tx pgx.Tx, appID int64, lastProcessedBlock uint64) error { lastBlock := uint64Expr(lastProcessedBlock) appUpdateStmt := table.Application. @@ -929,46 +776,3 @@ func updateLastProcessedBlock(ctx context.Context, tx pgx.Tx, appID int64, lastP _, err := tx.Exec(ctx, sqlStr, args...) return err } - -func (r *PostgresRepository) StoreTournamentEvents( - ctx context.Context, - appID int64, - commitments []*model.Commitment, - matches []*model.Match, - matchAdvanced []*model.MatchAdvanced, - matchDeleted []*model.Match, - lastProcessedBlock uint64, -) error { - tx, err := r.db.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) //nolint:errcheck - - err = insertCommitments(ctx, tx, appID, commitments) - if err != nil { - return err - } - - err = insertMatches(ctx, tx, appID, matches) - if err != nil { - return err - } - - err = insertMatchAdvanced(ctx, tx, appID, matchAdvanced) - if err != nil { - return err - } - - err = updateMatches(ctx, tx, appID, matchDeleted) - if err != nil { - return err - } - - err = updateLastProcessedBlock(ctx, tx, appID, lastProcessedBlock) - if err != nil { - return err - } - - return tx.Commit(ctx) -} diff --git a/internal/repository/postgres/passive_observer_test.go b/internal/repository/postgres/passive_observer_test.go new file mode 100644 index 000000000..fcfeacc8b --- /dev/null +++ b/internal/repository/postgres/passive_observer_test.go @@ -0,0 +1,460 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const observerCheckpoint = uint64(100) + +func observerUint256(t *testing.T, text string) Uint256 { + t.Helper() + number, ok := new(big.Int).SetString(text, 10) + require.True(t, ok) + value, err := Uint256FromBig(number) + require.NoError(t, err) + return value +} + +// newPassiveObserverWindow has all eight event types and final current views. +// Its inner match was advanced twice, sealed, deleted, and its bond recovered. +func newPassiveObserverWindow(t *testing.T) (*PostgresRepository, *Application, []*repository.TournamentEventBatch) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, observerCheckpoint)) + root := repotest.NewTournamentBuilder(app.ID).Build() + root.MaxLevel = 2 + makePair := func(tournament *Tournament) (*Commitment, *Commitment, *Match) { + one := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithTournamentAddress(tournament.Address). + WithCommitmentOne(one.Commitment).WithCommitmentTwo(two.Commitment).Build() + one.BlockNumber, two.BlockNumber, match.BlockNumber = 10, 11, 11 + match.TxHash, match.LogIndex = two.TxHash, 1 + match.EliminableAt = 50 + match.Winner = WinnerCommitment_ONE + match.DeletionReason = MatchDeletionReason_CHILD_TOURNAMENT + match.DeletionBlockNumber, match.DeletionTxHash, match.DeletionLogIndex = 80, new(repotest.UniqueHash()), new(uint64(0)) + match.Snapshot = MatchSnapshot{AsOfBlock: observerCheckpoint, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone} + return one, two, match + } + one, two, parentMatch := makePair(root) + child := repotest.NewTournamentBuilder(app.ID).WithLevel(1).WithParent(root.Address, parentMatch.IDHash).Build() + child.MaxLevel, child.Kind, child.Log2Step = 2, TournamentKindLeaf, 0 + child.CreationEvent.BlockNumber = 50 + child.StartInstant = 50 + childOne, childTwo, childMatch := makePair(child) + childOne.BlockNumber, childTwo.BlockNumber, childMatch.BlockNumber = 51, 52, 52 + childMatch.DeletionReason, childMatch.DeletionBlockNumber = MatchDeletionReason_STEP, 70 + childMatch.LeafSeal = &LeafMatchSeal{EliminableAt: 90, BlockNumber: 60, TxHash: repotest.UniqueHash()} + root.Snapshot = TournamentSnapshot{ + AsOfBlock: observerCheckpoint, Standing: TournamentStandingRootWinner, + Candidate: new(one.Commitment), WinnerCommitment: new(one.Commitment), FinalStateHash: new(one.FinalStateHash), + FinishedAtBlock: 90, + BondRecovery: TournamentBondRecovery{ + Disposition: BondDispositionRecoverable, Claimer: new(one.SubmitterAddress), Payment: new(Uint256), + }, + } + child.Snapshot = TournamentSnapshot{ + AsOfBlock: observerCheckpoint, Standing: TournamentStandingInnerWinner, + Candidate: new(childOne.Commitment), WinnerCommitment: new(childOne.Commitment), FinalStateHash: new(childOne.FinalStateHash), + FinishedAtBlock: 80, ParentCommitment: new(parentMatch.CommitmentOne), WinnerExpiresAt: 150, + InnerResult: &TournamentInnerResult{ + Disposition: InnerTournamentWinner, ParentCommitment: new(parentMatch.CommitmentOne), PausedAllowance: 50, + }, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}, + } + childOne.Snapshot.Claimer = common.Address{} + firstAdvance := repotest.NewMatchAdvancedBuilder(app.ID).WithTournamentAddress(child.Address).WithIDHash(childMatch.IDHash).Build() + firstAdvance.BlockNumber, firstAdvance.LogIndex, firstAdvance.EliminableAt = 55, 0, 85 + firstAdvance.SegmentStartPosition = observerUint256(t, "18446744073709551616") + secondAdvance := *firstAdvance + secondAdvance.LogIndex, secondAdvance.EliminableAt = 1, 86 + secondAdvance.LeftNode = repotest.UniqueHash() + refund := &BondEvent{ + ApplicationID: app.ID, EpochIndex: child.EpochIndex, TournamentAddress: child.Address, Type: BondEventPartialRefund, + BlockNumber: childMatch.DeletionBlockNumber, TxHash: *childMatch.DeletionTxHash, LogIndex: 1, + Refund: &PartialBondRefund{Recipient: childOne.SubmitterAddress, Value: observerUint256(t, "7"), Success: false}, + } + recovery := &BondEvent{ + ApplicationID: app.ID, EpochIndex: child.EpochIndex, TournamentAddress: child.Address, Type: BondEventRecovered, + BlockNumber: 95, TxHash: repotest.UniqueHash(), LogIndex: 0, + Recovery: &BondRecovered{Commitment: childOne.Commitment, Claimer: childOne.SubmitterAddress, + Payment: Uint256{}, Burned: observerUint256(t, "18446744073709551616")}, + } + return r, app, []*repository.TournamentEventBatch{ + {Tournament: root, Commitments: []*Commitment{one, two}, Matches: []*Match{parentMatch}}, + {Tournament: child, Commitments: []*Commitment{childOne, childTwo}, Matches: []*Match{childMatch}, + MatchAdvances: []*MatchAdvanced{firstAdvance, &secondAdvance}, BondEvents: []*BondEvent{refund, recovery}}, + } +} + +func TestPassiveObserverEventsAndCurrentViewsRoundTrip(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + for _, batch := range batches { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + require.Equal(t, batch.Tournament.CreationEvent, stored.CreationEvent) + for _, commitment := range batch.Commitments { + got, err := r.GetCommitment( + t.Context(), app.Name, commitment.EpochIndex, commitment.TournamentAddress.Hex(), commitment.Commitment.Hex(), + ) + require.NoError(t, err) + require.Equal(t, commitment.Snapshot, got.Snapshot) + require.Equal(t, commitment.SubmitterAddress, got.SubmitterAddress) + require.Equal(t, commitment.LogIndex, got.LogIndex) + } + for _, match := range batch.Matches { + got, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, match.TournamentAddress.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.Snapshot, got.Snapshot) + require.Equal(t, match.LeafSeal, got.LeafSeal) + require.Equal(t, match.DeletionLogIndex, got.DeletionLogIndex) + require.Equal(t, match.EliminableAt, got.EliminableAt) + } + } + child := batches[1].Tournament + childMatch := batches[1].Matches[0] + advances, total, err := r.ListMatchAdvances( + t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), repository.Pagination{}, false, + ) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, advances[0].OtherParent, advances[1].OtherParent, "parent hashes are not event identities") + require.Equal(t, uint64(0), advances[0].LogIndex) + require.Equal(t, uint64(1), advances[1].LogIndex) + require.Equal(t, batches[1].MatchAdvances[0].SegmentStartPosition, advances[0].SegmentStartPosition) + for _, advance := range advances { + got, err := r.GetMatchAdvanced(t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), + advance.TxHash, advance.LogIndex) + require.NoError(t, err) + require.Equal(t, advance, got) + } + bonds, total, err := r.ListBondEvents(t.Context(), app.Name, repository.BondEventFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, batches[1].BondEvents[0].Refund, bonds[0].Refund) + require.False(t, bonds[0].Refund.Success, "a failed refund is a recorded request, not a transferred amount") + require.Equal(t, batches[1].BondEvents[1].Recovery, bonds[1].Recovery) + require.Zero(t, bonds[1].Recovery.Payment) + for _, bond := range bonds { + got, err := r.GetBondEvent(t.Context(), app.Name, bond.TxHash, bond.LogIndex) + require.NoError(t, err) + require.Equal(t, bond, got) + } + // Replay must retain event rows, their timestamps, and their log identities. + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + replayed, _, err := r.ListBondEvents(t.Context(), app.Name, repository.BondEventFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, bonds, replayed) + replayedAdvances, _, err := r.ListMatchAdvances( + t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), repository.Pagination{}, false, + ) + require.NoError(t, err) + require.Equal(t, advances, replayedAdvances) +} + +func TestPassiveObserverRefreshClearsExpiredInnerWinner(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child := *batches[1].Tournament + child.Snapshot.AsOfBlock = 150 + child.Snapshot.Standing = TournamentStandingInnerEliminableWinnerExpired + child.Snapshot.WinnerCommitment, child.Snapshot.FinalStateHash, child.Snapshot.ParentCommitment = nil, nil, nil + child.Snapshot.WinnerExpiresAt = 0 + child.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentEliminable} + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{Tournament: &child}}, 150)) + stored, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, child.Snapshot, stored.Snapshot) + require.NotNil(t, stored.Snapshot.Candidate) + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + parent, err := r.GetTournament(t.Context(), app.Name, batches[0].Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, parent.Snapshot.AsOfBlock, "a child-only batch does not relabel the parent view") + appState, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(150), appState.LastTournamentCheckBlock) +} + +func TestPassiveObserverEmptyWindowKeepsStoredViews(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, nil, observerCheckpoint+1)) + for _, batch := range batches { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + } + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint+1, storedApp.LastTournamentCheckBlock) +} + +func TestPassiveObserverAdvanceQueriesStayWithinMatch(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child, match := batches[1].Tournament, batches[1].Matches[0] + advance := batches[1].MatchAdvances[0] + otherApp := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + for _, scope := range []struct { + name string + appName string + epoch uint64 + tournament common.Address + match common.Hash + }{ + {"wrong application", otherApp.Name, child.EpochIndex, child.Address, match.IDHash}, + {"wrong epoch", app.Name, child.EpochIndex + 1, child.Address, match.IDHash}, + {"wrong tournament", app.Name, child.EpochIndex, batches[0].Tournament.Address, match.IDHash}, + {"wrong match", app.Name, child.EpochIndex, child.Address, batches[0].Matches[0].IDHash}, + } { + t.Run(scope.name, func(t *testing.T) { + got, err := r.GetMatchAdvanced(t.Context(), scope.appName, scope.epoch, scope.tournament.Hex(), scope.match.Hex(), + advance.TxHash, advance.LogIndex) + require.NoError(t, err) + require.Nil(t, got) + rows, total, err := r.ListMatchAdvances(t.Context(), scope.appName, scope.epoch, scope.tournament.Hex(), scope.match.Hex(), + repository.Pagination{}, false) + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, rows) + }) + } + for _, descending := range []bool{false, true} { + for offset := uint64(0); offset < 2; offset++ { + rows, total, err := r.ListMatchAdvances(t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), match.IDHash.Hex(), + repository.Pagination{Limit: 1, Offset: offset}, descending) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Len(t, rows, 1) + expectedLog := offset + if descending { + expectedLog = 1 - offset + } + require.Equal(t, expectedLog, rows[0].LogIndex) + } + } +} + +func TestPassiveObserverSQLMatchesModelEnums(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, enum := range []struct { + name string + values []string + }{ + {"TournamentKind", observerEnumStrings(TournamentKindAllValues)}, + {"TournamentStandingState", observerEnumStrings(TournamentStandingStateAllValues)}, + {"MatchPhase", observerEnumStrings(MatchPhaseAllValues)}, + {"CommitmentSide", observerEnumStrings(CommitmentSideAllValues)}, + {"MatchTimeoutOutcome", observerEnumStrings(MatchTimeoutOutcomeAllValues)}, + {"InnerTournamentDisposition", observerEnumStrings(InnerTournamentDispositionAllValues)}, + {"BondDisposition", observerEnumStrings(BondDispositionAllValues)}, + {"BondEventType", observerEnumStrings(BondEventTypeAllValues)}, + } { + t.Run(enum.name, func(t *testing.T) { + rows, err := r.db.Query(t.Context(), ` + SELECT enumlabel FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + WHERE pg_type.typname = $1 ORDER BY enumsortorder`, enum.name) + require.NoError(t, err) + labels, err := pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + require.Equal(t, enum.values, labels) + }) + } +} + +func observerEnumStrings[T ~string](values []T) []string { + labels := make([]string, len(values)) + for i, value := range values { + labels[i] = string(value) + } + return labels +} + +func TestPassiveObserverRejectsImmutableFactChangesAndOlderViews(t *testing.T) { + for _, test := range []struct { + name string + change func(*repository.TournamentEventBatch) + }{ + {"child creation", func(b *repository.TournamentEventBatch) { b.Tournament.CreationEvent.LogIndex++ }}, + {"commitment join", func(b *repository.TournamentEventBatch) { b.Commitments[0].FinalStateHash = repotest.UniqueHash() }}, + {"match creation", func(b *repository.TournamentEventBatch) { b.Matches[0].LeftOfTwo = repotest.UniqueHash() }}, + {"leaf seal", func(b *repository.TournamentEventBatch) { b.Matches[0].LeafSeal.EliminableAt++ }}, + {"match deletion", func(b *repository.TournamentEventBatch) { b.Matches[0].DeletionTxHash = new(repotest.UniqueHash()) }}, + {"older view", func(b *repository.TournamentEventBatch) { + b.Tournament.Snapshot.AsOfBlock = observerCheckpoint - 1 + }}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + batch := batches[1] + before, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + batch.Tournament.Snapshot.AsOfBlock++ + test.change(batch) + head := batch.Tournament.Snapshot.AsOfBlock + for _, commitment := range batch.Commitments { + commitment.Snapshot.AsOfBlock = head + } + for _, match := range batch.Matches { + match.Snapshot.AsOfBlock = head + } + err = r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, head) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + after, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, before, after, "the earlier tournament refresh must roll back with a later conflict") + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestPassiveObserverConflictingEventRollsBackSnapshot(t *testing.T) { + for _, event := range []string{"advance", "bond"} { + t.Run(event, func(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child := *batches[1].Tournament + child.Snapshot.AsOfBlock = 101 + child.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentWinner, + ParentCommitment: child.Snapshot.ParentCommitment, PausedAllowance: 49} + batch := &repository.TournamentEventBatch{Tournament: &child} + if event == "advance" { + advance := *batches[1].MatchAdvances[0] + advance.LeftNode = repotest.UniqueHash() + batch.MatchAdvances = []*MatchAdvanced{&advance} + } else { + bond := *batches[1].BondEvents[0] + refund := *bond.Refund + refund.Success = true + bond.Refund = &refund + batch.BondEvents = []*BondEvent{&bond} + } + err := r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, 101) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + stored, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batches[1].Tournament.Snapshot, stored.Snapshot) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestUint256DomainBoundsAndExactIntegerScale(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, text := range []string{"0", "1.00", "18446744073709551616", + "115792089237316195423570985008687907853269984665640564039457584007913129639935"} { + var value Uint256 + require.NoError(t, r.db.QueryRow(t.Context(), "SELECT $1::uint256", text).Scan(&value)) + } + for _, text := range []string{"-1", "0.5", "NaN", "Infinity", + "115792089237316195423570985008687907853269984665640564039457584007913129639936"} { + var value Uint256 + err := r.db.QueryRow(t.Context(), "SELECT $1::uint256", text).Scan(&value) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr, text) + require.Equal(t, "23514", pgErr.Code, text) + } +} + +func TestPassiveObserverMatchPhaseRoundTrip(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + tournament.Snapshot.Standing = TournamentStandingMatchesActive + for _, phase := range []MatchPhase{MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed, MatchPhaseUninitialized} { + t.Run(phase.String(), func(t *testing.T) { + head := tournament.Snapshot.AsOfBlock + 1 + tournament.Snapshot.AsOfBlock = head + tournament.Snapshot.AcceptsJoins = false + match.Snapshot.AsOfBlock = head + match.Snapshot.Phase = phase + switch phase { + case MatchPhaseBisecting: + match.Snapshot.Bisection.SegmentStartPosition = observerUint256(t, "18446744073709551616") + match.Snapshot.Bisection.SegmentStartCycle = observerUint256(t, "18446744073709551617") + case MatchPhaseReadyToSeal: + match.Snapshot.Bisection.CurrentHeight = nil + case MatchPhaseSealed: + match.Snapshot.Bisection = nil + match.Snapshot.Sealed = &MatchSealedSnapshot{AgreeState: common.Hash{}, + DivergencePosition: observerUint256(t, "18446744073709551616"), + DivergenceCycle: observerUint256(t, "18446744073709551617"), + FinalStateOne: repotest.UniqueHash(), FinalStateTwo: repotest.UniqueHash()} + case MatchPhaseUninitialized: + match.Snapshot.Sealed = nil + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber, match.DeletionTxHash, match.DeletionLogIndex = head, new(common.Hash), new(uint64(0)) + tournament.Snapshot.Standing = TournamentStandingRootFailed + tournament.Snapshot.FinishedAtBlock = head + tournament.Snapshot.BondRecovery.Disposition = BondDispositionNoWinner + } + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, + []*repository.TournamentEventBatch{{Tournament: tournament, Matches: []*Match{match}}}, head)) + stored, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.Snapshot, stored.Snapshot) + }) + } +} + +func TestPassiveObserverRejectsInvalidPhasePayload(t *testing.T) { + r, app, _, initial := newTournamentIntegrityFixture(t) + for _, test := range []struct { + name string + change func(*Match) + }{ + {"bisecting without payload", func(m *Match) { m.Snapshot.Bisection = nil }}, + {"ready with height", func(m *Match) { m.Snapshot.Phase = MatchPhaseReadyToSeal }}, + {"sealed with bisection", func(m *Match) { m.Snapshot.Phase = MatchPhaseSealed }}, + {"uninitialized without deletion", func(m *Match) { m.Snapshot.Phase = MatchPhaseUninitialized }}, + } { + t.Run(test.name, func(t *testing.T) { + match := *initial + test.change(&match) + err := r.CreateMatch(t.Context(), app.Name, &match) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "matches_current_phase_check", pgErr.ConstraintName) + }) + } +} + +func TestPassiveObserverEpochFilterIncludesTerminalEpochs(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + accepted := repotest.NewEpochBuilder(app.ID).WithIndex(0).WithStatus(EpochStatus_Closed).Build() + closed := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed).Build() + open := repotest.NewEpochBuilder(app.ID).WithIndex(2).WithStatus(EpochStatus_Open).Build() + closed.TournamentAddress, accepted.TournamentAddress = new(repotest.UniqueAddress()), new(repotest.UniqueAddress()) + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, + map[*Epoch][]*Input{closed: {}, accepted: {}, open: {}}, observerCheckpoint)) + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, accepted, EpochStatus_ClaimAccepted) + epochs, total, err := r.ListEpochs(t.Context(), app.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, EpochStatus_ClaimAccepted, epochs[0].Status) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) +} diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql index 0d2d9e704..329496163 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql @@ -699,7 +699,7 @@ CREATE TABLE "tournaments" "address" ethereum_address NOT NULL, "parent_tournament_address" ethereum_address, "parent_match_id_hash" hash, - "max_level" INT NOT NULL CHECK("max_level" >= 0), + "max_level" INT NOT NULL CHECK("max_level" > 0), "level" INT NOT NULL CHECK("level" >= 0), "log2step" INT NOT NULL CHECK("log2step" >= 0), "height" INT NOT NULL CHECK("height" >= 0), @@ -738,7 +738,7 @@ CREATE TABLE "tournaments" OR ("level" > 0 AND "parent_tournament_address" IS NOT NULL AND "parent_match_id_hash" IS NOT NULL) ), - CONSTRAINT "tournaments_max_level_gte_level_check" CHECK ("max_level" >= "level"), + CONSTRAINT "tournaments_max_level_gt_level_check" CHECK ("max_level" > "level"), CONSTRAINT "tournaments_creation_event_check" CHECK ( ("level" = 0 AND num_nonnulls("creation_block_number", "creation_tx_hash", "creation_log_index") = 0) OR diff --git a/internal/repository/postgres/tournament_events.go b/internal/repository/postgres/tournament_events.go new file mode 100644 index 000000000..30141b7a3 --- /dev/null +++ b/internal/repository/postgres/tournament_events.go @@ -0,0 +1,374 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +// StoreTournamentEvents publishes event facts and pinned current snapshots in +// one application-wide transaction. Parents must precede their children. +func (r *PostgresRepository) StoreTournamentEvents( + ctx context.Context, appID int64, batches []*repository.TournamentEventBatch, lastBlock uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning tournament event window: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + // Keep the application aggregate lock before every child-row lock. + lockStmt := table.Application.SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(postgres.Int64(appID))).FOR(postgres.NO_KEY_UPDATE()) + lockSQL, lockArgs := lockStmt.Sql() + var lockedID int64 + if err := tx.QueryRow(ctx, lockSQL, lockArgs...).Scan(&lockedID); errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNotFound + } else if err != nil { + return fmt.Errorf("locking tournament application %d: %w", appID, err) + } + for _, batch := range batches { + if err := validateTournamentBatch(appID, batch, lastBlock); err != nil { + return err + } + if err := storeTournamentProjection(ctx, tx, appID, batch.Tournament); err != nil { + return err + } + if err := insertCommitments(ctx, tx, appID, batch.Commitments); err != nil { + return fmt.Errorf("storing tournament commitments: %w", err) + } + if err := insertMatches(ctx, tx, appID, batch.Matches); err != nil { + return fmt.Errorf("storing tournament matches: %w", err) + } + if err := insertMatchAdvanced(ctx, tx, appID, batch.MatchAdvances); err != nil { + return fmt.Errorf("storing tournament match advances: %w", err) + } + if err := insertBondEvents(ctx, tx, appID, batch.BondEvents); err != nil { + return fmt.Errorf("storing tournament bonds: %w", err) + } + } + if err := updateLastProcessedBlock(ctx, tx, appID, lastBlock); err != nil { + return fmt.Errorf("storing tournament checkpoint: %w", err) + } + return tx.Commit(ctx) +} + +func validateTournamentBatch(appID int64, batch *repository.TournamentEventBatch, lastBlock uint64) error { + if batch == nil || batch.Tournament == nil { + return fmt.Errorf("nil tournament batch for application %d", appID) + } + t := batch.Tournament + if t.ApplicationID != appID || t.Snapshot.AsOfBlock != lastBlock { + return fmt.Errorf("tournament projection does not match application %d at block %d", appID, lastBlock) + } + for _, v := range batch.Commitments { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.Snapshot.AsOfBlock != lastBlock { + return fmt.Errorf("commitment does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + for _, v := range batch.Matches { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.Snapshot.AsOfBlock != lastBlock { + return fmt.Errorf("match does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + for _, v := range batch.MatchAdvances { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.BlockNumber > lastBlock { + return fmt.Errorf("advance does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + for _, v := range batch.BondEvents { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.BlockNumber > lastBlock { + return fmt.Errorf("bond event does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + return nil +} + +func tournamentUpsert(v *Tournament, applicationID any) postgres.InsertStatement { + values := tournamentValues(v) + values[0] = applicationID + return table.Tournaments.INSERT(tournamentColumns).ON_CONFLICT( + table.Tournaments.ApplicationID, table.Tournaments.EpochIndex, table.Tournaments.Address, + ).DO_UPDATE(postgres.SET( + table.Tournaments.AsOfBlock.SET(table.Tournaments.EXCLUDED.AsOfBlock), + table.Tournaments.Standing.SET(table.Tournaments.EXCLUDED.Standing), + table.Tournaments.AcceptsJoins.SET(table.Tournaments.EXCLUDED.AcceptsJoins), + table.Tournaments.Candidate.SET(table.Tournaments.EXCLUDED.Candidate), + table.Tournaments.WinnerCommitment.SET(table.Tournaments.EXCLUDED.WinnerCommitment), + table.Tournaments.FinalStateHash.SET(table.Tournaments.EXCLUDED.FinalStateHash), + table.Tournaments.FinishedAtBlock.SET(table.Tournaments.EXCLUDED.FinishedAtBlock), + table.Tournaments.ParentCommitment.SET(table.Tournaments.EXCLUDED.ParentCommitment), + table.Tournaments.WinnerExpiresAt.SET(table.Tournaments.EXCLUDED.WinnerExpiresAt), + table.Tournaments.InnerDisposition.SET(table.Tournaments.EXCLUDED.InnerDisposition), + table.Tournaments.InnerParentCommitment.SET(table.Tournaments.EXCLUDED.InnerParentCommitment), + table.Tournaments.InnerPausedAllowance.SET(table.Tournaments.EXCLUDED.InnerPausedAllowance), + table.Tournaments.BondDisposition.SET(table.Tournaments.EXCLUDED.BondDisposition), + table.Tournaments.BondClaimer.SET(table.Tournaments.EXCLUDED.BondClaimer), + table.Tournaments.BondPayment.SET(table.Tournaments.EXCLUDED.BondPayment), + ).WHERE(postgres.AND( + table.Tournaments.EpochIndex.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.EpochIndex), + table.Tournaments.Address.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Address), + table.Tournaments.ParentTournamentAddress.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.ParentTournamentAddress), + table.Tournaments.ParentMatchIDHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.ParentMatchIDHash), + table.Tournaments.MaxLevel.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.MaxLevel), + table.Tournaments.Level.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Level), + table.Tournaments.Log2step.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Log2step), + table.Tournaments.Height.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Height), + table.Tournaments.InitialHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.InitialHash), + table.Tournaments.BaseCycle.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.BaseCycle), + table.Tournaments.Kind.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Kind), + table.Tournaments.StartInstant.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.StartInstant), + table.Tournaments.Allowance.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Allowance), + table.Tournaments.CreationBlockNumber.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationBlockNumber), + table.Tournaments.CreationTxHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationTxHash), + table.Tournaments.CreationLogIndex.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationLogIndex), + table.Tournaments.AsOfBlock.LT_EQ(table.Tournaments.EXCLUDED.AsOfBlock), + ))).VALUES(values[0], values[1:]...) +} + +func storeTournamentProjection(ctx context.Context, tx pgx.Tx, appID int64, v *Tournament) error { + stmt := tournamentUpsert(v, appID) + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("storing tournament %s: %w", v.Address, err) + } + if command.RowsAffected() != 1 { + return fmt.Errorf("%w: tournament %s", repository.ErrTournamentEventConflict, v.Address) + } + return nil +} + +func insertCommitments(ctx context.Context, tx pgx.Tx, appID int64, values []*Commitment) error { + if len(values) == 0 { + return nil + } + stmt := table.Commitments.INSERT(commitmentColumns).ON_CONFLICT( + table.Commitments.ApplicationID, table.Commitments.EpochIndex, table.Commitments.TournamentAddress, table.Commitments.Commitment, + ).DO_UPDATE(postgres.SET( + table.Commitments.AsOfBlock.SET(table.Commitments.EXCLUDED.AsOfBlock), + table.Commitments.Claimer.SET(table.Commitments.EXCLUDED.Claimer), + table.Commitments.ClockRunning.SET(table.Commitments.EXCLUDED.ClockRunning), + table.Commitments.ClockDeadline.SET(table.Commitments.EXCLUDED.ClockDeadline), + table.Commitments.ClockAllowance.SET(table.Commitments.EXCLUDED.ClockAllowance), + ).WHERE(postgres.AND( + table.Commitments.EpochIndex.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.EpochIndex), + table.Commitments.TournamentAddress.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.TournamentAddress), + table.Commitments.Commitment.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.Commitment), + table.Commitments.FinalStateHash.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.FinalStateHash), + table.Commitments.SubmitterAddress.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.SubmitterAddress), + table.Commitments.BlockNumber.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.BlockNumber), + table.Commitments.TxHash.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.TxHash), + table.Commitments.LogIndex.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.LogIndex), + table.Commitments.AsOfBlock.LT_EQ(table.Commitments.EXCLUDED.AsOfBlock), + ))) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid commitment application identity") + } + row := commitmentValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() != int64(len(values)) { + return fmt.Errorf("%w: commitment window", repository.ErrTournamentEventConflict) + } + return nil +} + +func insertMatches(ctx context.Context, tx pgx.Tx, appID int64, values []*Match) error { + if len(values) == 0 { + return nil + } + stmt := table.Matches.INSERT(matchColumns).ON_CONFLICT( + table.Matches.ApplicationID, table.Matches.EpochIndex, table.Matches.TournamentAddress, table.Matches.IDHash, + ).DO_UPDATE(postgres.SET( + table.Matches.SealEliminableAt.SET(table.Matches.EXCLUDED.SealEliminableAt), + table.Matches.SealBlockNumber.SET(table.Matches.EXCLUDED.SealBlockNumber), + table.Matches.SealTxHash.SET(table.Matches.EXCLUDED.SealTxHash), + table.Matches.SealLogIndex.SET(table.Matches.EXCLUDED.SealLogIndex), + table.Matches.AsOfBlock.SET(table.Matches.EXCLUDED.AsOfBlock), + table.Matches.Phase.SET(table.Matches.EXCLUDED.Phase), + table.Matches.TimeoutOutcome.SET(table.Matches.EXCLUDED.TimeoutOutcome), + table.Matches.DeferredCharge.SET(table.Matches.EXCLUDED.DeferredCharge), + table.Matches.RevealingParent.SET(table.Matches.EXCLUDED.RevealingParent), + table.Matches.WaitingLeft.SET(table.Matches.EXCLUDED.WaitingLeft), + table.Matches.WaitingRight.SET(table.Matches.EXCLUDED.WaitingRight), + table.Matches.SegmentStartPosition.SET(table.Matches.EXCLUDED.SegmentStartPosition), + table.Matches.SegmentStartCycle.SET(table.Matches.EXCLUDED.SegmentStartCycle), + table.Matches.CurrentHeight.SET(table.Matches.EXCLUDED.CurrentHeight), + table.Matches.Responder.SET(table.Matches.EXCLUDED.Responder), + table.Matches.AgreeState.SET(table.Matches.EXCLUDED.AgreeState), + table.Matches.DivergencePosition.SET(table.Matches.EXCLUDED.DivergencePosition), + table.Matches.DivergenceCycle.SET(table.Matches.EXCLUDED.DivergenceCycle), + table.Matches.FinalStateOne.SET(table.Matches.EXCLUDED.FinalStateOne), + table.Matches.FinalStateTwo.SET(table.Matches.EXCLUDED.FinalStateTwo), + table.Matches.Winner.SET(table.Matches.EXCLUDED.Winner), + table.Matches.DeletionReason.SET(table.Matches.EXCLUDED.DeletionReason), + table.Matches.DeletionBlockNumber.SET(table.Matches.EXCLUDED.DeletionBlockNumber), + table.Matches.DeletionTxHash.SET(table.Matches.EXCLUDED.DeletionTxHash), + table.Matches.DeletionLogIndex.SET(table.Matches.EXCLUDED.DeletionLogIndex), + ).WHERE(postgres.AND( + table.Matches.EpochIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.EpochIndex), + table.Matches.TournamentAddress.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.TournamentAddress), + table.Matches.IDHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.IDHash), + table.Matches.CommitmentOne.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.CommitmentOne), + table.Matches.CommitmentTwo.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.CommitmentTwo), + table.Matches.LeftOfTwo.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.LeftOfTwo), + table.Matches.BlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.BlockNumber), + table.Matches.TxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.TxHash), + table.Matches.LogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.LogIndex), + table.Matches.EliminableAt.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.EliminableAt), + table.Matches.AsOfBlock.LT_EQ(table.Matches.EXCLUDED.AsOfBlock), + table.Matches.SealTxHash.IS_NULL().OR(postgres.AND( + table.Matches.SealEliminableAt.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealEliminableAt), + table.Matches.SealBlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealBlockNumber), + table.Matches.SealTxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealTxHash), + table.Matches.SealLogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealLogIndex), + )), + table.Matches.DeletionReason.EQ(postgres.NewEnumValue(MatchDeletionReason_NOT_DELETED.String())).OR(postgres.AND( + table.Matches.Winner.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.Winner), + table.Matches.DeletionReason.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionReason), + table.Matches.DeletionBlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionBlockNumber), + table.Matches.DeletionTxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionTxHash), + table.Matches.DeletionLogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionLogIndex), + )), + ))) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid match application identity") + } + row := matchValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() != int64(len(values)) { + return fmt.Errorf("%w: match window", repository.ErrTournamentEventConflict) + } + return nil +} + +func insertMatchAdvanced(ctx context.Context, tx pgx.Tx, appID int64, values []*MatchAdvanced) error { + if len(values) == 0 { + return nil + } + stmt := table.MatchAdvances.INSERT(matchAdvanceColumns) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid matchAdvance application identity") + } + row := matchAdvanceValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.ON_CONFLICT( + table.MatchAdvances.ApplicationID, table.MatchAdvances.TxHash, table.MatchAdvances.LogIndex, + ).DO_NOTHING().Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() == int64(len(values)) { + return nil + } + // A duplicate event keeps its original row and timestamps. Check its payload + // after the insert; a same-key contradiction must roll back the window. + for _, v := range values { + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt).WHERE( + table.MatchAdvances.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.MatchAdvances.TxHash.EQ(postgres.Bytea(v.TxHash.Bytes()))). + AND(table.MatchAdvances.LogIndex.EQ(uint64Expr(v.LogIndex)))) + query, params := sel.Sql() + stored, err := scanMatchAdvanced(tx.QueryRow(ctx, query, params...)) + if err != nil { + return err + } + if !sameMatchAdvancedEvent(stored, v) { + return fmt.Errorf("%w: transaction %s log %d", repository.ErrTournamentEventConflict, v.TxHash, v.LogIndex) + } + } + return nil +} + +func sameMatchAdvancedEvent(a, b *MatchAdvanced) bool { + return a.ApplicationID == b.ApplicationID && + a.EpochIndex == b.EpochIndex && + a.TournamentAddress == b.TournamentAddress && + a.IDHash == b.IDHash && + a.OtherParent == b.OtherParent && + a.LeftNode == b.LeftNode && + a.SegmentStartPosition == b.SegmentStartPosition && + a.EliminableAt == b.EliminableAt && + a.BlockNumber == b.BlockNumber && + a.TxHash == b.TxHash && + a.LogIndex == b.LogIndex +} + +func insertBondEvents(ctx context.Context, tx pgx.Tx, appID int64, values []*BondEvent) error { + if len(values) == 0 { + return nil + } + stmt := table.BondEvents.INSERT(bondEventColumns) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid bondEvent application identity") + } + row := bondEventValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.ON_CONFLICT(table.BondEvents.ApplicationID, table.BondEvents.TxHash, table.BondEvents.LogIndex).DO_NOTHING().Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() == int64(len(values)) { + return nil + } + // A duplicate event keeps its original row and timestamps. Check its payload + // after the insert; a same-key contradiction must roll back the window. + for _, v := range values { + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt).WHERE( + table.BondEvents.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.BondEvents.TxHash.EQ(postgres.Bytea(v.TxHash.Bytes()))). + AND(table.BondEvents.LogIndex.EQ(uint64Expr(v.LogIndex)))) + query, params := sel.Sql() + stored, err := scanBondEvent(tx.QueryRow(ctx, query, params...)) + if err != nil { + return err + } + if !sameBondEvent(stored, v) { + return fmt.Errorf("%w: transaction %s log %d", repository.ErrTournamentEventConflict, v.TxHash, v.LogIndex) + } + } + return nil +} + +func sameBondEvent(a, b *BondEvent) bool { + if a.ApplicationID != b.ApplicationID || a.EpochIndex != b.EpochIndex || a.TournamentAddress != b.TournamentAddress || + a.Type != b.Type || a.BlockNumber != b.BlockNumber || a.TxHash != b.TxHash || a.LogIndex != b.LogIndex { + return false + } + if (a.Refund == nil) != (b.Refund == nil) || (a.Recovery == nil) != (b.Recovery == nil) { + return false + } + return (a.Refund == nil || *a.Refund == *b.Refund) && (a.Recovery == nil || *a.Recovery == *b.Recovery) +} diff --git a/internal/repository/postgres/tournament_events_test.go b/internal/repository/postgres/tournament_events_test.go new file mode 100644 index 000000000..36a06a07b --- /dev/null +++ b/internal/repository/postgres/tournament_events_test.go @@ -0,0 +1,199 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +func TestTournamentEventWindowLocksApplicationBeforeChildren(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + newRepository := func() *PostgresRepository { + cfg, err := pgxpool.ParseConfig(endpoint) + require.NoError(t, err) + cfg.MaxConns = 1 // Each operation has a stable PostgreSQL backend PID. + pool, err := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(t, err) + t.Cleanup(pool.Close) + return &PostgresRepository{db: pool} + } + writer, deleter := newRepository(), newRepository() + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).WithEnabled(false).Create(ctx, t, writer) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).WithInputBounds(0, 0).Build() + require.NoError(t, writer.CreateEpochsAndInputs(ctx, app.Name, map[*Epoch][]*Input{epoch: {}}, 10)) + var writerPID, deleterPID int32 + require.NoError(t, writer.db.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&writerPID)) + require.NoError(t, deleter.db.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&deleterPID)) + + gate, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, gate.Close(context.Background())) }) + gateTx, err := gate.Begin(ctx) + require.NoError(t, err) + defer gateTx.Rollback(context.Background()) //nolint:errcheck + _, err = gateTx.Exec(ctx, `SELECT 1 FROM epoch WHERE application_id = $1 FOR UPDATE`, app.ID) + require.NoError(t, err) + gatePID := int32(gate.PgConn().PID()) //nolint:gosec // PostgreSQL backend PIDs fit in int32. + waitForBlocker := func(blocked, blocker int32) { + t.Helper() + require.Eventually(t, func() bool { + var blockedByExpected bool + err := gate.QueryRow(ctx, "SELECT $1 = ANY(pg_blocking_pids($2))", blocker, blocked).Scan(&blockedByExpected) + return err == nil && blockedByExpected + }, 5*time.Second, 10*time.Millisecond, "backend %d must wait for backend %d", blocked, blocker) + } + + stored := make(chan error, 1) + go func() { + batch := &repository.TournamentEventBatch{Tournament: repotest.NewTournamentBuilder(app.ID).Build()} + batch.Tournament.Snapshot.AsOfBlock = 100 + stored <- writer.StoreTournamentEvents(ctx, app.ID, []*repository.TournamentEventBatch{batch}, 100) + }() + // The epoch lock pauses the first projection's foreign-key check. The + // writer must already hold the application lock before it reaches here. + waitForBlocker(writerPID, gatePID) + deleted := make(chan error, 1) + go func() { deleted <- deleter.DeleteApplication(ctx, app.ID) }() + waitForBlocker(deleterPID, writerPID) + + require.NoError(t, gateTx.Commit(ctx)) + require.NoError(t, <-stored, "the event window must commit before deletion can take its application lock") + require.NoError(t, <-deleted) + for _, query := range []string{"SELECT count(*) FROM application", "SELECT count(*) FROM epoch", "SELECT count(*) FROM tournaments"} { + var count int + require.NoError(t, writer.db.QueryRow(ctx, query).Scan(&count)) + require.Zero(t, count, "deletion must remove the complete committed projection") + } +} + +func TestTournamentEventWindowIsAtomic(t *testing.T) { + for _, test := range []struct { + name string + fault func([]*repository.TournamentEventBatch) + }{ + {name: "child row", fault: func(batches []*repository.TournamentEventBatch) { + child := *batches[1].Tournament + child.ParentMatchIDHash = new(repotest.UniqueHash()) + batches[1].Tournament = &child + }}, + {name: "child event", fault: func(batches []*repository.TournamentEventBatch) { + commitment := *batches[1].Commitments[0] + commitment.TournamentAddress = repotest.UniqueAddress() + batches[1].Commitments = []*Commitment{&commitment} + }}, + {name: "last root event", fault: func(batches []*repository.TournamentEventBatch) { + last := batches[len(batches)-1] + invalid := repotest.NewMatchBuilder(last.Tournament.ApplicationID). + WithEpochIndex(last.Tournament.EpochIndex).WithTournamentAddress(last.Tournament.Address).Build() + invalid.Snapshot.AsOfBlock = 100 + last.Matches = append(last.Matches, invalid) + }}, + {name: "duplicate commitment", fault: func(batches []*repository.TournamentEventBatch) { + batches[1].Commitments = append(batches[1].Commitments, batches[1].Commitments[0]) + }}, + } { + t.Run(test.name, func(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + raw, err := NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(raw.Close) + r := raw.(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, raw) + epoch0 := repotest.NewEpochBuilder(app.ID).WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + epoch1 := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(9, 19).WithInputBounds(0, 0).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, + map[*Epoch][]*Input{epoch0: {}, epoch1: {}}, 20)) + + root := repotest.NewTournamentBuilder(app.ID).Build() + root.Snapshot.AsOfBlock = 50 + require.NoError(t, r.CreateTournament(t.Context(), app.Name, root)) + updatedRoot := *root + updatedRoot.Snapshot.AsOfBlock = 100 + updatedRoot.Snapshot.AcceptsJoins = false + makeBatch := func(tournament *Tournament) *repository.TournamentEventBatch { + tournament.Snapshot.AsOfBlock = 100 + tournament.Snapshot.Standing = TournamentStandingMatchesActive + one := repotest.NewCommitmentBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).WithCommitmentOne(one.Commitment). + WithCommitmentTwo(two.Commitment).Build() + one.Snapshot.AsOfBlock = 100 + two.Snapshot.AsOfBlock = 100 + match.Snapshot.AsOfBlock = 100 + return &repository.TournamentEventBatch{Tournament: tournament, + Commitments: []*Commitment{one, two}, Matches: []*Match{match}} + } + parent := makeBatch(&updatedRoot) + child := repotest.NewTournamentBuilder(app.ID).WithLevel(1). + WithParent(root.Address, parent.Matches[0].IDHash).Build() + secondRoot := repotest.NewTournamentBuilder(app.ID).WithEpochIndex(1).Build() + good := []*repository.TournamentEventBatch{parent, makeBatch(child), makeBatch(secondRoot)} + bad := make([]*repository.TournamentEventBatch, len(good)) + for i, batch := range good { + batchCopy := *batch + bad[i] = &batchCopy + } + test.fault(bad) + require.Error(t, r.StoreTournamentEvents(t.Context(), app.ID, bad, 100)) + storedRoot, err := r.GetTournament(t.Context(), app.Name, root.Address.Hex()) + require.NoError(t, err) + require.Equal(t, root.Snapshot, storedRoot.Snapshot, "projection update must roll back with the last batch") + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Zero(t, storedApp.LastTournamentCheckBlock) + for _, target := range []struct { + query string + count int + }{ + {"SELECT count(*) FROM tournaments", 1}, {"SELECT count(*) FROM commitments", 0}, + {"SELECT count(*) FROM matches", 0}, {"SELECT count(*) FROM match_advances", 0}, + } { + var count int + require.NoError(t, r.db.QueryRow(t.Context(), target.query).Scan(&count)) + require.Equal(t, target.count, count, target.query) + } + + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, good, 100)) + storedApp, err = r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(100), storedApp.LastTournamentCheckBlock) + for _, batch := range good { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.NotNil(t, stored) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + commitments, total, err := r.ListCommitments(t.Context(), app.Name, + repository.CommitmentFilter{TournamentAddress: new(batch.Tournament.Address.Hex())}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Len(t, commitments, 2, "retry must not duplicate the rolled-back event rows") + } + }) + } +} diff --git a/internal/repository/postgres/tournament_integrity_test.go b/internal/repository/postgres/tournament_integrity_test.go new file mode 100644 index 000000000..dc4d47689 --- /dev/null +++ b/internal/repository/postgres/tournament_integrity_test.go @@ -0,0 +1,274 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const ( + integrityFirstCheckpoint = uint64(100) + integrityNextCheckpoint = uint64(200) +) + +func newTournamentIntegrityFixture(t *testing.T) (*PostgresRepository, *Application, *Tournament, *Match) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, epoch.LastBlock)) + tournament := repotest.NewTournamentBuilder(app.ID).Build() + one := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithTournamentAddress(tournament.Address). + WithCommitmentOne(one.Commitment).WithCommitmentTwo(two.Commitment).Build() + tournament.Snapshot.AsOfBlock = integrityFirstCheckpoint + one.Snapshot.AsOfBlock = integrityFirstCheckpoint + two.Snapshot.AsOfBlock = integrityFirstCheckpoint + match.Snapshot.AsOfBlock = integrityFirstCheckpoint + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{ + Tournament: tournament, Commitments: []*Commitment{one, two}, + }}, integrityFirstCheckpoint)) + return r, app, tournament, match +} + +func TestMatchDeletionMetadataRoundTrip(t *testing.T) { + for _, test := range []struct { + name string + hash *common.Hash + }{ + {name: "live"}, + {name: "deleted with zero hash", hash: new(common.Hash)}, + {name: "deleted with nonzero hash", hash: new(common.HexToHash("0x123"))}, + } { + for _, bulk := range []bool{false, true} { + writer := "create" + if bulk { + writer = "event window" + } + t.Run(test.name+"/"+writer, func(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + tournament.Snapshot.AsOfBlock = integrityNextCheckpoint + match.Snapshot.AsOfBlock = integrityNextCheckpoint + match.DeletionTxHash = test.hash + if test.hash != nil { + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = integrityNextCheckpoint + match.DeletionLogIndex = new(uint64(0)) + match.Snapshot.Phase = MatchPhaseUninitialized + match.Snapshot.Bisection = nil + } + if bulk { + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{ + Tournament: tournament, Matches: []*Match{match}, + }}, integrityNextCheckpoint)) + } else { + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + } + stored, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.DeletionReason, stored.DeletionReason) + require.Equal(t, match.DeletionBlockNumber, stored.DeletionBlockNumber) + require.Equal(t, match.DeletionTxHash, stored.DeletionTxHash) + listed, _, err := r.ListMatches(t.Context(), app.Name, repository.MatchFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Len(t, listed, 1) + require.Equal(t, match.DeletionTxHash, listed[0].DeletionTxHash) + var isNull bool + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT deletion_tx_hash IS NULL FROM matches`).Scan(&isNull)) + require.Equal(t, test.hash == nil, isNull) + }) + } + } +} + +func TestMatchDeletionMetadataRejectsIncompleteTuples(t *testing.T) { + r, app, _, match := newTournamentIntegrityFixture(t) + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + for _, test := range []struct { + name string + reason MatchDeletionReason + block any + hash *common.Hash + code string + }{ + {"live with transaction", MatchDeletionReason_NOT_DELETED, 0, new(common.Hash), "23514"}, + {"live with block", MatchDeletionReason_NOT_DELETED, 1, nil, "23514"}, + {"deleted without transaction", MatchDeletionReason_TIMEOUT, 1, nil, "23514"}, + {"deleted without block", MatchDeletionReason_TIMEOUT, 0, new(common.Hash), "23514"}, + {"live with null block", MatchDeletionReason_NOT_DELETED, nil, nil, "23502"}, + {"deleted with null block", MatchDeletionReason_TIMEOUT, nil, new(common.Hash), "23502"}, + } { + t.Run(test.name, func(t *testing.T) { + // Keep the current phase coherent so this test isolates the event tuple. + _, err := r.db.Exec(t.Context(), `UPDATE matches SET + deletion_reason = $1::"MatchDeletionReason", deletion_block_number = $2, deletion_tx_hash = $3, + deletion_log_index = CASE WHEN $1 = 'NOT_DELETED' THEN NULL ELSE 0 END, + phase = CASE WHEN $1 = 'NOT_DELETED' THEN 'BISECTING'::"MatchPhase" ELSE 'UNINITIALIZED'::"MatchPhase" END, + revealing_parent = CASE WHEN $1 = 'NOT_DELETED' THEN revealing_parent ELSE NULL END, + waiting_left = CASE WHEN $1 = 'NOT_DELETED' THEN waiting_left ELSE NULL END, + waiting_right = CASE WHEN $1 = 'NOT_DELETED' THEN waiting_right ELSE NULL END, + segment_start_position = CASE WHEN $1 = 'NOT_DELETED' THEN segment_start_position ELSE NULL END, + segment_start_cycle = CASE WHEN $1 = 'NOT_DELETED' THEN segment_start_cycle ELSE NULL END, + current_height = CASE WHEN $1 = 'NOT_DELETED' THEN current_height ELSE NULL END, + responder = CASE WHEN $1 = 'NOT_DELETED' THEN responder ELSE NULL END`, + test.reason.String(), test.block, hashToBytes(test.hash)) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, test.code, pgErr.Code) + if test.block == nil { + require.Equal(t, "deletion_block_number", pgErr.ColumnName) + } else { + require.Equal(t, "matches_deletion_columns_set_together", pgErr.ConstraintName) + } + }) + } +} + +func TestTournamentWindowRollsBackInvalidDeletion(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + tournament.Snapshot.AsOfBlock = integrityNextCheckpoint + tournament.Snapshot.Standing = TournamentStandingRootWinner + tournament.Snapshot.FinishedAtBlock = integrityNextCheckpoint + tournament.Snapshot.Candidate = new(match.CommitmentOne) + tournament.Snapshot.WinnerCommitment = new(match.CommitmentOne) + tournament.Snapshot.FinalStateHash = new(common.Hash) + deletion := *match + deletion.Winner = WinnerCommitment_ONE + deletion.DeletionReason = MatchDeletionReason_TIMEOUT + deletion.DeletionBlockNumber = integrityNextCheckpoint + deletion.DeletionLogIndex = new(uint64(0)) + deletion.Snapshot = MatchSnapshot{AsOfBlock: integrityNextCheckpoint, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone} + batch := &repository.TournamentEventBatch{Tournament: tournament, Matches: []*Match{&deletion}} + err := r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, integrityNextCheckpoint) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "matches_deletion_columns_set_together", pgErr.ConstraintName) + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Zero(t, stored.Snapshot.FinishedAtBlock) + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + storedMatch, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, MatchDeletionReason_NOT_DELETED, storedMatch.DeletionReason) + require.Nil(t, storedMatch.DeletionTxHash) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityFirstCheckpoint, storedApp.LastTournamentCheckBlock) + + // The atomic path replaces the removed single-row update methods. Verify + // its successful update as well as the failed window above. + deletion.DeletionTxHash = new(common.Hash) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, integrityNextCheckpoint)) + stored, err = r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.Snapshot, stored.Snapshot) + storedMatch, err = r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, deletion.Winner, storedMatch.Winner) + require.Equal(t, deletion.DeletionReason, storedMatch.DeletionReason) + require.Equal(t, deletion.DeletionBlockNumber, storedMatch.DeletionBlockNumber) + require.Equal(t, deletion.DeletionTxHash, storedMatch.DeletionTxHash) + storedApp, err = r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityNextCheckpoint, storedApp.LastTournamentCheckBlock) +} + +func TestTournamentAddressIdentifiesOneEpochPerApplication(t *testing.T) { + r, app, tournament, _ := newTournamentIntegrityFixture(t) + // Re-observing the same identity remains idempotent. + require.NoError(t, r.CreateTournament(t.Context(), app.Name, tournament)) + nextEpoch := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{nextEpoch: {}}, nextEpoch.LastBlock)) + duplicate := *tournament + duplicate.EpochIndex = nextEpoch.Index + err := r.CreateTournament(t.Context(), app.Name, &duplicate) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "tournaments_address_idx", pgErr.ConstraintName) + + updated := *tournament + updated.Snapshot.AsOfBlock = integrityNextCheckpoint + updated.Snapshot.Standing = TournamentStandingRootFailed + updated.Snapshot.FinishedAtBlock = integrityNextCheckpoint + duplicate.Snapshot.AsOfBlock = integrityNextCheckpoint + err = r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{ + {Tournament: &updated}, {Tournament: &duplicate}, + }, integrityNextCheckpoint) + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "tournaments_address_idx", pgErr.ConstraintName) + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.EpochIndex, stored.EpochIndex) + require.Zero(t, stored.Snapshot.FinishedAtBlock) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityFirstCheckpoint, storedApp.LastTournamentCheckBlock) + + otherApp := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + otherEpoch := repotest.NewEpochBuilder(otherApp.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), otherApp.Name, map[*Epoch][]*Input{otherEpoch: {}}, otherEpoch.LastBlock)) + otherTournament := *tournament + otherTournament.ApplicationID = otherApp.ID + require.NoError(t, r.CreateTournament(t.Context(), otherApp.Name, &otherTournament), "the uniqueness key includes the application") +} + +func TestTournamentLevelConstraints(t *testing.T) { + for _, test := range []struct { + name string + maxLevel uint64 + level uint64 + constraint string + }{ + {name: "single level root", maxLevel: 1, level: 0}, + {name: "multi level root", maxLevel: 3, level: 0}, + {name: "inner nonleaf", maxLevel: 3, level: 1}, + {name: "inner leaf", maxLevel: 2, level: 1}, + {name: "zero levels", maxLevel: 0, level: 0, constraint: "tournaments_max_level_check"}, + {name: "level equals count", maxLevel: 1, level: 1, constraint: "tournaments_max_level_gt_level_check"}, + {name: "level exceeds count", maxLevel: 1, level: 2, constraint: "tournaments_max_level_gt_level_check"}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + if test.level > 0 { + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + tournament = repotest.NewTournamentBuilder(app.ID).WithLevel(1). + WithParent(tournament.Address, match.IDHash).Build() + require.NoError(t, r.CreateTournament(t.Context(), app.Name, tournament)) + } + kind := TournamentKindNonLeaf + if test.maxLevel > 0 && test.level == test.maxLevel-1 { + kind = TournamentKindLeaf + } + + // Write directly to test the database constraint, not a Go-side guard. + _, err := r.db.Exec(t.Context(), `UPDATE tournaments SET max_level = $1, level = $2, kind = $3 + WHERE application_id = $4 AND epoch_index = $5 AND address = $6`, + test.maxLevel, test.level, kind.String(), app.ID, tournament.EpochIndex, tournament.Address.Bytes()) + if test.constraint != "" { + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "23514", pgErr.Code) + require.Equal(t, test.constraint, pgErr.ConstraintName) + } else { + require.NoError(t, err) + tournament.MaxLevel, tournament.Level, tournament.Kind = test.maxLevel, test.level, kind + } + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.MaxLevel, stored.MaxLevel) + require.Equal(t, tournament.Level, stored.Level) + require.Equal(t, tournament.Kind, stored.Kind) + }) + } +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index e0a261ad7..bc6674f2c 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -348,11 +348,23 @@ type BondEventRepository interface { ListBondEvents(ctx context.Context, nameOrAddress string, f BondEventFilter, p Pagination, descending bool) ([]*BondEvent, uint64, error) } +// TournamentEventBatch contains one tournament projection and the events from +// a completed scan window. Batches are ordered with parents before children: +// a child tournament references a match written in its parent's batch. +type TournamentEventBatch struct { + Tournament *Tournament + Commitments []*Commitment + Matches []*Match + MatchAdvances []*MatchAdvanced + BondEvents []*BondEvent +} + type BulkOperationsRepository interface { StoreAdvanceResult(ctx context.Context, appID int64, result *AdvanceResult) error StoreClaimAndProofs(ctx context.Context, epoch *Epoch, outputs []*Output) error - StoreTournamentEvents(ctx context.Context, appID int64, commitments []*Commitment, matches []*Match, - matchAdvanced []*MatchAdvanced, matchDeleted []*Match, lastBlock uint64) error + // StoreTournamentEvents commits every tournament batch and one application + // cursor together. Callers must finish all event reads before this call. + StoreTournamentEvents(ctx context.Context, appID int64, batches []*TournamentEventBatch, lastBlock uint64) error } type NodeConfigRepository interface { diff --git a/internal/repository/repotest/bulk_test_cases.go b/internal/repository/repotest/bulk_test_cases.go index 81e33a41a..bcb95a835 100644 --- a/internal/repository/repotest/bulk_test_cases.go +++ b/internal/repository/repotest/bulk_test_cases.go @@ -5,7 +5,6 @@ package repotest import ( "context" - "encoding/hex" "fmt" "sync" @@ -722,12 +721,18 @@ func (s *BulkOperationsSuite) TestStoreClaimAndProofs() { } func (s *BulkOperationsSuite) TestStoreTournamentEvents() { + const ( + joinedEventScanEnd uint64 = 100 + advancedEventScanEnd uint64 = 200 + deletedEventScanEnd uint64 = 300 + ) // setupTournamentWithMatch creates a PRT app with a tournament, two // commitments, and one match, all stored via StoreTournamentEvents. type tournamentSetup struct { - app *Application - tournAddr common.Address - match *Match + app *Application + tournAddr common.Address + tournament *Tournament + match *Match } setupTournamentWithMatch := func() *tournamentSetup { s.T().Helper() @@ -767,12 +772,11 @@ func (s *BulkOperationsSuite) TestStoreTournamentEvents() { err = s.Repo.StoreTournamentEvents( s.Ctx, app.ID, - []*Commitment{commitment1, commitment2}, - []*Match{match}, - nil, nil, 100) + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{commitment1, commitment2}, Matches: []*Match{match}}}, joinedEventScanEnd) s.Require().NoError(err) - return &tournamentSetup{app: app, tournAddr: tournAddr, match: match} + return &tournamentSetup{app: app, tournAddr: tournAddr, tournament: tournament, match: match} } s.Run("StoresCommitmentsAndMatches", func() { @@ -802,18 +806,17 @@ func (s *BulkOperationsSuite) TestStoreTournamentEvents() { WithTournamentAddress(ts.tournAddr). WithIDHash(ts.match.IDHash). Build() + ts.tournament.Snapshot.AsOfBlock = advancedEventScanEnd err := s.Repo.StoreTournamentEvents( s.Ctx, ts.app.ID, - nil, nil, - []*MatchAdvanced{ma}, nil, 200) + []*repository.TournamentEventBatch{{Tournament: ts.tournament, MatchAdvances: []*MatchAdvanced{ma}}}, advancedEventScanEnd) s.Require().NoError(err) // Verify match advanced was stored gotMA, err := s.Repo.GetMatchAdvanced( s.Ctx, ts.app.IApplicationAddress.String(), - 0, ts.tournAddr.String(), ts.match.IDHash.Hex(), - hex.EncodeToString(ma.OtherParent[:])) + 0, ts.tournAddr.String(), ts.match.IDHash.Hex(), ma.TxHash, ma.LogIndex) s.Require().NoError(err) s.Require().NotNil(gotMA) s.Equal(ma.OtherParent, gotMA.OtherParent) @@ -823,20 +826,20 @@ func (s *BulkOperationsSuite) TestStoreTournamentEvents() { ts := setupTournamentWithMatch() // Mark the match as deleted (winner decided) - deletedMatch := &Match{ - EpochIndex: 0, - TournamentAddress: ts.tournAddr, - IDHash: ts.match.IDHash, - Winner: WinnerCommitment_ONE, - DeletionReason: MatchDeletionReason_TIMEOUT, - DeletionBlockNumber: 200, - DeletionTxHash: UniqueHash(), + deletedMatch := *ts.match + deletedMatch.Winner = WinnerCommitment_ONE + deletedMatch.DeletionReason = MatchDeletionReason_TIMEOUT + deletedMatch.DeletionBlockNumber = advancedEventScanEnd + deletedMatch.DeletionTxHash = new(UniqueHash()) + deletedMatch.DeletionLogIndex = new(uint64(0)) + deletedMatch.Snapshot = MatchSnapshot{ + AsOfBlock: deletedEventScanEnd, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, } + ts.tournament.Snapshot.AsOfBlock = deletedEventScanEnd err := s.Repo.StoreTournamentEvents( s.Ctx, ts.app.ID, - nil, nil, nil, - []*Match{deletedMatch}, 300) + []*repository.TournamentEventBatch{{Tournament: ts.tournament, Matches: []*Match{&deletedMatch}}}, deletedEventScanEnd) s.Require().NoError(err) // Verify the match was updated @@ -846,6 +849,8 @@ func (s *BulkOperationsSuite) TestStoreTournamentEvents() { s.Require().NoError(err) s.Equal(WinnerCommitment_ONE, gotMatch.Winner) s.Equal(MatchDeletionReason_TIMEOUT, gotMatch.DeletionReason) + s.Equal(deletedMatch.DeletionBlockNumber, gotMatch.DeletionBlockNumber) + s.Equal(deletedMatch.DeletionTxHash, gotMatch.DeletionTxHash) }) } @@ -1137,8 +1142,9 @@ func (s *BulkOperationsSuite) TestStoreClaimAndProofsRollback() { } func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { + const failedWindowEnd uint64 = 100 // Helper: create a PRT application with one closed epoch and a tournament. - setupPRTApp := func() (app *Application, tournAddr common.Address) { + setupPRTApp := func() (app *Application, tournament *Tournament) { s.T().Helper() app = NewApplicationBuilder(). WithConsensus(Consensus_PRT). @@ -1153,39 +1159,38 @@ func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { map[*Epoch][]*Input{epoch: {input}}, 10) s.Require().NoError(err) - tournAddr = UniqueAddress() - tournament := NewTournamentBuilder(app.ID). + tournAddr := UniqueAddress() + tournament = NewTournamentBuilder(app.ID). WithEpochIndex(0).WithAddress(tournAddr).Build() err = s.Repo.CreateTournament( s.Ctx, app.IApplicationAddress.String(), tournament) s.Require().NoError(err) - return app, tournAddr + return app, tournament } // Insert valid commitments + a match that references a non-existent - // tournament address, causing an FK violation. The commitments + // commitment, causing an FK violation. The commitments // inserted in the same transaction must be rolled back. s.Run("RollbackOnMatchInsertFailure", func() { - app, tournAddr := setupPRTApp() + app, tournament := setupPRTApp() + tournAddr := tournament.Address // Valid commitment targeting the real tournament commitment := NewCommitmentBuilder(app.ID). WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() - // Match targeting a non-existent tournament → FK violation - bogusAddr := UniqueAddress() + // Match referencing a non-existent second commitment → FK violation match := NewMatchBuilder(app.ID). WithEpochIndex(0). - WithTournamentAddress(bogusAddr). + WithTournamentAddress(tournAddr). WithCommitmentOne(commitment.Commitment). WithCommitmentTwo(UniqueHash()). Build() err := s.Repo.StoreTournamentEvents( s.Ctx, app.ID, - []*Commitment{commitment}, - []*Match{match}, - nil, nil, 100) + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{commitment}, Matches: []*Match{match}}}, failedWindowEnd) s.Require().Error(err) // Verify the commitment was rolled back @@ -1196,30 +1201,27 @@ func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { s.Nil(got, "commitment should have been rolled back") }) - // Insert valid commitments + try to delete (update) a non-existent - // match. updateMatches returns an error when RowsAffected == 0. - // The commitments inserted earlier in the same tx must be rolled back. - s.Run("RollbackOnMatchDeleteFailure", func() { - app, tournAddr := setupPRTApp() + // A newly observed deleted match still needs both joined commitments. + // Its missing second commitment must roll back the valid first join. + s.Run("RollbackOnDeletedMatchInsertFailure", func() { + app, tournament := setupPRTApp() + tournAddr := tournament.Address // Valid new commitment newCommitment := NewCommitmentBuilder(app.ID). WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() - // Try to delete a match that doesn't exist - deletedMatch := &Match{ - EpochIndex: 0, - TournamentAddress: tournAddr, - IDHash: UniqueHash(), // doesn't exist - Winner: WinnerCommitment_ONE, - DeletionReason: MatchDeletionReason_TIMEOUT, - } + deletedMatch := NewMatchBuilder(app.ID). + WithTournamentAddress(tournAddr). + WithCommitmentOne(newCommitment.Commitment). + WithCommitmentTwo(UniqueHash()). + WithWinner(WinnerCommitment_ONE). + WithDeletion(MatchDeletionReason_TIMEOUT, failedWindowEnd, UniqueHash()).Build() err := s.Repo.StoreTournamentEvents( s.Ctx, app.ID, - []*Commitment{newCommitment}, - nil, nil, - []*Match{deletedMatch}, 100) + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{newCommitment}, Matches: []*Match{deletedMatch}}}, failedWindowEnd) s.Require().Error(err) // Verify the new commitment was rolled back @@ -1233,7 +1235,8 @@ func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { // Insert a match advanced event for a match that doesn't exist, // causing an FK violation. Commitments in the same tx should roll back. s.Run("RollbackOnMatchAdvancedInsertFailure", func() { - app, tournAddr := setupPRTApp() + app, tournament := setupPRTApp() + tournAddr := tournament.Address // Valid new commitment newCommitment := NewCommitmentBuilder(app.ID). @@ -1248,10 +1251,8 @@ func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { err := s.Repo.StoreTournamentEvents( s.Ctx, app.ID, - []*Commitment{newCommitment}, - nil, - []*MatchAdvanced{bogusMA}, - nil, 100) + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{newCommitment}, MatchAdvances: []*MatchAdvanced{bogusMA}}}, failedWindowEnd) s.Require().Error(err) // Verify the commitment was rolled back From 4ee582ac4b9935c4825f8a1e764c8f735453f9f1 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:16 -0300 Subject: [PATCH 13/41] feat(jsonrpc)!: expose v3 contract state Expose the final application and dispute model through JSON-RPC and the discovery schema. Return tournament and match phases, clocks, child results, financial events, and observation block information. Require a specific match for advance lists. The list returns the transaction hash and log index needed for a single-event lookup, with the same event content in both responses. Include API contract tests. BREAKING CHANGE: remove the legacy data-availability response field and use the new dispute projections and event-addressed match-advance API. --- internal/jsonrpc/api/params.go | 20 +- internal/jsonrpc/api/params_test.go | 16 +- internal/jsonrpc/application_contract_test.go | 29 + internal/jsonrpc/batchbudget_test.go | 1 + internal/jsonrpc/config_test.go | 85 ++ internal/jsonrpc/jsonrpc-discover.json | 1057 ++++++++++++++++- internal/jsonrpc/jsonrpc.go | 163 ++- internal/jsonrpc/jsonrpc_test.go | 161 ++- internal/jsonrpc/match_projection_test.go | 77 ++ .../jsonrpc/prt_observer_contract_test.go | 491 ++++++++ internal/jsonrpc/util_test.go | 29 +- 11 files changed, 1972 insertions(+), 157 deletions(-) create mode 100644 internal/jsonrpc/application_contract_test.go create mode 100644 internal/jsonrpc/config_test.go create mode 100644 internal/jsonrpc/match_projection_test.go create mode 100644 internal/jsonrpc/prt_observer_contract_test.go diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index 9736a06ba..9991660fc 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -205,7 +205,25 @@ type GetMatchAdvanceParams struct { EpochIndex string `json:"epoch_index"` TournamentAddress string `json:"tournament_address"` IDHash string `json:"id_hash"` - Parent string `json:"parent"` + TxHash string `json:"tx_hash"` + LogIndex string `json:"log_index"` +} + +// ListBondEventsParams selects immutable refund and recovery events. +type ListBondEventsParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + TournamentAddress *string `json:"tournament_address,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetBondEventParams identifies an event by its transaction and block log index. +type GetBondEventParams struct { + Application string `json:"application"` + TxHash string `json:"tx_hash"` + LogIndex string `json:"log_index"` } // ListWithdrawalsParams aligns with the OpenRPC specification diff --git a/internal/jsonrpc/api/params_test.go b/internal/jsonrpc/api/params_test.go index 5836a5ca5..8a41d694f 100644 --- a/internal/jsonrpc/api/params_test.go +++ b/internal/jsonrpc/api/params_test.go @@ -198,8 +198,20 @@ func TestPositionalParamsDeclarationOrder(t *testing.T) { }, "GetMatchAdvanceParams": { func() any { return &GetMatchAdvanceParams{} }, - `["app","0x4","tournament","id-hash","parent"]`, - `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","id_hash":"id-hash","parent":"parent"}`, + `["app","0x4","tournament","id-hash","tx-hash","0x4"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"id_hash":"id-hash","tx_hash":"tx-hash","log_index":"0x4"}`, + }, + "ListBondEventsParams": { + func() any { return &ListBondEventsParams{} }, + `["app","0x4","tournament",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"limit":25,"offset":3,"descending":true}`, + }, + "GetBondEventParams": { + func() any { return &GetBondEventParams{} }, + `["app","tx-hash","0x4"]`, + `{"application":"app","tx_hash":"tx-hash","log_index":"0x4"}`, }, "ListWithdrawalsParams": { func() any { return &ListWithdrawalsParams{} }, diff --git a/internal/jsonrpc/application_contract_test.go b/internal/jsonrpc/application_contract_test.go new file mode 100644 index 000000000..7a4e89086 --- /dev/null +++ b/internal/jsonrpc/application_contract_test.go @@ -0,0 +1,29 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiscoverySchemaApplicationInputBox(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + + var spec struct { + Components struct { + Schemas map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + + properties := spec.Components.Schemas["Application"].Properties + require.Contains(t, properties, "iinputbox_address") + require.NotContains(t, properties, "data_availability") +} diff --git a/internal/jsonrpc/batchbudget_test.go b/internal/jsonrpc/batchbudget_test.go index 84c844b79..c12c3303f 100644 --- a/internal/jsonrpc/batchbudget_test.go +++ b/internal/jsonrpc/batchbudget_test.go @@ -53,6 +53,7 @@ func TestBatchListItemLimitSupportsNamedAndPositionalParams(t *testing.T) { "cartesi_listCommitments": `["app",null,null,10000]`, "cartesi_listMatches": `["app",null,null,10000]`, "cartesi_listMatchAdvances": `["app","0x0","tournament","id",10000]`, + "cartesi_listBondEvents": `["app",null,null,10000]`, } for method, positional := range positionalAtLimit { diff --git a/internal/jsonrpc/config_test.go b/internal/jsonrpc/config_test.go new file mode 100644 index 000000000..d76607674 --- /dev/null +++ b/internal/jsonrpc/config_test.go @@ -0,0 +1,85 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http/httptest" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +type savedConfigRepository struct { + repository.Repository + raw []byte + err error +} + +func (r *savedConfigRepository) LoadNodeConfigRaw( + _ context.Context, key string, +) ([]byte, time.Time, time.Time, error) { + if key != evmreader.EvmReaderConfigKey { + panic("unexpected configuration key") + } + return r.raw, time.Time{}, time.Time{}, r.err +} + +func TestNodeInfoAndChainIDValidateSavedConfig(t *testing.T) { + const nullConfigJSON = "null" + for _, method := range []string{"cartesi_getNodeInfo", "cartesi_getChainId"} { + for _, test := range []struct { + name string + raw string + err error + wantCode int + }{ + {"absent", "", repository.ErrNotFound, JSONRPC_RESOURCE_NOT_FOUND}, + {"nil bytes", "", nil, JSONRPC_INTERNAL_ERROR}, + {"null config", nullConfigJSON, nil, JSONRPC_INTERNAL_ERROR}, + {"empty", "{}", nil, JSONRPC_INTERNAL_ERROR}, + {"missing chain", `{"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"missing policy", `{"ChainID":1}`, nil, JSONRPC_INTERNAL_ERROR}, + {"null chain", `{"ChainID":null,"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"null policy", `{"ChainID":1,"DefaultBlock":null}`, nil, JSONRPC_INTERNAL_ERROR}, + {"zero chain", `{"ChainID":0,"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"invalid policy", `{"ChainID":1,"DefaultBlock":"INVALID"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"valid", `{"ChainID":31337,"DefaultBlock":"FINALIZED"}`, nil, 0}, + } { + t.Run(method+"/"+test.name, func(t *testing.T) { + var raw []byte + if test.raw != "" { + raw = []byte(test.raw) + } + s := &Service{ + HTTPServiceTemplate: service.HTTPServiceTemplate{ + BaseTemplate: service.BaseTemplate{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, + }, + repository: &savedConfigRepository{raw: raw, err: test.err}, + handlers: cloneDispatchTable(jsonrpcHandlers), + } + request := httptest.NewRequest("POST", "/rpc", nil) + result, err := s.handlers[method](s, request, RPCRequest{}) + if test.wantCode != 0 { + require.Nil(t, result) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, test.wantCode, rpcErr.Code) + return + } + require.NoError(t, err) + encoded, err := json.Marshal(result) + require.NoError(t, err) + require.Contains(t, string(encoded), "0x7a69") + }) + } + } +} diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index f304b7d17..584da7f5c 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -1526,7 +1526,7 @@ { "name": "cartesi_listMatchAdvances", "summary": "List match advances", - "description": "Returns a paginated list of match advances for the specified match.", + "description": "Returns complete immutable advance records for one match. Application, epoch, tournament, and match are required. Events are ordered by block number and block-global log index. Each record includes its transaction hash and log index for getMatchAdvance. Repeated other-parent hashes remain distinct events.", "params": [ { "name": "application", @@ -1538,7 +1538,7 @@ }, { "name": "epoch_index", - "description": "Filter match advances by a specific epoch index (hex encoded).", + "description": "The epoch index of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/UnsignedInteger" }, @@ -1546,7 +1546,7 @@ }, { "name": "tournament_address", - "description": "Filter match advances by tournament address (hex encoded).", + "description": "The tournament address of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/EthereumAddress" }, @@ -1583,7 +1583,7 @@ }, { "name": "descending", - "description": "if true, the list will be sorted in descending order by epoch index.", + "description": "If true, reverse both block number and log index ordering.", "schema": { "type": "boolean", "default": false @@ -1618,7 +1618,7 @@ { "name": "cartesi_getMatchAdvance", "summary": "Get a specific match advance", - "description": "Fetches a single match advance by application, epoch index, tournament address, ID hash and parent.", + "description": "Returns one complete immutable advance record within the supplied application, epoch, tournament, and match. Use the transaction hash and block-global log index from listMatchAdvances. List and get return the same record fields.", "params": [ { "name": "application", @@ -1630,7 +1630,7 @@ }, { "name": "epoch_index", - "description": "The index of the epoch (hex encoded).", + "description": "The epoch index of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/UnsignedInteger" }, @@ -1638,7 +1638,7 @@ }, { "name": "tournament_address", - "description": "The tournament address (hex encoded).", + "description": "The tournament address of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/EthereumAddress" }, @@ -1646,19 +1646,27 @@ }, { "name": "id_hash", - "description": "The ID hash of the match advance (hex encoded).", + "description": "The ID hash of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/Hash" }, "required": true }, { - "name": "parent", - "description": "The parent hash of the match advance (hex encoded).", + "name": "tx_hash", + "description": "Transaction hash of the event.", "schema": { "$ref": "#/components/schemas/Hash" }, "required": true + }, + { + "name": "log_index", + "description": "Block-global log index of the event, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true } ], "result": { @@ -1688,6 +1696,153 @@ } ] }, + { + "name": "cartesi_listBondEvents", + "summary": "List bond events", + "description": "Returns partial-refund and bond-recovery events, ordered by block number and block-global log index. A failed partial refund records the requested value, not a payment. A failed terminal bond transfer emits no BondRecovered event; it remains recoverable in the tournament snapshot.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Filter bond events by a specific epoch index (hex encoded).", + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/UnsignedInteger"}, + {"type": "null"} + ] + }, + "required": false + }, + { + "name": "tournament_address", + "description": "Filter bond events by tournament address (hex encoded).", + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/EthereumAddress"}, + {"type": "null"} + ] + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of bond events to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of bond events to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "If true, reverse both block number and log index ordering.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BondEventListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getBondEvent", + "summary": "Get a bond event", + "description": "Returns one refund or recovery event by application, transaction hash, and block-global log index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "tx_hash", + "description": "Transaction hash of the event.", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + }, + { + "name": "log_index", + "description": "Block-global log index of the event, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BondEventGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/BondEventNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, { "name": "cartesi_getNodeInfo", "summary": "Get node information", @@ -1809,9 +1964,6 @@ "withdrawal_config": { "$ref": "#/components/schemas/WithdrawalConfig" }, - "data_availability": { - "$ref": "#/components/schemas/ByteArray" - }, "consensus_type": { "$ref": "#/components/schemas/Consensus" }, @@ -2788,38 +2940,65 @@ "height": { "$ref": "#/components/schemas/UnsignedInteger" }, - "winner_commitment": { - "oneOf": [ - { - "$ref": "#/components/schemas/Hash" - }, - { - "type": "null" - } - ] + "created_at": { + "type": "string", + "format": "date-time" }, - "final_state_hash": { + "updated_at": { + "type": "string", + "format": "date-time" + }, + "initial_hash": { + "$ref": "#/components/schemas/Hash" + }, + "base_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "kind": { + "$ref": "#/components/schemas/TournamentKind" + }, + "start_instant": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Tournament creation block." + }, + "allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Initial clock allowance in blocks." + }, + "creation_event": { "oneOf": [ { - "$ref": "#/components/schemas/Hash" + "$ref": "#/components/schemas/TournamentCreationEvent" }, { "type": "null" } ] }, - "finished_at_block": { - "$ref": "#/components/schemas/UnsignedInteger" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" + "snapshot": { + "$ref": "#/components/schemas/TournamentSnapshot" } - } + }, + "description": "Immutable tournament descriptor and parent relation, with a current snapshot read at a stated block. Local epoch acceptance or application failure does not make contract state immutable.", + "required": [ + "epoch_index", + "address", + "parent_tournament_address", + "parent_match_id_hash", + "max_level", + "level", + "log2step", + "height", + "created_at", + "updated_at", + "initial_hash", + "base_cycle", + "kind", + "start_instant", + "allowance", + "creation_event", + "snapshot" + ] }, "TournamentListResult": { "type": "object", @@ -2874,8 +3053,28 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "snapshot": { + "$ref": "#/components/schemas/CommitmentSnapshot" } - } + }, + "description": "Immutable joined commitment and submitter, with current claimer and clocks. Every indexed commitment has joined; retained clocks or a zero claimer do not prove liveness.", + "required": [ + "epoch_index", + "tournament_address", + "commitment", + "final_state_hash", + "submitter_address", + "block_number", + "tx_hash", + "created_at", + "updated_at", + "log_index", + "snapshot" + ] }, "CommitmentListResult": { "type": "object", @@ -2912,13 +3111,13 @@ "$ref": "#/components/schemas/Hash" }, "commitment_one": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "commitment_two": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "left_of_two": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "block_number": { "$ref": "#/components/schemas/UnsignedInteger" @@ -2936,7 +3135,14 @@ "$ref": "#/components/schemas/UnsignedInteger" }, "deletion_tx_hash": { - "$ref": "#/components/schemas/Hash" + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] }, "created_at": { "type": "string", @@ -2945,8 +3151,59 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable MatchCreated deadline, not the current responder deadline." + }, + "leaf_seal": { + "oneOf": [ + { + "$ref": "#/components/schemas/LeafMatchSeal" + }, + { + "type": "null" + } + ] + }, + "deletion_log_index": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnsignedInteger" + }, + { + "type": "null" + } + ] + }, + "snapshot": { + "$ref": "#/components/schemas/MatchSnapshot" } - } + }, + "required": [ + "epoch_index", + "tournament_address", + "id_hash", + "commitment_one", + "commitment_two", + "left_of_two", + "block_number", + "tx_hash", + "winner_commitment", + "deletion_reason", + "deletion_block_number", + "deletion_tx_hash", + "created_at", + "updated_at", + "log_index", + "eliminable_at", + "leaf_seal", + "deletion_log_index", + "snapshot" + ] }, "MatchListResult": { "type": "object", @@ -2983,10 +3240,10 @@ "$ref": "#/components/schemas/Hash" }, "other_parent": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "left_node": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "block_number": { "$ref": "#/components/schemas/UnsignedInteger" @@ -3001,8 +3258,32 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "segment_start_position": { + "$ref": "#/components/schemas/Uint256" + }, + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable both-sides elimination deadline reported by this advance." } - } + }, + "required": [ + "epoch_index", + "tournament_address", + "id_hash", + "other_parent", + "left_node", + "block_number", + "tx_hash", + "created_at", + "updated_at", + "log_index", + "segment_start_position", + "eliminable_at" + ] }, "MatchAdvancedListResult": { "type": "object", @@ -3025,22 +3306,680 @@ "$ref": "#/components/schemas/MatchAdvanced" } } - } - }, - "errors": { - "InvalidParams": { - "code": -32602, - "message": "Invalid parameters" }, - "InternalError": { - "code": -32603, - "message": "Internal server error" + "TournamentKind": { + "type": "string", + "enum": [ + "LEAF", + "NON_LEAF" + ] }, - "TimeoutError": { - "code": -32070, - "message": "Request timed out" + "TournamentStandingState": { + "type": "string", + "enum": [ + "MATCHES_ACTIVE", + "AWAITING_CLOSURE", + "ROOT_WINNER", + "ROOT_FAILED", + "INNER_WINNER", + "INNER_ELIMINABLE_NO_WINNER", + "INNER_ELIMINABLE_WINNER_EXPIRED" + ] }, - "ResponseSizeLimitExceeded": { + "MatchPhase": { + "type": "string", + "enum": [ + "UNINITIALIZED", + "BISECTING", + "READY_TO_SEAL", + "SEALED" + ] + }, + "CommitmentSide": { + "type": "string", + "enum": [ + "ONE", + "TWO" + ] + }, + "MatchTimeoutOutcome": { + "type": "string", + "enum": [ + "NONE", + "ONE_WINS", + "TWO_WINS", + "ELIMINATE_BOTH" + ] + }, + "InnerTournamentDisposition": { + "type": "string", + "enum": [ + "UNSETTLED", + "WINNER", + "ELIMINABLE" + ] + }, + "BondDisposition": { + "type": "string", + "enum": [ + "TOURNAMENT_RUNNING", + "NO_WINNER", + "RECOVERABLE", + "RECOVERED" + ] + }, + "BondEventType": { + "type": "string", + "enum": [ + "PARTIAL_BOND_REFUND", + "BOND_RECOVERED" + ] + }, + "Uint256": { + "type": "string", + "pattern": "^0x(0|[1-9a-f][0-9a-f]{0,63})$", + "description": "An exact uint256 quantity in canonical hexadecimal form. The maximum is 2^256-1. Never a floating-point JSON number." + }, + "TournamentCreationEvent": { + "type": "object", + "description": "Immutable NewInnerTournament event, emitted by the parent tournament. A root has no such event.", + "properties": { + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + } + }, + "required": [ + "block_number", + "tx_hash", + "log_index" + ] + }, + "LeafMatchSeal": { + "type": "object", + "properties": { + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable deadline from LeafMatchSealed; both sides are eliminable at this block." + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + } + }, + "required": [ + "eliminable_at", + "block_number", + "tx_hash", + "log_index" + ] + }, + "TournamentInnerResult": { + "type": "object", + "properties": { + "disposition": { + "$ref": "#/components/schemas/InnerTournamentDisposition" + }, + "parent_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "paused_allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Remaining carryover allowance in blocks at as_of_block." + } + }, + "required": [ + "disposition", + "parent_commitment", + "paused_allowance" + ] + }, + "TournamentBondRecovery": { + "type": "object", + "description": "Claimer and payment are present only for RECOVERABLE. A present zero payment is valid.", + "properties": { + "disposition": { + "$ref": "#/components/schemas/BondDisposition" + }, + "claimer": { + "oneOf": [ + { + "$ref": "#/components/schemas/EthereumAddress" + }, + { + "type": "null" + } + ] + }, + "payment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Uint256" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "disposition", + "claimer", + "payment" + ] + }, + "TournamentSnapshot": { + "type": "object", + "description": "Current contract state, not historical winner data. An expired inner candidate remains a candidate, but is not a current winner. Inner result is null for a root. Separate API calls do not form one database snapshot.", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Configured-policy block at which every view in this snapshot was read." + }, + "standing": { + "$ref": "#/components/schemas/TournamentStandingState" + }, + "accepts_joins": { + "type": "boolean" + }, + "candidate": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "winner_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "final_state_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "parent_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "finished_at_block": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Completion block, or zero while unfinished. Completion does not stop observation." + }, + "winner_expires_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Expiry block for a live inner winner; zero for other standings." + }, + "inner_result": { + "oneOf": [ + { + "$ref": "#/components/schemas/TournamentInnerResult" + }, + { + "type": "null" + } + ] + }, + "bond_recovery": { + "$ref": "#/components/schemas/TournamentBondRecovery" + } + }, + "required": [ + "as_of_block", + "standing", + "accepts_joins", + "candidate", + "winner_commitment", + "final_state_hash", + "parent_commitment", + "finished_at_block", + "winner_expires_at", + "inner_result", + "bond_recovery" + ] + }, + "CommitmentSnapshot": { + "type": "object", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "claimer": { + "$ref": "#/components/schemas/EthereumAddress", + "description": "Current claimer, which can be zero after elimination or recovery. The original submitter remains an immutable join field." + }, + "clock_running": { + "type": "boolean" + }, + "clock_deadline": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Responder expiry block when running; zero when paused." + }, + "clock_allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Paused allowance in blocks. Clock storage alone does not prove that a commitment is live." + } + }, + "required": [ + "as_of_block", + "claimer", + "clock_running", + "clock_deadline", + "clock_allowance" + ] + }, + "MatchBisectionSnapshot": { + "type": "object", + "description": "Current divergence frontier. Current height is present for BISECTING and null for READY_TO_SEAL. Responder side ONE/TWO is not the deletion winner enum.", + "properties": { + "revealing_parent": { + "$ref": "#/components/schemas/Hash" + }, + "waiting_left": { + "$ref": "#/components/schemas/Hash" + }, + "waiting_right": { + "$ref": "#/components/schemas/Hash" + }, + "segment_start_position": { + "$ref": "#/components/schemas/Uint256" + }, + "segment_start_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "current_height": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnsignedInteger" + }, + { + "type": "null" + } + ] + }, + "responder": { + "$ref": "#/components/schemas/CommitmentSide" + } + }, + "required": [ + "revealing_parent", + "waiting_left", + "waiting_right", + "segment_start_position", + "segment_start_cycle", + "current_height", + "responder" + ] + }, + "MatchSealedSnapshot": { + "type": "object", + "description": "Sealed values are in commitment-side order, not reveal order.", + "properties": { + "agree_state": { + "$ref": "#/components/schemas/Hash" + }, + "divergence_position": { + "$ref": "#/components/schemas/Uint256" + }, + "divergence_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "final_state_one": { + "$ref": "#/components/schemas/Hash" + }, + "final_state_two": { + "$ref": "#/components/schemas/Hash" + } + }, + "required": [ + "agree_state", + "divergence_position", + "divergence_cycle", + "final_state_one", + "final_state_two" + ] + }, + "MatchSnapshot": { + "type": "object", + "description": "Current match state. Deleted matches have UNINITIALIZED phase and no phase payload; deletion facts remain on the match. A sealed non-leaf match has no local timeout outcome because its child controls progress.", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "phase": { + "$ref": "#/components/schemas/MatchPhase" + }, + "bisection": { + "oneOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "type": "null" + } + ] + }, + "sealed": { + "oneOf": [ + { + "$ref": "#/components/schemas/MatchSealedSnapshot" + }, + { + "type": "null" + } + ] + }, + "timeout_outcome": { + "$ref": "#/components/schemas/MatchTimeoutOutcome" + }, + "deferred_charge": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Deferred charge in blocks from the timeout classifier at as_of_block." + } + }, + "required": [ + "as_of_block", + "phase", + "bisection", + "sealed", + "timeout_outcome", + "deferred_charge" + ], + "oneOf": [ + { + "properties": { + "phase": { + "const": "UNINITIALIZED" + }, + "bisection": { + "type": "null" + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "BISECTING" + }, + "bisection": { + "allOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "properties": { + "current_height": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + } + ] + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "READY_TO_SEAL" + }, + "bisection": { + "allOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "properties": { + "current_height": { + "type": "null" + } + } + } + ] + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "SEALED" + }, + "bisection": { + "type": "null" + }, + "sealed": { + "$ref": "#/components/schemas/MatchSealedSnapshot" + } + } + } + ] + }, + "PartialBondRefund": { + "type": "object", + "description": "Value is the requested refund. It was paid only if success is true.", + "properties": { + "recipient": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "value": { + "$ref": "#/components/schemas/Uint256" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "recipient", + "value", + "success" + ] + }, + "BondRecovered": { + "type": "object", + "properties": { + "commitment": { + "$ref": "#/components/schemas/Hash" + }, + "claimer": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "payment": { + "$ref": "#/components/schemas/Uint256" + }, + "burned": { + "$ref": "#/components/schemas/Uint256" + } + }, + "required": [ + "commitment", + "claimer", + "payment", + "burned" + ] + }, + "BondEvent": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tournament_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "type": { + "$ref": "#/components/schemas/BondEventType" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + }, + "refund": { + "oneOf": [ + { + "$ref": "#/components/schemas/PartialBondRefund" + }, + { + "type": "null" + } + ] + }, + "recovery": { + "oneOf": [ + { + "$ref": "#/components/schemas/BondRecovered" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "epoch_index", + "tournament_address", + "type", + "block_number", + "tx_hash", + "log_index", + "refund", + "recovery", + "created_at", + "updated_at" + ], + "oneOf": [ + { + "properties": { + "type": { + "const": "PARTIAL_BOND_REFUND" + }, + "refund": { + "$ref": "#/components/schemas/PartialBondRefund" + }, + "recovery": { + "type": "null" + } + } + }, + { + "properties": { + "type": { + "const": "BOND_RECOVERED" + }, + "refund": { + "type": "null" + }, + "recovery": { + "$ref": "#/components/schemas/BondRecovered" + } + } + } + ] + }, + "BondEventListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BondEvent" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + }, + "required": [ + "data", + "pagination" + ] + }, + "BondEventGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/BondEvent" + } + }, + "required": [ + "data" + ] + } + }, + "errors": { + "InvalidParams": { + "code": -32602, + "message": "Invalid parameters" + }, + "InternalError": { + "code": -32603, + "message": "Internal server error" + }, + "TimeoutError": { + "code": -32070, + "message": "Request timed out" + }, + "ResponseSizeLimitExceeded": { "code": -31003, "message": "Response size limit exceeded" }, @@ -3088,6 +4027,10 @@ "code": -31001, "message": "Match advanced not found" }, + "BondEventNotFound": { + "code": -31001, + "message": "Bond event not found" + }, "NodeConfigNotFound": { "code": -31001, "message": "EVM Reader config not found" diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 93c7546bb..0c387e2a3 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -17,6 +17,8 @@ import ( "runtime/debug" "unicode/utf8" + "github.com/ethereum/go-ethereum/common" + "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/evmreader" "github.com/cartesi/rollups-node/internal/jsonrpc/api" @@ -109,6 +111,8 @@ var jsonrpcHandlers = dispatchTable{ "cartesi_getMatch": handleGetMatch, "cartesi_listMatchAdvances": handleListMatchAdvances, "cartesi_getMatchAdvance": handleGetMatchAdvance, + "cartesi_listBondEvents": handleListBondEvents, + "cartesi_getBondEvent": handleGetBondEvent, "cartesi_getNodeInfo": handleGetNodeInfo, "cartesi_getChainId": handleGetChainID, "cartesi_getNodeVersion": handleGetNodeVersion, @@ -125,6 +129,7 @@ var listParamsTypes = map[string]reflect.Type{ "cartesi_listCommitments": reflect.TypeOf(api.ListCommitmentsParams{}), "cartesi_listMatches": reflect.TypeOf(api.ListMatchesParams{}), "cartesi_listMatchAdvances": reflect.TypeOf(api.ListMatchAdvancesParams{}), + "cartesi_listBondEvents": reflect.TypeOf(api.ListBondEventsParams{}), } func truncatedMethod(method string) string { @@ -1560,26 +1565,17 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } - // Create match advance filter based on params - epochIndex, err := config.ToIndexFromString(params.EpochIndex) + epochIndex, err := parseMatchScope(params.EpochIndex, params.TournamentAddress, params.IDHash) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) - } - - if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) - } - - if _, err := config.ToHashFromString(params.IDHash); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) + return nil, err } pagination := repository.Pagination{ Limit: params.Limit, Offset: params.Offset, } - matchAdvances, total, err := s.repository.ListMatchAdvances(r.Context(), params.Application, epochIndex, - params.TournamentAddress, params.IDHash, pagination, params.Descending) + matchAdvances, total, err := s.repository.ListMatchAdvances(r.Context(), params.Application, + epochIndex, params.TournamentAddress, params.IDHash, pagination, params.Descending) if err != nil { return nil, s.repositoryError(r.Context(), "Unable to retrieve match advances from repository", err) } @@ -1609,46 +1605,145 @@ func handleGetMatchAdvance(s *Service, r *http.Request, req RPCRequest) (any, er return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } - // Validate application parameter - if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + txHash, logIndex, err := parseTournamentEventIdentity(params.Application, params.TxHash, params.LogIndex) + if err != nil { + return nil, err + } + epochIndex, err := parseMatchScope(params.EpochIndex, params.TournamentAddress, params.IDHash) + if err != nil { + return nil, err + } + matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, + epochIndex, params.TournamentAddress, params.IDHash, txHash, logIndex) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve match advanced from repository", err) + } + if matchAdvanced == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found") } - epochIndex, err := config.ToIndexFromString(params.EpochIndex) + return api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}, nil +} + +func parseMatchScope(epoch, tournament, id string) (uint64, error) { + epochIndex, err := config.ToIndexFromString(epoch) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + if _, err := config.ToAddressFromString(tournament); err != nil { + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } + if _, err := config.ToHashFromString(id); err != nil { + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) + } + return epochIndex, nil +} - if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) +func parseTournamentEventIdentity(application, hash, index string) (common.Hash, uint64, error) { + if err := validateNameOrAddress(application); err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + txHash, err := config.ToHashFromString(hash) + if err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid transaction hash: %v", err)) + } + logIndex, err := config.ToIndexFromString(index) + if err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid log index: %v", err)) } + return txHash, logIndex, nil +} - if _, err := config.ToHashFromString(params.IDHash); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) +func parseTournamentEventFilter(epoch, tournament *string) (*uint64, *common.Address, error) { + var epochIndex *uint64 + if epoch != nil { + value, err := config.ToIndexFromString(*epoch) + if err != nil { + return nil, nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + epochIndex = &value + } + var tournamentAddress *common.Address + if tournament != nil { + value, err := config.ToAddressFromString(*tournament) + if err != nil { + return nil, nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + tournamentAddress = &value } + return epochIndex, tournamentAddress, nil +} - parent, err := config.ToHashFromString(params.Parent) +func handleListBondEvents(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListBondEventsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + if params.Limit == 0 { + params.Limit = LIST_ITEM_DEFAULT + } + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + epochIndex, tournamentAddress, err := parseTournamentEventFilter(params.EpochIndex, params.TournamentAddress) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent hash: %v", err)) + return nil, err } - - matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, epochIndex, - params.TournamentAddress, params.IDHash, parent.Hex()[2:]) + filter := repository.BondEventFilter{EpochIndex: epochIndex, TournamentAddress: tournamentAddress} + pagination := repository.Pagination{Limit: params.Limit, Offset: params.Offset} + events, total, err := s.repository.ListBondEvents(r.Context(), params.Application, filter, pagination, params.Descending) if err != nil { - return nil, s.repositoryError(r.Context(), "Unable to retrieve match advanced from repository", err) + return nil, s.repositoryError(r.Context(), "Unable to retrieve bond events from repository", err) } - if matchAdvanced == nil { + if len(events) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found") } + if events == nil { + events = []*model.BondEvent{} + } + return api.ListResponse[*model.BondEvent]{ + Data: events, + Pagination: api.Pagination{TotalCount: total, Limit: params.Limit, Offset: params.Offset}, + }, nil +} - return api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}, nil +func handleGetBondEvent(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetBondEventParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + txHash, logIndex, err := parseTournamentEventIdentity(params.Application, params.TxHash, params.LogIndex) + if err != nil { + return nil, err + } + event, err := s.repository.GetBondEvent(r.Context(), params.Application, txHash, logIndex) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve bond event from repository", err) + } + if event == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Bond event not found") + } + return api.SingleResponse[*model.BondEvent]{Data: event}, nil } func handleGetNodeInfo(s *Service, r *http.Request, _ RPCRequest) (any, error) { - cfg, err := repository.LoadNodeConfig[evmreader.PersistentConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) + cfg, err := repository.LoadNodeConfig[config.PersistentChainConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) if errors.Is(err, repository.ErrNotFound) { return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") } @@ -1664,7 +1759,7 @@ func handleGetNodeInfo(s *Service, r *http.Request, _ RPCRequest) (any, error) { } func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { - config, err := repository.LoadNodeConfig[evmreader.PersistentConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) + cfg, err := repository.LoadNodeConfig[config.PersistentChainConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) if errors.Is(err, repository.ErrNotFound) { return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") } @@ -1672,7 +1767,7 @@ func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { return nil, s.repositoryError(r.Context(), "Unable to retrieve evmreader config from repository", err) } - return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", config.Value.ChainID)}, nil + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", cfg.Value.ChainID)}, nil } func handleGetNodeVersion(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index 626883048..8dda80e4e 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -47,6 +47,8 @@ type jsonrpcSchema struct { } } +const matchDeletionBlock = uint64(101) + // failure: invalid JSON (extra ',' at the end) func TestInvalidJSON(t *testing.T) { s := newTestService(t, t.Name()) @@ -185,6 +187,11 @@ func TestMethod(t *testing.T) { resp := testRPCResponse[model.Application]{} assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, nr, nameToNumber(resp.Result.Data.Name)) + + var raw testRPCResponse[map[string]json.RawMessage] + require.NoError(t, json.Unmarshal(body, &raw)) + require.Contains(t, raw.Result.Data, "iinputbox_address") + require.NotContains(t, raw.Result.Data, "data_availability") }) }) @@ -283,7 +290,7 @@ func TestMethod(t *testing.T) { &model.NodeConfig[evmreader.PersistentConfig]{ Key: evmreader.EvmReaderConfigKey, Value: evmreader.PersistentConfig{ - ChainID: nr, + ChainID: nr, DefaultBlock: model.DefaultBlock_Finalized, }, }, ) @@ -1255,6 +1262,12 @@ func TestMethod(t *testing.T) { assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, 1, len(resp.Result.Data)) assert.Equal(t, numberToName(nr), resp.Result.Data[0].Name) + + var raw testRPCResponse[[]map[string]json.RawMessage] + require.NoError(t, json.Unmarshal(body, &raw)) + require.Len(t, raw.Result.Data, 1) + require.Contains(t, raw.Result.Data[0], "iinputbox_address") + require.NotContains(t, raw.Result.Data[0], "data_availability") }) // success: 1 application is in the database (array params) -> 1 @@ -2365,19 +2378,17 @@ func TestMethod(t *testing.T) { }`, numberToName(app), tnr)) type Result struct { - EpochIndex hex64 `json:"epoch_index"` - Address common.Address `json:"address"` - ParentTournamentAddress *common.Address `json:"parent_tournament_address"` - ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` - MaxLevel hex64 `json:"max_level"` - Level hex64 `json:"level"` - Log2Step hex64 `json:"log2step"` - Height hex64 `json:"height"` - WinnerCommitment *common.Hash `json:"winner_commitment"` - FinalStateHash *common.Hash `json:"final_state_hash"` - FinishedAtBlock hex64 `json:"finished_at_block"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + EpochIndex hex64 `json:"epoch_index"` + Address common.Address `json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel hex64 `json:"max_level"` + Level hex64 `json:"level"` + Log2Step hex64 `json:"log2step"` + Height hex64 `json:"height"` + Snapshot model.TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } resp := testRPCResponse[Result]{} @@ -2868,7 +2879,7 @@ func TestMethod(t *testing.T) { WithCommitmentOne(commitment.Commitment). WithCommitmentTwo(commitment.Commitment). WithWinner(model.WinnerCommitment_NONE). - WithDeletionReason(model.MatchDeletionReason_TIMEOUT). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). Build()) require.NoError(t, err, "failed to create match. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) @@ -2897,8 +2908,8 @@ func TestMethod(t *testing.T) { t.Run("cartesi_getMatchAdvance", func(t *testing.T) { method := getName(t.Name()) - // failure: epoch_index not hex encoded -> invalid param - t.Run("malformedEpochIndex", func(t *testing.T) { + // failure: log_index not hex encoded -> invalid param + t.Run("malformedLogIndex", func(t *testing.T) { testHistogram.inc(method) s := newTestService(t, t.Name()) @@ -2910,18 +2921,22 @@ func TestMethod(t *testing.T) { "method": "cartesi_getMatchAdvance", "params": { "application": "%v", - "epoch_index": "%v" + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "%v" }, "id": 0 - }`, numberToName(app), nr)) + }`, numberToName(app), 0, nr)) resp := testRPCResponse[any]{} assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) - assert.Equal(t, "Invalid epoch index: expected hex encoded value", resp.Error.Message) + assert.Equal(t, "Invalid log index: expected hex encoded value", resp.Error.Message) }) - // failure: epoch not in the database -> resource not found + // failure: event not in the database -> resource not found t.Run("absent", func(t *testing.T) { testHistogram.inc(method) s := newTestService(t, t.Name()) @@ -2942,13 +2957,14 @@ func TestMethod(t *testing.T) { "method": "cartesi_getMatchAdvance", "params": { "application": "%v", - "epoch_index": "0x%020x", - "tournament_address": "0x%040x", - "id_hash": "0x%064x", - "parent": "0x%064x" + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "0x0" }, "id": 0 - }`, numberToName(app), nr+1, 0, 0, 0)) + }`, numberToName(app), 0)) resp := testRPCResponse[any]{} assert.Nil(t, json.Unmarshal(body, &resp)) @@ -2967,13 +2983,14 @@ func TestMethod(t *testing.T) { "method": "cartesi_getMatchAdvance", "params": { "application": "%v", - "epoch_index": "0x%020x", - "tournament_address": "0x%040x", - "id_hash": "0x%064x", - "parent": "0x%064x" + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "0x0" }, "id": 0 - }`, numberToName(nr), 0, 0, 0, 0)) + }`, numberToName(nr), 0)) resp := testRPCResponse[any]{} assert.Nil(t, json.Unmarshal(body, &resp)) @@ -2981,7 +2998,7 @@ func TestMethod(t *testing.T) { assert.Equal(t, "Application not found", resp.Error.Message) }) - // success: commitment is in the database -> retrieve epoch + // success: event is in the database -> retrieve its full identity t.Run("present", func(t *testing.T) { testHistogram.inc(method) s := newTestService(t, t.Name()) @@ -3023,17 +3040,17 @@ func TestMethod(t *testing.T) { WithCommitmentOne(commitment.Commitment). WithCommitmentTwo(commitment.Commitment). WithWinner(model.WinnerCommitment_NONE). - WithDeletionReason(model.MatchDeletionReason_TIMEOUT). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). Build()) require.NoError(t, err, "failed to create match. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) - err = s.repository.CreateMatchAdvanced(ctx, numberToName(app), - repotest.NewMatchAdvancedBuilder(appID). - WithEpochIndex(nr). - WithTournamentAddress(address). - WithIDHash(idHash). - WithOtherParent(parent). - Build()) + advance := repotest.NewMatchAdvancedBuilder(appID). + WithEpochIndex(nr). + WithTournamentAddress(address). + WithIDHash(idHash). + WithOtherParent(parent). + Build() + err = s.repository.CreateMatchAdvanced(ctx, numberToName(app), advance) require.NoError(t, err, "failed to create match advanced. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ @@ -3041,18 +3058,20 @@ func TestMethod(t *testing.T) { "method": "cartesi_getMatchAdvance", "params": { "application": "%v", - "epoch_index": "0x%020x", - "tournament_address": "0x%020x", - "id_hash": "0x%064x", - "parent": "%s" + "epoch_index": "0x%x", + "tournament_address": "%s", + "id_hash": "%s", + "tx_hash": "%s", + "log_index": "0x%x" }, "id": 0 - }`, numberToName(app), nr, address, idHash, parentHex)) + }`, numberToName(app), nr, address.Hex(), idHash.Hex(), advance.TxHash.Hex(), advance.LogIndex)) resp := testRPCResponse[getMatchAdvancedResult]{} assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, idHash, resp.Result.Data.IDHash) assert.Equal(t, hex64(nr), resp.Result.Data.EpochIndex) + assert.Equal(t, advance.TxHash, resp.Result.Data.TxHash) }) }) @@ -3150,7 +3169,7 @@ func TestMethod(t *testing.T) { WithCommitmentOne(commitmentOne). WithCommitmentTwo(commitmentTwo). WithWinner(model.WinnerCommitment_NONE). - WithDeletionReason(model.MatchDeletionReason_TIMEOUT). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). Build()) require.NoError(t, err, "on test case: %v, application: %v, report_index: %v", 0, appID, tnr) } @@ -3345,7 +3364,6 @@ func TestMethod(t *testing.T) { WithCommitmentOne(commitment). WithCommitmentTwo(commitment). WithWinner(model.WinnerCommitment_NONE). - WithDeletionReason(model.MatchDeletionReason_NOT_DELETED). Build()) require.NoError(t, err) @@ -3951,6 +3969,54 @@ func TestMethod(t *testing.T) { }) }) + for _, method := range []string{"cartesi_getBondEvent", "cartesi_listBondEvents"} { + t.Run(method, func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := t.Context() + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID).WithStatus(model.EpochStatus_Closed).Build() + s.createTestEpoch(ctx, t, numberToName(app), epoch) + tournament := repotest.NewTournamentBuilder(appID).Build() + txHash := repotest.UniqueHash() + first := &model.BondEvent{ + ApplicationID: appID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Type: model.BondEventPartialRefund, BlockNumber: tournament.Snapshot.AsOfBlock, TxHash: txHash, + Refund: &model.PartialBondRefund{Recipient: repotest.UniqueAddress(), Success: false}, + } + second := *first + second.LogIndex = 1 + second.Refund = &model.PartialBondRefund{Recipient: repotest.UniqueAddress(), Success: true} + require.NoError(t, s.repository.StoreTournamentEvents(ctx, appID, []*repository.TournamentEventBatch{{ + Tournament: tournament, BondEvents: []*model.BondEvent{first, &second}, + }}, tournament.Snapshot.AsOfBlock)) + params := map[string]any{"application": numberToName(app)} + if method == "cartesi_getBondEvent" { + params["tx_hash"], params["log_index"] = txHash.Hex(), "0x1" + } else { + params["limit"], params["offset"], params["descending"] = 1, 1, false + } + request, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params, "id": 0}) + require.NoError(t, err) + body := s.doRequest(t, 0, request) + if method == "cartesi_getBondEvent" { + var response testRPCResponse[model.BondEvent] + require.NoError(t, json.Unmarshal(body, &response)) + require.Nil(t, response.Error) + require.Equal(t, second.LogIndex, response.Result.Data.LogIndex) + require.Equal(t, second.Refund, response.Result.Data.Refund) + } else { + var response testRPCResponse[[]model.BondEvent] + require.NoError(t, json.Unmarshal(body, &response)) + require.Nil(t, response.Error) + require.Len(t, response.Result.Data, 1) + require.Equal(t, second.LogIndex, response.Result.Data[0].LogIndex) + require.Equal(t, second.Refund, response.Result.Data[0].Refund) + } + }) + } + // tested methods, implemented methods and discover methods must match: data, err := discoverSpec.ReadFile("jsonrpc-discover.json") require.NoError(t, err) @@ -4050,6 +4116,7 @@ func TestListOffsetValidation(t *testing.T) { "cartesi_listCommitments", "cartesi_listMatches", "cartesi_listMatchAdvances", + "cartesi_listBondEvents", } { t.Run(method, func(t *testing.T) { s := newBatchTestService() diff --git a/internal/jsonrpc/match_projection_test.go b/internal/jsonrpc/match_projection_test.go new file mode 100644 index 000000000..3815c974b --- /dev/null +++ b/internal/jsonrpc/match_projection_test.go @@ -0,0 +1,77 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type matchProjectionRepository struct { + repository.Repository + match *Match +} + +func (r *matchProjectionRepository) GetMatch(context.Context, string, uint64, string, string) (*Match, error) { + return r.match, nil +} + +func TestMatchDeletionTransactionProjection(t *testing.T) { + for _, deleted := range []bool{false, true} { + name := "not deleted" + if deleted { + name = "deleted with zero hash" + } + t.Run(name, func(t *testing.T) { + match := &Match{TournamentAddress: common.HexToAddress("0x1"), IDHash: common.HexToHash("0x2"), + Winner: WinnerCommitment_NONE, DeletionReason: MatchDeletionReason_NOT_DELETED} + if deleted { + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = 1 + match.DeletionTxHash = new(common.Hash) + } + params, err := json.Marshal(api.GetMatchParams{Application: "app", EpochIndex: "0x0", + TournamentAddress: match.TournamentAddress.Hex(), IDHash: match.IDHash.Hex()}) + require.NoError(t, err) + s := &Service{repository: &matchProjectionRepository{match: match}} + response, err := handleGetMatch(s, httptest.NewRequest(http.MethodPost, "/", nil), RPCRequest{Params: params}) + require.NoError(t, err) + data, err := json.Marshal(response) + require.NoError(t, err) + var decoded struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(data, &decoded)) + if deleted { + require.JSONEq(t, `"`+(common.Hash{}).Hex()+`"`, string(decoded.Data["deletion_tx_hash"])) + } else { + require.Equal(t, "null", string(decoded.Data["deletion_tx_hash"])) + } + }) + } +} + +func TestDiscoverySchemaNullableMatchDeletionTransaction(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Components struct { + Schemas map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + require.JSONEq(t, `{"oneOf":[{"$ref":"#/components/schemas/Hash"},{"type":"null"}]}`, + string(spec.Components.Schemas["Match"].Properties["deletion_tx_hash"])) +} diff --git a/internal/jsonrpc/prt_observer_contract_test.go b/internal/jsonrpc/prt_observer_contract_test.go new file mode 100644 index 000000000..5a2ef87c6 --- /dev/null +++ b/internal/jsonrpc/prt_observer_contract_test.go @@ -0,0 +1,491 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const ( + observerGetBondEventMethod = "cartesi_getBondEvent" + observerListBondEventsMethod = "cartesi_listBondEvents" + observerGetMatchAdvanceMethod = "cartesi_getMatchAdvance" + observerListMatchAdvancesMethod = "cartesi_listMatchAdvances" + observerTestApplication = "app" + observerUint64Overflow = "0x10000000000000000" + observerApplicationKey = "application" + observerEpochIndexKey = "epoch_index" + observerTournamentAddressKey = "tournament_address" + observerMatchIDKey = "id_hash" + observerLogIndexKey = "log_index" + observerTxHashKey = "tx_hash" +) + +type observerAPIRepository struct { + repository.Repository + app *Application + bond *BondEvent + advance *MatchAdvanced + tournament *Tournament + commitment *Commitment + match *Match + err error + + application string + txHash common.Hash + logIndex uint64 + bondFilter repository.BondEventFilter + epochIndex uint64 + tournamentAddress string + idHash string + pagination repository.Pagination + descending bool +} + +func (r *observerAPIRepository) GetApplication(context.Context, string) (*Application, error) { + return r.app, r.err +} + +func (r *observerAPIRepository) GetBondEvent(_ context.Context, app string, tx common.Hash, index uint64) (*BondEvent, error) { + r.application, r.txHash, r.logIndex = app, tx, index + return r.bond, r.err +} + +func (r *observerAPIRepository) GetMatchAdvanced(_ context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, index uint64, +) (*MatchAdvanced, error) { + r.application, r.txHash, r.logIndex = app, tx, index + r.epochIndex, r.tournamentAddress, r.idHash = epoch, tournament, id + return r.advance, r.err +} + +func (r *observerAPIRepository) ListBondEvents(_ context.Context, app string, f repository.BondEventFilter, + p repository.Pagination, descending bool, +) ([]*BondEvent, uint64, error) { + r.application, r.bondFilter, r.pagination, r.descending = app, f, p, descending + if r.bond == nil || r.err != nil { + return nil, 0, r.err + } + return []*BondEvent{r.bond}, 1, nil +} + +func (r *observerAPIRepository) ListMatchAdvances(_ context.Context, app string, epoch uint64, tournament, id string, + p repository.Pagination, descending bool, +) ([]*MatchAdvanced, uint64, error) { + r.application, r.pagination, r.descending = app, p, descending + r.epochIndex, r.tournamentAddress, r.idHash = epoch, tournament, id + if r.advance == nil || r.err != nil { + return nil, 0, r.err + } + return []*MatchAdvanced{r.advance}, 1, nil +} + +func (r *observerAPIRepository) GetTournament(context.Context, string, string) (*Tournament, error) { + return r.tournament, r.err +} + +func (r *observerAPIRepository) GetCommitment(context.Context, string, uint64, string, string) (*Commitment, error) { + return r.commitment, r.err +} + +func (r *observerAPIRepository) GetMatch(context.Context, string, uint64, string, string) (*Match, error) { + return r.match, r.err +} + +func observerAPIRequest(t *testing.T, repo *observerAPIRepository, method string, params any) (json.RawMessage, error) { + t.Helper() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + encoded, err := json.Marshal(params) + require.NoError(t, err) + result, err := jsonrpcHandlers[method](s, httptest.NewRequest(http.MethodPost, "/", nil), RPCRequest{Params: encoded}) + if err != nil { + return nil, err + } + data, err := json.Marshal(result) + require.NoError(t, err) + return data, nil +} + +func TestTournamentEventAPIIdentityAndErrors(t *testing.T) { + tx := common.HexToHash("0xabc") + tournament, id := common.HexToAddress("0x12"), common.HexToHash("0x34") + for _, method := range []string{observerGetBondEventMethod, observerGetMatchAdvanceMethod} { + t.Run(method, func(t *testing.T) { + repo := &observerAPIRepository{app: &Application{}, + bond: &BondEvent{TxHash: tx, Type: BondEventPartialRefund, Refund: &PartialBondRefund{}}, + advance: &MatchAdvanced{TxHash: tx}} + for _, index := range []string{"0x0", "0xffffffffffffffff"} { + params := map[string]string{ + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: index, + } + if method == observerGetMatchAdvanceMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + _, err := observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.Equal(t, observerTestApplication, repo.application) + require.Equal(t, tx, repo.txHash) + if method == observerGetMatchAdvanceMethod { + require.Equal(t, uint64(7), repo.epochIndex) + require.Equal(t, tournament.Hex(), repo.tournamentAddress) + require.Equal(t, id.Hex(), repo.idHash) + } + if index == "0x0" { + require.Zero(t, repo.logIndex) + } else { + require.Equal(t, ^uint64(0), repo.logIndex) + } + } + for name, params := range map[string]map[string]string{ + "missing hash": {observerApplicationKey: observerTestApplication, observerLogIndexKey: "0x0"}, + "short hash": {observerApplicationKey: observerTestApplication, observerTxHashKey: "0x01", observerLogIndexKey: "0x0"}, + "missing index": {observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex()}, + "decimal index": {observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: "1"}, + "overflow index": { + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), + observerLogIndexKey: observerUint64Overflow, + }, + "legacy payload key": {observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x0", "parent": tx.Hex()}, + } { + t.Run(name, func(t *testing.T) { + _, err := observerAPIRequest(t, &observerAPIRepository{}, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, JSONRPC_INVALID_PARAMS, rpcErr.Code) + }) + } + params := map[string]string{ + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: "0x0", + } + if method == observerGetMatchAdvanceMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + for _, test := range []struct { + name string + repo *observerAPIRepository + code int + }{ + {"unknown application", &observerAPIRepository{}, JSONRPC_APPLICATION_NOT_FOUND}, + {"unknown event", &observerAPIRepository{app: &Application{}}, JSONRPC_RESOURCE_NOT_FOUND}, + {"repository failure", &observerAPIRepository{err: errors.New("read failed")}, JSONRPC_INTERNAL_ERROR}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := observerAPIRequest(t, test.repo, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, test.code, rpcErr.Code) + }) + } + }) + } +} + +func TestTournamentEventAPIListFilters(t *testing.T) { + tournament, id := common.HexToAddress("0x12"), common.HexToHash("0x34") + for _, method := range []string{observerListBondEventsMethod, observerListMatchAdvancesMethod} { + t.Run(method, func(t *testing.T) { + repo := &observerAPIRepository{app: &Application{}} + params := map[string]any{observerApplicationKey: observerTestApplication} + if method == observerListMatchAdvancesMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + response, err := observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.JSONEq(t, `{"data":[],"pagination":{"total_count":0,"limit":50,"offset":0}}`, string(response)) + require.Equal(t, repository.BondEventFilter{}, repo.bondFilter) + params[observerEpochIndexKey], params[observerTournamentAddressKey] = "0x7", tournament.Hex() + params["limit"], params["offset"], params["descending"] = 20000, 3, true + _, err = observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.Equal(t, repository.Pagination{Limit: LIST_ITEM_LIMIT, Offset: 3}, repo.pagination) + require.True(t, repo.descending) + if method == observerListBondEventsMethod { + require.Equal(t, repository.BondEventFilter{EpochIndex: new(uint64(7)), TournamentAddress: &tournament}, repo.bondFilter) + } else { + require.Equal(t, uint64(7), repo.epochIndex) + require.Equal(t, tournament.Hex(), repo.tournamentAddress) + require.Equal(t, id.Hex(), repo.idHash) + } + }) + } +} + +func TestMatchAdvanceAPIRequiresMatchScope(t *testing.T) { + for _, method := range []string{observerListMatchAdvancesMethod, observerGetMatchAdvanceMethod} { + for field, invalidValues := range map[string][]any{ + observerApplicationKey: {nil, json.RawMessage("null"), "", "invalid/name"}, + observerEpochIndexKey: {nil, json.RawMessage("null"), "", "1", observerUint64Overflow}, + observerTournamentAddressKey: {nil, json.RawMessage("null"), "", "not-an-address"}, + observerMatchIDKey: {nil, json.RawMessage("null"), "", "0x01"}, + } { + for _, value := range invalidValues { + t.Run(fmt.Sprintf("%s/%s/%v", method, field, value), func(t *testing.T) { + params := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x0", + observerTournamentAddressKey: (common.Address{}).Hex(), observerMatchIDKey: (common.Hash{}).Hex(), + observerTxHashKey: (common.Hash{}).Hex(), observerLogIndexKey: "0x0"} + if value == nil { + delete(params, field) + } else { + params[field] = value + } + repo := &observerAPIRepository{} + _, err := observerAPIRequest(t, repo, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, JSONRPC_INVALID_PARAMS, rpcErr.Code) + require.Empty(t, repo.application, "invalid scope must not reach the repository") + }) + } + } + } +} + +func TestBondEventAPIKeepsExactOutcomes(t *testing.T) { + maximum, err := Uint256FromBig(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))) + require.NoError(t, err) + for _, success := range []bool{false, true} { + repo := &observerAPIRepository{bond: &BondEvent{Type: BondEventPartialRefund, BlockNumber: 12, LogIndex: 0, + Refund: &PartialBondRefund{Recipient: common.HexToAddress("0x1"), Value: maximum, Success: success}}} + response, err := observerAPIRequest(t, repo, observerGetBondEventMethod, api.GetBondEventParams{ + Application: observerTestApplication, TxHash: repo.bond.TxHash.Hex(), LogIndex: "0x0", + }) + require.NoError(t, err) + var data struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &data)) + require.Equal(t, "null", string(data.Data["recovery"])) + require.JSONEq(t, fmt.Sprintf(`{"recipient":%q,"value":%q,"success":%t}`, + repo.bond.Refund.Recipient.Hex(), "0x"+strings.Repeat("f", 64), success), string(data.Data["refund"])) + require.Equal(t, `"0x0"`, string(data.Data[observerLogIndexKey])) + } + repo := &observerAPIRepository{bond: &BondEvent{Type: BondEventRecovered, + Recovery: &BondRecovered{Payment: Uint256{}, Burned: maximum}}} + response, err := observerAPIRequest(t, repo, observerGetBondEventMethod, api.GetBondEventParams{ + Application: observerTestApplication, TxHash: repo.bond.TxHash.Hex(), LogIndex: "0x0", + }) + require.NoError(t, err) + var result struct { + Data BondEvent `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + require.Nil(t, result.Data.Refund) + require.Equal(t, repo.bond.Recovery, result.Data.Recovery) +} + +func TestTournamentAPISeparatesCurrentStateFromFacts(t *testing.T) { + address := common.HexToAddress("0x12") + candidate := common.HexToHash("0x34") + repo := &observerAPIRepository{tournament: &Tournament{Address: address, Level: 1, + Snapshot: TournamentSnapshot{AsOfBlock: 100, Standing: TournamentStandingInnerEliminableWinnerExpired, + Candidate: &candidate, FinishedAtBlock: 80, + InnerResult: &TournamentInnerResult{Disposition: InnerTournamentEliminable}, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}}}} + response, err := observerAPIRequest(t, repo, "cartesi_getTournament", + api.GetTournamentParams{Application: observerTestApplication, Address: address.Hex()}) + require.NoError(t, err) + var decoded struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &decoded)) + for _, field := range []string{"winner_commitment", "final_state_hash", "finished_at_block"} { + require.NotContains(t, decoded.Data, field, "current result must not have a duplicate top-level field") + } + var snapshot map[string]json.RawMessage + require.NoError(t, json.Unmarshal(decoded.Data["snapshot"], &snapshot)) + require.Equal(t, `"0x64"`, string(snapshot["as_of_block"])) + require.Equal(t, `"`+candidate.Hex()+`"`, string(snapshot["candidate"])) + require.Equal(t, "null", string(snapshot["winner_commitment"])) + require.Equal(t, "null", string(snapshot["final_state_hash"])) + require.Equal(t, "null", string(snapshot["parent_commitment"])) + require.Equal(t, `"0x0"`, string(snapshot["winner_expires_at"])) +} + +func TestMatchAPIPreservesPhasePayloads(t *testing.T) { + for _, phase := range []MatchPhase{MatchPhaseUninitialized, MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed} { + t.Run(string(phase), func(t *testing.T) { + match := &Match{Snapshot: MatchSnapshot{AsOfBlock: 200, Phase: phase, TimeoutOutcome: MatchTimeoutNone}} + switch phase { + case MatchPhaseBisecting, MatchPhaseReadyToSeal: + match.Snapshot.Bisection = &MatchBisectionSnapshot{Responder: CommitmentSideTwo} + if phase == MatchPhaseBisecting { + match.Snapshot.Bisection.CurrentHeight = new(uint64(4)) + } + case MatchPhaseSealed: + match.Snapshot.Sealed = &MatchSealedSnapshot{} + case MatchPhaseUninitialized: + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = 190 + match.DeletionTxHash, match.DeletionLogIndex = new(common.Hash), new(uint64) + } + response, err := observerAPIRequest(t, &observerAPIRepository{match: match}, "cartesi_getMatch", api.GetMatchParams{ + Application: observerTestApplication, EpochIndex: "0x0", + TournamentAddress: match.TournamentAddress.Hex(), IDHash: match.IDHash.Hex(), + }) + require.NoError(t, err) + var result struct { + Data struct { + Snapshot MatchSnapshot `json:"snapshot"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + require.Equal(t, match.Snapshot, result.Data.Snapshot) + }) + } +} + +func TestCommitmentAPIKeepsOriginalSubmitterAfterRecovery(t *testing.T) { + commitment := &Commitment{SubmitterAddress: common.HexToAddress("0x12"), FinalStateHash: common.HexToHash("0x34"), + Snapshot: CommitmentSnapshot{AsOfBlock: 100, ClockAllowance: 50}} + response, err := observerAPIRequest(t, &observerAPIRepository{commitment: commitment}, "cartesi_getCommitment", + api.GetCommitmentParams{Application: observerTestApplication, EpochIndex: "0x0", + TournamentAddress: commitment.TournamentAddress.Hex(), Commitment: commitment.Commitment.Hex()}) + require.NoError(t, err) + var decoded struct { + Data Commitment `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &decoded)) + require.Equal(t, commitment.SubmitterAddress, decoded.Data.SubmitterAddress) + require.Equal(t, commitment.FinalStateHash, decoded.Data.FinalStateHash) + require.Equal(t, common.Address{}, decoded.Data.Snapshot.Claimer) + require.Equal(t, commitment.Snapshot, decoded.Data.Snapshot) +} + +func TestMatchAdvanceAPIKeepsFullWidthPosition(t *testing.T) { + position, err := Uint256FromBig(new(big.Int).Lsh(big.NewInt(1), 200)) + require.NoError(t, err) + advance := &MatchAdvanced{EpochIndex: 7, TournamentAddress: common.HexToAddress("0x12"), + IDHash: common.HexToHash("0x34"), OtherParent: common.HexToHash("0x56"), LeftNode: common.HexToHash("0x78"), + BlockNumber: 100, TxHash: common.HexToHash("0x90"), SegmentStartPosition: position, EliminableAt: 300, LogIndex: 4} + expected, err := json.Marshal(advance) + require.NoError(t, err) + for _, method := range []string{observerGetMatchAdvanceMethod, observerListMatchAdvancesMethod} { + t.Run(method, func(t *testing.T) { + params := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x7", + observerTournamentAddressKey: advance.TournamentAddress.Hex(), observerMatchIDKey: advance.IDHash.Hex()} + if method == observerGetMatchAdvanceMethod { + params[observerTxHashKey], params[observerLogIndexKey] = advance.TxHash.Hex(), "0x4" + } + response, err := observerAPIRequest(t, &observerAPIRepository{advance: advance}, method, params) + require.NoError(t, err) + var result struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + if method == observerListMatchAdvancesMethod { + var records []json.RawMessage + require.NoError(t, json.Unmarshal(result.Data, &records)) + require.Len(t, records, 1) + result.Data = records[0] + } + require.JSONEq(t, string(expected), string(result.Data), "list and get must return the complete same record") + var decoded struct { + Position Uint256 `json:"segment_start_position"` + EliminableAt string `json:"eliminable_at"` + LogIndex string `json:"log_index"` + TxHash common.Hash `json:"tx_hash"` + } + require.NoError(t, json.Unmarshal(result.Data, &decoded)) + require.Equal(t, position, decoded.Position) + require.Equal(t, "0x12c", decoded.EliminableAt) + require.Equal(t, "0x4", decoded.LogIndex) + require.Equal(t, advance.TxHash, decoded.TxHash) + }) + } +} + +func TestDiscoverySchemaPassiveObserver(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Methods []struct { + Name string `json:"name"` + Params []struct { + Name string `json:"name"` + Required bool `json:"required"` + } `json:"params"` + } `json:"methods"` + Components struct { + Schemas map[string]json.RawMessage `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + expected := map[string][]string{ + observerGetBondEventMethod: {observerApplicationKey, observerTxHashKey, observerLogIndexKey}, + observerListBondEventsMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, "limit", "offset", "descending", + }, + observerGetMatchAdvanceMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, observerMatchIDKey, + observerTxHashKey, observerLogIndexKey, + }, + observerListMatchAdvancesMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, observerMatchIDKey, + "limit", "offset", "descending", + }, + } + for _, method := range spec.Methods { + if want, ok := expected[method.Name]; ok { + names := make([]string, 0, len(method.Params)) + requiredCount := len(want) + switch method.Name { + case observerListMatchAdvancesMethod: + requiredCount = 4 + case observerListBondEventsMethod: + requiredCount = 1 + } + for index, param := range method.Params { + names = append(names, param.Name) + require.Equal(t, index < requiredCount, param.Required) + } + require.Equal(t, want, names) + delete(expected, method.Name) + } + } + require.Empty(t, expected, "all observer methods must be documented") + var wide struct { + Pattern string `json:"pattern"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas["Uint256"], &wide)) + pattern, err := regexp.Compile(wide.Pattern) + require.NoError(t, err) + for _, valid := range []string{"0x0", "0x1", observerUint64Overflow, "0x" + strings.Repeat("f", 64)} { + require.True(t, pattern.MatchString(valid), valid) + } + for _, invalid := range []string{"0x", "0x00", "-0x1", "0x" + strings.Repeat("f", 65), "1", "0x1.5"} { + require.False(t, pattern.MatchString(invalid), invalid) + } + for _, name := range []string{"Tournament", "Commitment", "Match"} { + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas[name], &schema)) + require.Contains(t, schema.Properties, "snapshot") + if name == "Tournament" { + require.NotContains(t, schema.Properties, "winner_commitment") + require.NotContains(t, schema.Properties, "finished_at_block") + } + } +} diff --git a/internal/jsonrpc/util_test.go b/internal/jsonrpc/util_test.go index 5c954f09f..54c8e5137 100644 --- a/internal/jsonrpc/util_test.go +++ b/internal/jsonrpc/util_test.go @@ -130,7 +130,6 @@ func (s *Service) newTestApplication(ctx context.Context, t *testing.T, i uint64 app := repotest.NewApplicationBuilder(). WithName(hex). WithAddress(common.HexToAddress(hex)). - WithDataAvailability([]byte{0x00, 0x00, 0x00, 0x00}). Create(ctx, t, s.repository) return app.ID } @@ -240,19 +239,17 @@ func (s *Service) advanceInput( } type listTournamentsResult struct { - EpochIndex hex64 `json:"epoch_index"` - Address common.Address `json:"address"` - ParentTournamentAddress *common.Address `json:"parent_tournament_address"` - ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` - MaxLevel hex64 `json:"max_level"` - Level hex64 `json:"level"` - Log2Step hex64 `json:"log2step"` - Height hex64 `json:"height"` - WinnerCommitment *common.Hash `json:"winner_commitment"` - FinalStateHash *common.Hash `json:"final_state_hash"` - FinishedAtBlock hex64 `json:"finished_at_block"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + EpochIndex hex64 `json:"epoch_index"` + Address common.Address `json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel hex64 `json:"max_level"` + Level hex64 `json:"level"` + Log2Step hex64 `json:"log2step"` + Height hex64 `json:"height"` + Snapshot model.TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type getCommitmentResult struct { @@ -280,7 +277,7 @@ type getMatchResult struct { TxHash common.Hash `json:"tx_hash"` Winner model.WinnerCommitment `json:"winner_commitment"` DeletionReason model.MatchDeletionReason `json:"deletion_reason"` - DeletionTxHash common.Hash `json:"deletion_tx_hash"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -310,7 +307,7 @@ type listMatchesResult struct { TxHash common.Hash `json:"tx_hash"` Winner model.WinnerCommitment `json:"winner_commitment"` DeletionReason model.MatchDeletionReason `json:"deletion_reason"` - DeletionTxHash common.Hash `json:"deletion_tx_hash"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } From 6a20e048384e0327fae3e1ce06ab8ca03d0dfb34 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:52:31 -0300 Subject: [PATCH 14/41] feat(cli)!: unify transaction submission Estimate gas by default and retain manual limits. Wait for a successful receipt unless --no-wait is set. Replace --async and update its callers. Keep hashes on broadcast and wait errors. Separate stdout results from stderr diagnostics, and omit automatic usage after runtime errors. Allow send by application address without database access. Read the InputBox from the Application contract, reject zero before signing, and require its input event. Keep decimal text and hexadecimal JSON. Allow execute by address and --proof-file without database or node API access. Preserve complete output bytes, the positional index, and ordered proof hashes. Require the matching Application output-execution event before reporting a mined result. Keep names and missing-data lookup in the DB. Test manual gas, strict no-wait, and missing action evidence. --- .../root/execute/execute.go | 309 +++++++++---- .../root/execute/execute_test.go | 412 ++++++++++++++++++ cmd/cartesi-rollups-cli/root/root.go | 9 +- cmd/cartesi-rollups-cli/root/send/send.go | 175 +++++--- .../root/send/send_test.go | 295 +++++++++++++ internal/cli/ethereum.go | 24 +- internal/cli/legacy_fees_test.go | 170 ++++++++ internal/cli/prompt.go | 10 +- internal/cli/transaction.go | 192 ++++++++ internal/cli/transaction_review_test.go | 109 +++++ internal/cli/transaction_test.go | 262 +++++++++++ internal/cli/types.go | 12 +- scripts/load-test.sh | 2 +- test/integration/cli_helpers_test.go | 2 +- 14 files changed, 1803 insertions(+), 180 deletions(-) create mode 100644 cmd/cartesi-rollups-cli/root/execute/execute_test.go create mode 100644 cmd/cartesi-rollups-cli/root/send/send_test.go create mode 100644 internal/cli/legacy_fees_test.go create mode 100644 internal/cli/transaction.go create mode 100644 internal/cli/transaction_review_test.go create mode 100644 internal/cli/transaction_test.go diff --git a/cmd/cartesi-rollups-cli/root/execute/execute.go b/cmd/cartesi-rollups-cli/root/execute/execute.go index f9891c60c..f0c35d958 100644 --- a/cmd/cartesi-rollups-cli/root/execute/execute.go +++ b/cmd/cartesi-rollups-cli/root/execute/execute.go @@ -4,135 +4,262 @@ package execute import ( + "bytes" + "context" "encoding/json" + "errors" "fmt" + "io" "os" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/repository/factory" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" - "github.com/cartesi/rollups-node/pkg/ethutil" ) -var Cmd = &cobra.Command{ - Use: "execute [app-name-or-address] [output-index]", - Short: "Executes a voucher", - Example: examples, - Args: cobra.ExactArgs(2), //nolint:mnd - Run: run, - Long: ` -Supported Environment Variables: - CARTESI_DATABASE_CONNECTION Database connection string - CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint`, -} +var Cmd = newCommand() -const examples = `# Executes voucher/output with index 5: -cartesi-rollups-cli execute echo-dapp 5 +type options struct { + proofFile string + skipConfirmation bool +} -# Executes voucher/output with index 3 using application address: -cartesi-rollups-cli execute 0x1234567890123456789012345678901234567890 3 +func newCommand() *cobra.Command { + var opts options + cmd := &cobra.Command{ + Use: "execute [app-name-or-address] [output-index]", + Short: "Execute a proven application output", + Example: examples, + Args: cobra.ExactArgs(2), //nolint:mnd // Application and output index. + RunE: opts.run, + PreRunE: func(command *cobra.Command, _ []string) error { + if command.Flags().Changed("proof-file") && opts.proofFile == "" { + return fmt.Errorf("--proof-file cannot be empty") + } + return nil + }, + Long: `Execute the output at the application-wide output index. -# Execute without confirmation prompt: -cartesi-rollups-cli execute echo-dapp 5 --yes` +Without --proof-file, read the output and its proof from the local database. +With --proof-file, read a JSON object with raw_data and output_hashes_siblings. +raw_data must contain the complete 0x-prefixed output bytes, not decoded payload. +output_hashes_siblings must contain the ordered 0x-prefixed 32-byte proof hashes. +The output index remains the second positional argument; do not put it in the file. -var ( - skipConfirmation bool - asJSONParam bool -) +An application address plus --proof-file needs no database or node JSON-RPC API. +An application name still needs the database to resolve its address. -func init() { - Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") - Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Required for local output or application-name lookup + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum HTTP endpoint`, + } + cmd.Flags().StringVar(&opts.proofFile, "proof-file", "", "JSON file with raw_data and output_hashes_siblings") + cmd.Flags().BoolVarP(&opts.skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().Bool("json", false, "Print result as JSON") + cli.AddTransactionFlags(cmd) - origHelpFunc := Cmd.HelpFunc() - Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { - command.Flags().Lookup("verbose").Hidden = false - command.Flags().Lookup("database-connection").Hidden = false - command.Flags().Lookup("blockchain-http-endpoint").Hidden = false - origHelpFunc(command, strings) + originalHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + for _, name := range []string{"verbose", "database-connection", "blockchain-http-endpoint"} { + if flag := command.Flags().Lookup(name); flag != nil { + flag.Hidden = false + } + } + originalHelp(command, args) }) + return cmd } -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() +const examples = `# Execute output 5 with its proof from the local database: +cartesi-rollups-cli execute echo-dapp 5 - nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) +# Export a proof from the node JSON-RPC API: +cartesi-rollups-cli read outputs echo-dapp 5 --jsonrpc | jq '.data | {raw_data, output_hashes_siblings}' > output-proof.json - outputIndex, err := config.ToUint64FromDecimalOrHexString(args[1]) - cobra.CheckErr(err) +# Execute the exported output without a database or node JSON-RPC connection: +cartesi-rollups-cli execute 0x1234567890123456789012345678901234567890 5 --proof-file output-proof.json --yes` - dsn, err := config.GetDatabaseConnection() - cobra.CheckErr(err) +type executionInput struct { + applicationAddress common.Address + output []byte + proof iapplication.OutputValidityProof +} +func (o *options) run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + outputIndex, err := config.ToUint64FromDecimalOrHexString(args[1]) + if err != nil { + return err + } + input, err := resolveExecution(ctx, nameOrAddress, outputIndex, o.proofFile) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } + client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + if err != nil { + return err + } + defer client.Close() + chainID, err := client.ChainID(ctx) + if err != nil { + return err + } + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return err + } + if !o.skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to execute application %v (%v) output index %v with account %v\n", + nameOrAddress, input.applicationAddress, outputIndex, txOpts.From) + if err != nil { + return err + } + confirmed, err := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + appContract, err := iapplication.NewIApplication(input.applicationAddress, client) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.ExecuteOutput(opts, input.output, input.proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) + } + if receipt != nil { + executed := false + for _, log := range receipt.Logs { + if log == nil || log.Address != input.applicationAddress { + continue + } + event, err := appContract.ParseOutputExecuted(*log) + if err == nil && event.OutputIndex == outputIndex && bytes.Equal(event.Output, input.output) { + executed = true + break + } + } + if !executed { + return fmt.Errorf("transaction %s mined, but its receipt has no matching OutputExecuted event", tx.Hash()) + } + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} +func resolveExecution(ctx context.Context, nameOrAddress string, outputIndex uint64, proofFile string) (executionInput, error) { + input := executionInput{proof: iapplication.OutputValidityProof{OutputIndex: outputIndex}} + if proofFile != "" { + output, siblings, err := loadOutputProof(proofFile) + if err != nil { + return executionInput{}, err + } + address, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return executionInput{}, err + } + input.applicationAddress = address + input.output = output + input.proof.OutputHashesSiblings = siblings + return input, nil + } + dsn, err := config.GetDatabaseConnection() + if err != nil { + return executionInput{}, err + } repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) - cobra.CheckErr(err) + if err != nil { + return executionInput{}, err + } defer repo.Close() - output, err := repo.GetOutput(ctx, nameOrAddress, outputIndex) - cobra.CheckErr(err) - + if err != nil { + return executionInput{}, err + } if output == nil { - fmt.Fprintf(os.Stderr, "The output with index %d was not found in the database\n", outputIndex) - repo.Close() - os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + return executionInput{}, fmt.Errorf("output with index %d was not found in the database", outputIndex) } - - app, err := repo.GetApplication(ctx, nameOrAddress) - cobra.CheckErr(err) - if len(output.OutputHashesSiblings) == 0 { - fmt.Fprintf(os.Stderr, "The output with index %d has no associated proof yet\n", outputIndex) - os.Exit(1) + return executionInput{}, fmt.Errorf("output with index %d has no associated proof yet", outputIndex) } + app, err := repo.GetApplication(ctx, nameOrAddress) + if err != nil { + return executionInput{}, err + } + if app == nil { + return executionInput{}, fmt.Errorf("application %q not found", nameOrAddress) + } + input.applicationAddress = app.IApplicationAddress + input.output = output.RawData + input.proof.OutputHashesSiblings = make([][32]byte, len(output.OutputHashesSiblings)) + for i, hash := range output.OutputHashesSiblings { + input.proof.OutputHashesSiblings[i] = hash + } + return input, nil +} - client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) - - chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) - - txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) - - if !skipConfirmation { - fmt.Printf("Preparing to execute application %v (%v) output index %v with account %v\n", - app.Name, app.IApplicationAddress, outputIndex, txOpts.From) - - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) - if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) +func loadOutputProof(path string) ([]byte, [][32]byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("read output proof file: %w", err) + } + var data struct { + RawData string `json:"raw_data"` + OutputHashesSiblings []string `json:"output_hashes_siblings"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&data); err != nil { + return nil, nil, fmt.Errorf("parse output proof file: %w", err) + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err != nil { + return nil, nil, fmt.Errorf("parse output proof file trailing data: %w", err) } + return nil, nil, fmt.Errorf("output proof file must contain exactly one JSON object") } - - txHash, err := ethutil.ExecuteOutput( - ctx, - client, - ethutil.NewStaticTransactOptsFactory(txOpts), - app.IApplicationAddress, - outputIndex, - output.RawData, - output.OutputHashesSiblings, - ) - cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - - if asJSONParam { - result := cli.ExecuteResult{ - TransactionHash: txHash.Hex(), + output, err := hexutil.Decode(data.RawData) + if err != nil { + return nil, nil, fmt.Errorf("invalid raw_data: %w", err) + } + if len(output) == 0 { + return nil, nil, fmt.Errorf("raw_data cannot be empty") + } + if len(data.OutputHashesSiblings) == 0 { + return nil, nil, fmt.Errorf("output_hashes_siblings cannot be empty") + } + siblings := make([][32]byte, len(data.OutputHashesSiblings)) + for i, encoded := range data.OutputHashesSiblings { + decoded, err := hexutil.Decode(encoded) + if err != nil { + return nil, nil, fmt.Errorf("invalid output_hashes_siblings[%d]: %w", i, err) + } + if len(decoded) != common.HashLength { + return nil, nil, fmt.Errorf("output_hashes_siblings[%d] must be %d bytes, got %d", i, common.HashLength, len(decoded)) } - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("Voucher executed tx-hash: %v\n", txHash) + copy(siblings[i][:], decoded) } + return output, siblings, nil } diff --git a/cmd/cartesi-rollups-cli/root/execute/execute_test.go b/cmd/cartesi-rollups-cli/root/execute/execute_test.go new file mode 100644 index 000000000..9fe89763b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/execute/execute_test.go @@ -0,0 +1,412 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package execute + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/outputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testChainID = 31337 + testGasLimit = 90000 + proofFileFlag = "--proof-file" + jsonFlag = "--json" + yesFlag = "--yes" +) + +func writeProofFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "output-proof.json") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func proofDocument(t *testing.T, output []byte, siblings []common.Hash) string { + t.Helper() + data, err := json.Marshal(struct { + RawData string `json:"raw_data"` + OutputHashesSiblings []common.Hash `json:"output_hashes_siblings"` + }{hexutil.Encode(output), siblings}) + require.NoError(t, err) + return string(data) +} + +func TestLoadOutputProofPreservesBytesAndOrder(t *testing.T) { + t.Parallel() + output := []byte{0, 1, 2, 3, 0xfe, 0xff} + siblings := []common.Hash{common.HexToHash("0x1234"), common.HexToHash("0xabcd")} + path := writeProofFile(t, " \n"+proofDocument(t, output, siblings)+"\n\t") + gotOutput, gotSiblings, err := loadOutputProof(path) + require.NoError(t, err) + require.Equal(t, output, gotOutput) + require.Equal(t, [][32]byte{siblings[0], siblings[1]}, gotSiblings) +} + +func TestLoadOutputProofRejectsInvalidFiles(t *testing.T) { + t.Parallel() + hash := common.HexToHash("0x01").Hex() + for name, contents := range map[string]string{ + "empty file": "", + "null object": "null", + "array object": "[]", + "missing fields": "{}", + "missing output": `{"output_hashes_siblings":["` + hash + `"]}`, + "empty output": `{"raw_data":"0x","output_hashes_siblings":["` + hash + `"]}`, + "null output": `{"raw_data":null,"output_hashes_siblings":["` + hash + `"]}`, + "missing output prefix": `{"raw_data":"aabb","output_hashes_siblings":["` + hash + `"]}`, + "invalid output hex": `{"raw_data":"0xgg","output_hashes_siblings":["` + hash + `"]}`, + "odd output hex": `{"raw_data":"0xabc","output_hashes_siblings":["` + hash + `"]}`, + "missing siblings": `{"raw_data":"0xaa"}`, + "null siblings": `{"raw_data":"0xaa","output_hashes_siblings":null}`, + "empty siblings": `{"raw_data":"0xaa","output_hashes_siblings":[]}`, + "null sibling": `{"raw_data":"0xaa","output_hashes_siblings":[null]}`, + "non-string sibling": `{"raw_data":"0xaa","output_hashes_siblings":[1]}`, + "empty sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0x"]}`, + "missing sibling prefix": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash[2:] + `"]}`, + "short sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0xab"]}`, + "long sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0x` + strings.Repeat("ab", 33) + `"]}`, + "invalid sibling hex": `{"raw_data":"0xaa","output_hashes_siblings":["0x` + strings.Repeat("gg", 32) + `"]}`, + "unknown index": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"],"output_index":7}`, + "trailing object": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}{}`, + "trailing null": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}null`, + "trailing garbage": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}garbage`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + output, siblings, err := loadOutputProof(writeProofFile(t, contents)) + require.Error(t, err) + require.Nil(t, output) + require.Nil(t, siblings) + }) + } + _, _, err := loadOutputProof(filepath.Join(t.TempDir(), "missing.json")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// executeRPC is a local Ethereum JSON-RPC server for the real generated binding. +// It does not implement a node JSON-RPC API or connect to a blockchain. +type executeRPC struct { + sent chan *types.Transaction + chainReads atomic.Int32 + estimates atomic.Int32 + receipts atomic.Int32 + failed bool + logs []*types.Log +} + +func (r *executeRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // Ethereum RPC method eth_chainId. + r.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(testChainID)), nil +} + +func (*executeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*executeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (*executeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (*executeRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return 0, nil +} + +func (r *executeRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, nil +} + +func (r *executeRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), nil +} + +func (r *executeRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + return &types.Receipt{TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: r.logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit}, nil +} + +func outputExecutedLog(t *testing.T, application common.Address, index uint64, output []byte) *types.Log { + t.Helper() + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["OutputExecuted"] + data, err := event.Inputs.NonIndexed().Pack(output) + require.NoError(t, err) + return &types.Log{ + Address: application, + Topics: []common.Hash{event.ID, common.BigToHash(new(big.Int).SetUint64(index))}, + Data: data, + } +} + +func setupExecuteRPC(t *testing.T, service *executeRPC) common.Address { + t.Helper() + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + // This value fails configuration parsing. A successful address/file command + // therefore proves that it did not read the database setting or open a DB. + t.Setenv(config.DATABASE_CONNECTION, "://invalid-database") + return crypto.PubkeyToAddress(key.PublicKey) +} + +func TestCommandExecutesProofFileWithoutDatabase(t *testing.T) { + outputABI, err := outputs.OutputsMetaData.GetAbi() + require.NoError(t, err) + output, err := outputABI.Pack("Voucher", common.HexToAddress("0x02"), big.NewInt(3), []byte{0xaa, 0xbb}) + require.NoError(t, err) + // The CLI must submit complete proven bytes, without decoding and rebuilding + // the output or dropping any committed suffix. + output = append(output, 0xcc, 0xdd) + siblings := []common.Hash{common.HexToHash("0x11"), common.HexToHash("0x22")} + file := writeProofFile(t, proofDocument(t, output, siblings)) + for _, tt := range []struct { + name string + manualGas bool + noWait bool + failed bool + }{ + {name: "estimate and wait"}, + {name: "manual gas and wait", manualGas: true}, + {name: "manual gas failed receipt", manualGas: true, failed: true}, + {name: "estimated no wait", noWait: true}, + {name: "manual gas no wait", manualGas: true, noWait: true}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &executeRPC{ + sent: make(chan *types.Transaction, 1), failed: tt.failed, + logs: []*types.Log{outputExecutedLog(t, common.HexToAddress(testApplication), 7, output)}, + } + signer := setupExecuteRPC(t, service) + if tt.manualGas { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "0x7", proofFileFlag, file, yesFlag, jsonFlag} + if tt.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1, "command error: %v", err) + tx := <-service.sent + if tt.failed { + require.ErrorContains(t, err, "receipt status 0") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + } else { + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if tt.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + } + } + if tt.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if tt.manualGas { + require.Zero(t, service.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + } else { + require.EqualValues(t, 1, service.estimates.Load()) + } + require.Equal(t, common.HexToAddress(testApplication), *tx.To()) + from, err := types.Sender(types.LatestSignerForChainID(big.NewInt(testChainID)), tx) + require.NoError(t, err) + require.Equal(t, signer, from) + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + method, err := appABI.MethodById(tx.Data()[:4]) + require.NoError(t, err) + require.Equal(t, "executeOutput", method.Name) + callArgs, err := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, err) + require.Equal(t, output, callArgs[0]) + proof := abi.ConvertType(callArgs[1], new(iapplication.OutputValidityProof)).(*iapplication.OutputValidityProof) + require.Equal(t, uint64(7), proof.OutputIndex) + require.Equal(t, [][32]byte{siblings[0], siblings[1]}, proof.OutputHashesSiblings) + }) + } +} + +func TestCommandRequiresMatchingOutputExecuted(t *testing.T) { + application := common.HexToAddress(testApplication) + output := []byte{0xaa, 0xbb, 0xcc} + file := writeProofFile(t, proofDocument(t, output, []common.Hash{common.HexToHash("0x11")})) + valid := outputExecutedLog(t, application, 7, output) + wrongEmitter := outputExecutedLog(t, common.HexToAddress("0x02"), 7, output) + wrongIndex := outputExecutedLog(t, application, 8, output) + wrongBytes := outputExecutedLog(t, application, 7, []byte{0xaa, 0xbb}) + malformed := &types.Log{Address: application, Topics: valid.Topics, Data: []byte{1}} + missingIndex := &types.Log{Address: application, Topics: valid.Topics[:1], Data: valid.Data} + unrelated := &types.Log{Address: application, Topics: []common.Hash{common.HexToHash("0x03")}} + for _, test := range []struct { + name string + logs []*types.Log + noWait bool + wantError bool + }{ + {name: "empty receipt", logs: []*types.Log{}, wantError: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantError: true}, + {name: "wrong index", logs: []*types.Log{wrongIndex}, wantError: true}, + {name: "wrong bytes", logs: []*types.Log{wrongBytes}, wantError: true}, + {name: "malformed data", logs: []*types.Log{malformed}, wantError: true}, + {name: "missing index topic", logs: []*types.Log{missingIndex}, wantError: true}, + {name: "unrelated event", logs: []*types.Log{unrelated}, wantError: true}, + {name: "nil log", logs: []*types.Log{nil}, wantError: true}, + {name: "later matching event", + logs: []*types.Log{wrongEmitter, wrongIndex, wrongBytes, malformed, missingIndex, unrelated, nil, valid}}, + {name: "no wait does not require event", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1), logs: test.logs} + setupExecuteRPC(t, service) + // A manual limit permits broadcast even when estimation would have + // rejected a wrong destination. Receipt evidence must still be checked. + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + cmd := newCommand() + cmd.SilenceUsage = true // Match the root CLI's error-output policy. + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", proofFileFlag, file, yesFlag, jsonFlag} + if test.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Zero(t, service.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantError { + require.ErrorContains(t, err, "no matching OutputExecuted event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if test.noWait { + require.Equal(t, "broadcast", result["status"]) + } else { + require.Equal(t, "mined", result["status"]) + } + }) + } +} + +func TestCommandRejectsInvalidProofBeforeEthereum(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + for _, application := range []string{testApplication, "echo-dapp"} { + t.Run(application, func(t *testing.T) { + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{application, "7", proofFileFlag, writeProofFile(t, `{"raw_data":"0xaa","unknown":1}`)}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "unknown field") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) + }) + } +} + +func TestCommandWithoutFileStillRequiresDatabase(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testApplication, "7", yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), config.DATABASE_CONNECTION) + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} + +func TestCommandRejectsEmptyProofPathBeforeLookup(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testApplication, "7", proofFileFlag, ""}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "--proof-file cannot be empty") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} + +func TestCommandProofFileNameStillRequiresDatabase(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + file := writeProofFile(t, proofDocument(t, []byte{0xaa}, []common.Hash{common.HexToHash("0x01")})) + cmd.SetArgs([]string{"echo-dapp", "7", proofFileFlag, file, yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "resolving application") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} diff --git a/cmd/cartesi-rollups-cli/root/root.go b/cmd/cartesi-rollups-cli/root/root.go index 58ec16a07..d266a6b1a 100644 --- a/cmd/cartesi-rollups-cli/root/root.go +++ b/cmd/cartesi-rollups-cli/root/root.go @@ -25,9 +25,10 @@ import ( ) var Cmd = &cobra.Command{ - Use: "cartesi-rollups-cli", - Short: "Command line interface for the Cartesi Rollups Node", - Version: version.BuildVersion, + Use: "cartesi-rollups-cli", + Short: "Command line interface for the Cartesi Rollups Node", + Version: version.BuildVersion, + SilenceUsage: true, } var ( @@ -59,7 +60,7 @@ func init() { // Blockchain gas limit Cmd.PersistentFlags().Uint64Var(&gasLimit, "gas-limit", 0, - "Blockchain gas limit") + "Transaction gas limit. Zero estimates gas; a nonzero value skips estimation.") cobra.CheckErr(viper.BindPFlag(config.BLOCKCHAIN_GAS_LIMIT, Cmd.PersistentFlags().Lookup("gas-limit"))) cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("gas-limit")) diff --git a/cmd/cartesi-rollups-cli/root/send/send.go b/cmd/cartesi-rollups-cli/root/send/send.go index 30308de40..197302314 100644 --- a/cmd/cartesi-rollups-cli/root/send/send.go +++ b/cmd/cartesi-rollups-cli/root/send/send.go @@ -7,17 +7,20 @@ import ( "encoding/json" "fmt" "io" + "math/big" "os" "strings" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/repository/factory" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" - "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" ) @@ -27,12 +30,21 @@ var Cmd = &cobra.Command{ Short: "Sends a rollups input transaction to the ethereum provider", Example: examples, Args: cobra.MinimumNArgs(1), - Run: run, + PreRunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Flags().Changed("inputbox") { + return fmt.Errorf("--inputbox is not supported by send; the application selects its InputBox") + } + return nil + }, + RunE: run, Long: ` +Send to the InputBox selected by the Application contract. +An application address does not require a database. An application name does. +The global InputBox setting does not select the input destination. + Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string - CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS Input Box contract address`, + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint`, } const examples = `# Send the string "hi": @@ -42,7 +54,7 @@ cartesi-rollups-cli send echo-dapp "hi" cartesi-rollups-cli send echo-dapp 0x6869 --hex # Read from stdin: -echo "hi" | cartesi-rollups-cli send echo-dapp +echo "hi" | cartesi-rollups-cli send echo-dapp --yes # Skip confirmation prompt: cartesi-rollups-cli send echo-dapp "hi" --yes` @@ -51,22 +63,19 @@ var ( isHex bool skipConfirmation bool asJSONParam bool - asyncMode bool ) func init() { Cmd.Flags().BoolVarP(&isHex, "hex", "x", false, "Force interpretation of payload as hex.") Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") - Cmd.Flags().BoolVar(&asyncMode, "async", false, - "Send the transaction without waiting for confirmation. Prints the tx hash and returns immediately.") + cli.AddTransactionFlags(Cmd) origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { command.Flags().Lookup("verbose").Hidden = false command.Flags().Lookup("database-connection").Hidden = false command.Flags().Lookup("blockchain-http-endpoint").Hidden = false - command.Flags().Lookup("inputbox").Hidden = false origHelpFunc(command, strings) }) } @@ -102,96 +111,120 @@ func decodeHex(s string) ([]byte, error) { return b, nil } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } - dsn, err := config.GetDatabaseConnection() - cobra.CheckErr(err) + appAddress, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) - - iboxAddr, err := config.GetContractsInputBoxAddress() - cobra.CheckErr(err) - - repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) - cobra.CheckErr(err) - defer repo.Close() - - app, err := repo.GetApplication(ctx, nameOrAddress) - cobra.CheckErr(err) - if app == nil { - fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - repo.Close() - os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + if err != nil { + return err } // Check if stdin is being used for payload and --yes flag is not set if len(args) == 1 && !skipConfirmation && !cli.IsTerminal(os.Stdin) { - cobra.CheckErr(fmt.Errorf("reading payload from stdin. Use --yes flag to skip confirmation when piping data")) + return fmt.Errorf("reading payload from stdin: use --yes to skip confirmation when piping data") } payload, err := resolvePayload(args) - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() + application, err := iapplication.NewIApplication(appAddress, client) + if err != nil { + return err + } + inputBoxAddress, err := application.GetInputBox(&bind.CallOpts{Context: ctx}) + if err != nil { + return fmt.Errorf("read application InputBox: %w", cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + } + if inputBoxAddress == (common.Address{}) { + return fmt.Errorf("application %s returned the zero InputBox address", appAddress) + } chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) - - txOptsFactory := ethutil.NewStaticTransactOptsFactory(txOpts) + if err != nil { + return err + } // Ask for confirmation unless --yes flag is set if !skipConfirmation { - fmt.Printf("Preparing to send input to application %v (%v) with account %v\n", - app.Name, app.IApplicationAddress, txOpts.From) + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to send input to application %v with account %v\n", + appAddress, txOpts.From) + if err != nil { + return err + } - confirmed, promptErr := cli.ConfirmPrompt("Do you want to proceed?") - if promptErr != nil || !confirmed { - fmt.Println("Operation cancelled") - return + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to proceed?") + if promptErr != nil { + return promptErr + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled") + return err } } - if asyncMode { - txHash, err := ethutil.AddInputAsync(ctx, client, txOptsFactory, iboxAddr, app.IApplicationAddress, payload) - cobra.CheckErr(cli.DecorateRevert(err, iinputbox.IInputBoxMetaData, iapplication.IApplicationMetaData)) - if asJSONParam { - result := cli.SendResult{ - ApplicationAddress: app.IApplicationAddress.Hex(), - TransactionHash: txHash.Hex(), + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return inputBox.AddInput(opts, appAddress, payload) + }) + if err != nil { + return cli.DecorateRevert(err, iinputbox.IInputBoxMetaData, iapplication.IApplicationMetaData) + } + + result := cli.SendResult{ + TransactionResult: cli.NewTransactionResult(tx, receipt), + ApplicationAddress: appAddress.Hex(), + } + var inputIndex *big.Int + if receipt != nil { + for _, log := range receipt.Logs { + if log.Address != inputBoxAddress { + continue + } + event, err := inputBox.ParseInputAdded(*log) + if err == nil && event.AppContract == appAddress { + inputIndex = event.Index + result.InputIndex = hexutil.EncodeBig(event.Index) + break } - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Println(txHash.Hex()) } - return + if result.InputIndex == "" { + return fmt.Errorf("transaction %s mined, but its receipt has no matching InputAdded event", tx.Hash()) + } } - - inputIndex, blockNumber, txHash, err := ethutil.AddInput(ctx, client, txOptsFactory, iboxAddr, app.IApplicationAddress, payload) - cobra.CheckErr(cli.DecorateRevert(err, iinputbox.IInputBoxMetaData, iapplication.IApplicationMetaData)) - if asJSONParam { - result := cli.SendResult{ - ApplicationAddress: app.IApplicationAddress.Hex(), - TransactionHash: txHash.Hex(), - InputIndex: fmt.Sprintf("0x%x", inputIndex), - BlockNumber: fmt.Sprintf("0x%x", blockNumber), - } - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("Input sent to app at %s. Index: %d BlockNumber: %d\n", - app.IApplicationAddress, inputIndex, blockNumber) + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + if receipt != nil { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Input sent to app at %s. Index: %s BlockNumber: %s Tx-hash: %s\n", + appAddress, inputIndex.String(), receipt.BlockNumber.String(), result.TransactionHash) + return err } + return cli.WriteTransactionResult(cmd, tx, receipt) } diff --git a/cmd/cartesi-rollups-cli/root/send/send_test.go b/cmd/cartesi-rollups-cli/root/send/send_test.go new file mode 100644 index 000000000..0e9d60b17 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/send/send_test.go @@ -0,0 +1,295 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package send_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/send" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +const ( + sendTestGas = 90000 + jsonFlag = "--json" + noWaitFlag = "--no-wait" + gasLimitFlag = "--gas-limit" +) + +func TestSendUsesApplicationInputBox(t *testing.T) { + t.Run("address without database", func(t *testing.T) { runSendInputBoxTest(t, false) }) + t.Run("name from database", func(t *testing.T) { runSendInputBoxTest(t, true) }) +} + +func runSendInputBoxTest(t *testing.T, byName bool) { + t.Helper() + inputBox := common.HexToAddress("0xb2") + app := &model.Application{ + Name: "send-inputbox", IApplicationAddress: common.HexToAddress("0xc3"), + IConsensusAddress: common.HexToAddress("0xd4"), IInputBoxAddress: common.HexToAddress("0xf6"), + EpochLength: 10, ConsensusType: model.Consensus_Authority, Status: model.ApplicationStatus_OK, + } + appReference := app.IApplicationAddress.Hex() + t.Setenv(config.DATABASE_CONNECTION, "invalid") + if byName { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + release, err := db.LockTestPostgres(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(release) + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := factory.NewRepositoryFromConnectionString(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + _, err = repo.CreateApplication(t.Context(), app, false) + require.NoError(t, err) + t.Setenv(config.DATABASE_CONNECTION, endpoint) + appReference = app.Name + } + globalBox := common.HexToAddress("0xa1") + t.Setenv(config.CONTRACTS_INPUT_BOX_ADDRESS, globalBox.Hex()) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + applicationABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + getter := applicationABI.Methods["getInputBox"] + + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["InputAdded"] + data, err := event.Inputs.NonIndexed().Pack([]byte("encoded input")) + require.NoError(t, err) + inputLog := func(box, application common.Address, index int64) *types.Log { + return &types.Log{Address: box, Data: data, Topics: []common.Hash{ + event.ID, common.BytesToHash(application.Bytes()), common.BigToHash(big.NewInt(index)), + }} + } + for _, test := range []struct { + name string + flags []string + missingEvent bool + noWait bool + json bool + zeroInputBox bool + manualGas bool + }{ + {name: "decimal text"}, + {name: "hex JSON", flags: []string{jsonFlag}, json: true}, + {name: "broadcast only", flags: []string{jsonFlag, noWaitFlag}, json: true, noWait: true}, + {name: "reject unrelated events", missingEvent: true}, + {name: "manual gas", flags: []string{gasLimitFlag, "123456"}, manualGas: true}, + {name: "manual gas broadcast only", flags: []string{jsonFlag, noWaitFlag, gasLimitFlag, "123456"}, + json: true, noWait: true, manualGas: true}, + {name: "reject zero InputBox", zeroInputBox: true}, + {name: "reject zero InputBox with manual gas", flags: []string{gasLimitFlag, "123456"}, zeroInputBox: true, manualGas: true}, + {name: "reject zero InputBox without waiting", flags: []string{noWaitFlag}, zeroInputBox: true, noWait: true}, + {name: "reject zero InputBox with manual gas without waiting", flags: []string{noWaitFlag, gasLimitFlag, "123456"}, + zeroInputBox: true, noWait: true, manualGas: true}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &sendRPC{ + sent: make(chan *types.Transaction, 2), application: app.IApplicationAddress, + inputBox: inputBox, getterID: getter.ID, + logs: []*types.Log{ + inputLog(globalBox, app.IApplicationAddress, 1), + inputLog(inputBox, common.HexToAddress("0xe5"), 2), + }, + } + if !test.missingEvent { + backend.logs = append(backend.logs, inputLog(inputBox, app.IApplicationAddress, 17)) + } + if test.zeroInputBox { + backend.inputBox = common.Address{} + // Reject the destination before reading signer configuration. + t.Setenv(config.AUTH_KIND, "invalid-signer") + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", backend)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + resetSendCommand(t) + var stdout, stderr bytes.Buffer + root.Cmd.SetOut(&stdout) + root.Cmd.SetErr(&stderr) + root.Cmd.SetArgs(append([]string{"send", appReference, "hello", "--yes"}, test.flags...)) + err := root.Cmd.ExecuteContext(t.Context()) + if test.zeroInputBox { + require.ErrorContains(t, err, "zero InputBox address") + require.EqualValues(t, 1, backend.getterCalls.Load()) + require.Zero(t, backend.chainReads.Load()) + require.Zero(t, backend.estimates.Load()) + require.Zero(t, backend.receipts.Load()) + require.Empty(t, backend.sent) + require.Empty(t, stdout.String()) + return + } + require.Len(t, backend.sent, 1, stderr.String()) + tx := <-backend.sent + require.Equal(t, inputBox, *tx.To(), "the contract selects the InputBox, not global or cached configuration") + require.EqualValues(t, 1, backend.getterCalls.Load()) + method := parsed.Methods["addInput"] + require.Equal(t, method.ID, tx.Data()[:4]) + args, decodeErr := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, decodeErr) + require.Equal(t, app.IApplicationAddress, args[0]) + require.Equal(t, []byte("hello"), args[1]) + if test.manualGas { + require.Zero(t, backend.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + } else { + require.EqualValues(t, 1, backend.estimates.Load()) + } + if test.noWait { + require.Zero(t, backend.receipts.Load()) + } else { + require.EqualValues(t, 1, backend.receipts.Load()) + } + if test.missingEvent { + require.ErrorContains(t, err, "no matching InputAdded event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err, stderr.String()) + if !test.json { + require.Contains(t, stdout.String(), "Index: 17 BlockNumber: 16") + require.Contains(t, stdout.String(), tx.Hash().Hex()) + return + } + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if test.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "input_index") + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + require.Equal(t, "0x11", result["input_index"]) + require.Equal(t, "0x10", result["block_number"]) + } + }) + } +} + +func TestSendRejectsInputBoxOverride(t *testing.T) { + resetSendCommand(t) + // An unusable DSN proves the flag fails before database or chain work. + t.Setenv(config.DATABASE_CONNECTION, "invalid") + var stdout, stderr bytes.Buffer + root.Cmd.SetOut(&stdout) + root.Cmd.SetErr(&stderr) + root.Cmd.SetArgs([]string{"send", "example", "hello", "--inputbox", common.HexToAddress("0xa1").Hex()}) + require.ErrorContains(t, root.Cmd.ExecuteContext(t.Context()), "--inputbox is not supported by send") + require.Empty(t, stdout.String()) +} + +func resetSendCommand(t *testing.T) { + t.Helper() + for _, flags := range []*pflag.FlagSet{root.Cmd.PersistentFlags(), send.Cmd.Flags()} { + flags.VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) + } + send.Cmd.SetContext(t.Context()) + t.Cleanup(func() { + root.Cmd.SetOut(nil) + root.Cmd.SetErr(nil) + }) +} + +type sendRPC struct { + sent chan *types.Transaction + logs []*types.Log + application common.Address + inputBox common.Address + getterID []byte + getterCalls atomic.Int32 + chainReads atomic.Int32 + receipts atomic.Int32 + estimates atomic.Int32 +} + +type sendCall struct { + To common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` +} + +func (b *sendRPC) Call(_ context.Context, call sendCall, block string) (hexutil.Bytes, error) { + if call.To != b.application || !bytes.Equal(call.Input, b.getterID) || block != "latest" { + return nil, fmt.Errorf("unexpected application getter: %+v at %s", call, block) + } + b.getterCalls.Add(1) + return common.LeftPadBytes(b.inputBox.Bytes(), common.HashLength), nil +} + +func (b *sendRPC) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + b.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*sendRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(16), Difficulty: big.NewInt(1), GasLimit: sendTestGas} +} + +func (*sendRPC) GasPrice(context.Context) *hexutil.Big { return (*hexutil.Big)(big.NewInt(1)) } + +func (*sendRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { return 0 } + +func (*sendRPC) GetCode(context.Context, common.Address, string) hexutil.Bytes { + return hexutil.Bytes{1} +} + +func (b *sendRPC) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + b.estimates.Add(1) + return sendTestGas +} + +func (b *sendRPC) SendRawTransaction(ctx context.Context, data hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + select { + case b.sent <- tx: + case <-ctx.Done(): + return common.Hash{}, ctx.Err() + } + return tx.Hash(), nil +} + +func (b *sendRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + b.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(16), + Logs: b.logs, GasUsed: sendTestGas, CumulativeGasUsed: sendTestGas} +} diff --git a/internal/cli/ethereum.go b/internal/cli/ethereum.go index 00357d81d..8776e46f3 100644 --- a/internal/cli/ethereum.go +++ b/internal/cli/ethereum.go @@ -11,11 +11,12 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) -func GetTransactOpts(ctx context.Context, chainId *big.Int) (*bind.TransactOpts, error) { - factory, err := auth.GetTransactOptsFactory(ctx, chainId) +func GetTransactOpts(ctx context.Context, chainID *big.Int) (*bind.TransactOpts, error) { + factory, err := auth.GetTransactOptsFactory(ctx, chainID) if err != nil { return nil, err } @@ -41,3 +42,22 @@ func GetTransactOptsFromFactory( } return txOpts, nil } + +// prepareTransactionFees applies the CLI fee policy immediately before signing. +// Dependent transactions, such as approval followed by deposit, each obtain a +// fresh price. A false legacy setting preserves the binding's automatic choice. +func prepareTransactionFees( + ctx context.Context, + client ethereum.GasPricer, + opts *bind.TransactOpts, +) (*bind.TransactOpts, error) { + legacy, err := config.GetBlockchainLegacyEnabled() + if err != nil && !errors.Is(err, config.ErrNotDefined) { + return nil, err + } + factory := ethutil.NewStaticTransactOptsFactory(opts) + if legacy { + factory = ethutil.WithLegacyFees(factory, client) + } + return factory.NewTransactOpts(ctx) +} diff --git a/internal/cli/legacy_fees_test.go b/internal/cli/legacy_fees_test.go new file mode 100644 index 000000000..6b29f0afc --- /dev/null +++ b/internal/cli/legacy_fees_test.go @@ -0,0 +1,170 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "fmt" + "math/big" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" +) + +func TestTransactLegacyFeePolicy(t *testing.T) { + for _, legacy := range []bool{false, true} { + for _, manualGas := range []bool{false, true} { + for _, noWait := range []bool{false, true} { + t.Run(fmt.Sprintf("legacy_%t/manual_gas_%t/no_wait_%t", legacy, manualGas, noWait), func(t *testing.T) { + viper.Reset() + config.SetDefaults() + t.Cleanup(func() { viper.Reset(); config.SetDefaults() }) + viper.Set(config.AUTH_KIND, "private_key") + viper.Set(config.AUTH_PRIVATE_KEY, testPrivateKey) + viper.Set(config.BLOCKCHAIN_LEGACY_ENABLED, legacy) + if manualGas { + viper.Set(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + backend := &cliLegacyFeeRPC{sent: make(chan *types.Transaction, 2)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chainID := big.NewInt(31337) + opts, err := GetTransactOpts(t.Context(), chainID) + require.NoError(t, err) + opts.Nonce, opts.Value = big.NewInt(9), big.NewInt(8) + inputBox, err := iinputbox.NewIInputBox(common.HexToAddress("0x01"), client) + require.NoError(t, err) + cmd, _, _ := transactionCommand() + require.NoError(t, cmd.Flags().Set("no-wait", fmt.Sprint(noWait))) + for sequence := int64(1); sequence <= 2; sequence++ { + tx, receipt, err := Transact(t.Context(), cmd, client, opts, + func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.True(t, prepared.NoSend) + require.Equal(t, t.Context(), prepared.Context) + return inputBox.AddInput(prepared, common.HexToAddress("0x02"), []byte{3}) + }) + require.NoError(t, err) + require.Len(t, backend.sent, 1, "broadcast exactly once") + sent := <-backend.sent + require.Equal(t, tx.Hash(), sent.Hash()) + if legacy { + require.EqualValues(t, types.LegacyTxType, sent.Type()) + require.Equal(t, big.NewInt(100+sequence), sent.GasPrice()) + } else { + require.EqualValues(t, types.DynamicFeeTxType, sent.Type()) + } + from, err := types.Sender(types.LatestSignerForChainID(chainID), sent) + require.NoError(t, err) + require.Equal(t, opts.From, from) + require.Equal(t, opts.Nonce.Uint64(), sent.Nonce()) + require.Equal(t, opts.Value, sent.Value()) + if manualGas { + require.EqualValues(t, 123456, sent.Gas()) + } else { + require.EqualValues(t, 75000, sent.Gas()) + } + if noWait { + require.Nil(t, receipt) + } else { + require.Equal(t, types.ReceiptStatusSuccessful, receipt.Status) + } + } + require.False(t, opts.NoSend) + require.Nil(t, opts.GasPrice, "do not cache the fee in caller options") + if legacy { + require.EqualValues(t, 2, backend.prices.Load()) + } else { + require.Zero(t, backend.prices.Load()) + } + if manualGas { + require.Zero(t, backend.estimates.Load()) + } else { + require.EqualValues(t, 2, backend.estimates.Load()) + } + if noWait { + require.Zero(t, backend.receipts.Load()) + } else { + require.EqualValues(t, 2, backend.receipts.Load()) + } + }) + } + } + } +} + +type cliLegacyFeeRPC struct { + prices atomic.Int32 + estimates atomic.Int32 + receipts atomic.Int32 + sent chan *types.Transaction +} + +func (r *cliLegacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} + +func (*cliLegacyFeeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(0), BaseFee: big.NewInt(10)}, nil +} + +func (*cliLegacyFeeRPC) MaxPriorityFeePerGas(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(2)), nil +} + +func (*cliLegacyFeeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (r *cliLegacyFeeRPC) EstimateGas(context.Context, map[string]any) (hexutil.Uint64, error) { + r.estimates.Add(1) + return 75000, nil +} + +func (r *cliLegacyFeeRPC) SendRawTransaction(_ context.Context, data hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + r.sent <- tx + return tx.Hash(), nil +} + +func (r *cliLegacyFeeRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(1), Logs: []*types.Log{}}, nil +} + +func TestTransactLegacyFeeFailureDoesNotSign(t *testing.T) { + viper.Reset() + config.SetDefaults() + t.Cleanup(func() { viper.Reset(); config.SetDefaults() }) + viper.Set(config.BLOCKCHAIN_LEGACY_ENABLED, true) + client := &transactionRPC{} + cmd, _, stderr := transactionCommand() + tx, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { + t.Fatal("must not sign without a gas price") + return nil, nil + }) + require.ErrorContains(t, err, "prepare transaction fees: suggest legacy gas price") + require.Nil(t, tx) + require.Nil(t, receipt) + require.Empty(t, stderr.String()) + require.Zero(t, client.sends) + require.Zero(t, client.receipts) +} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index 2c7e3f4b8..d366e09f4 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -7,6 +7,7 @@ import ( "bufio" "errors" "fmt" + "io" "os" "strings" ) @@ -21,10 +22,17 @@ var ErrNonInteractive = errors.New( // Returns ErrNonInteractive if stdin is not a terminal (e.g. piped in a script), // or a wrapped I/O error if reading from stdin fails. func ConfirmPrompt(message string) (bool, error) { + return ConfirmPromptTo(os.Stdout, message) +} + +// ConfirmPromptTo writes the human prompt separately from command results. +func ConfirmPromptTo(output io.Writer, message string) (bool, error) { if !IsTerminal(os.Stdin) { return false, ErrNonInteractive } - fmt.Printf("%s [y/N]: ", message) + if _, err := fmt.Fprintf(output, "%s [y/N]: ", message); err != nil { + return false, fmt.Errorf("write confirmation prompt: %w", err) + } scanner := bufio.NewScanner(os.Stdin) scanner.Scan() if err := scanner.Err(); err != nil { diff --git a/internal/cli/transaction.go b/internal/cli/transaction.go new file mode 100644 index 000000000..b784501a7 --- /dev/null +++ b/internal/cli/transaction.go @@ -0,0 +1,192 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" +) + +const ( + defaultReceiptTimeout = 2 * time.Minute + receiptPollInterval = 500 * time.Millisecond +) + +// AddTransactionFlags installs the receipt policy for one transaction command. +// Commands with dependent steps must also reject incompatible --no-wait use. +func AddTransactionFlags(cmd *cobra.Command) { + cmd.Flags().Bool("no-wait", false, "Return after broadcast without checking execution success") + cmd.Flags().Duration("wait-timeout", defaultReceiptTimeout, "Maximum time to wait for a mined receipt") + previousRun := cmd.PreRunE + cmd.PreRunE = func(command *cobra.Command, args []string) error { + _, _, err := transactionPolicy(command) + if err != nil { + return err + } + if previousRun != nil { + return previousRun(command, args) + } + return nil + } + previousHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + if flag := command.Flags().Lookup("gas-limit"); flag != nil { + flag.Hidden = false + } + previousHelp(command, args) + }) +} + +func transactionPolicy(cmd *cobra.Command) (bool, time.Duration, error) { + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return false, 0, err + } + timeout, err := cmd.Flags().GetDuration("wait-timeout") + if err != nil { + return false, 0, err + } + if timeout <= 0 { + return false, 0, fmt.Errorf("wait-timeout must be positive") + } + return noWait, timeout, nil +} + +// TransactionClient provides fee suggestions, broadcast, and receipt lookup. +type TransactionClient interface { + ethereum.GasPricer + SendTransaction(context.Context, *types.Transaction) error + TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) +} + +// Transact prepares and signs once, broadcasts once, and optionally checks mining. +// A nil receipt with no error means broadcast-only, not confirmed execution. +// After signing, the transaction is returned even if sending or waiting fails. +// GasLimit is preserved: zero estimates gas; a manual limit skips estimation. +func Transact( + ctx context.Context, + cmd *cobra.Command, + client TransactionClient, + opts *bind.TransactOpts, + build func(*bind.TransactOpts) (*types.Transaction, error), +) (*types.Transaction, *types.Receipt, error) { + noWait, timeout, err := transactionPolicy(cmd) + if err != nil { + return nil, nil, err + } + if err := ctx.Err(); err != nil { + return nil, nil, err + } + preparedOpts, err := prepareTransactionFees(ctx, client, opts) + if err != nil { + return nil, nil, fmt.Errorf("prepare transaction fees: %w", err) + } + preparedOpts.NoSend = true + tx, err := build(preparedOpts) + if err != nil { + return nil, nil, fmt.Errorf("prepare transaction: %w", err) + } + if tx == nil { + return nil, nil, fmt.Errorf("prepare transaction: no signed transaction") + } + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Transaction hash: %s\n", tx.Hash().Hex()); err != nil { + return tx, nil, fmt.Errorf("write transaction hash before broadcast: %w", err) + } + // Before broadcast, keep normal signal handling for prompts, stdin, and + // signers that capture their own context. No transaction has been sent. + // After this point, cancellation must report the known transaction hash. + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + if err := client.SendTransaction(ctx, tx); err != nil { + return tx, nil, fmt.Errorf("broadcast transaction %s: %w; check its receipt before retrying", tx.Hash().Hex(), err) + } + if noWait { + return tx, nil, nil + } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + receipt, err := waitForReceipt(waitCtx, client, tx.Hash()) + return tx, receipt, err +} + +func waitForReceipt(ctx context.Context, client TransactionClient, hash common.Hash) (*types.Receipt, error) { + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), err) + } + receipt, err := client.TransactionReceipt(ctx, hash) + switch { + case err == nil && receipt != nil: + if receipt.TxHash != hash { + return nil, fmt.Errorf("transaction %s outcome unknown: provider returned a different receipt hash", hash.Hex()) + } + if receipt.Status != types.ReceiptStatusSuccessful { + return receipt, fmt.Errorf("transaction %s failed in block %v (receipt status %d)", + hash.Hex(), receipt.BlockNumber, receipt.Status) + } + return receipt, nil + case err != nil && !errors.Is(err, ethereum.NotFound): + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), err) + } + // A missing receipt is normal while the transaction is pending. Do not + // infer failure or resubmit from mempool visibility on another RPC node. + timer := time.NewTimer(receiptPollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), ctx.Err()) + case <-timer.C: + } + } +} + +// TransactionResult distinguishes broadcast from successful mined execution. +// A mined result does not assert finality or application input processing. +type TransactionResult struct { + TransactionHash string `json:"transaction_hash"` + Status string `json:"status"` + BlockNumber string `json:"block_number,omitempty"` +} + +func NewTransactionResult(tx *types.Transaction, receipt *types.Receipt) TransactionResult { + result := TransactionResult{TransactionHash: tx.Hash().Hex(), Status: "broadcast"} + if receipt != nil { + result.Status = "mined" + if receipt.BlockNumber != nil { + result.BlockNumber = fmt.Sprintf("0x%x", receipt.BlockNumber) + } + } + return result +} + +func WriteTransactionResult(cmd *cobra.Command, tx *types.Transaction, receipt *types.Receipt) error { + result := NewTransactionResult(tx, receipt) + asJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + if asJSON { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + err = encoder.Encode(result) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Transaction %s: %s\n", result.Status, result.TransactionHash) + } + if err != nil { + return fmt.Errorf("write transaction result: %w", err) + } + return nil +} diff --git a/internal/cli/transaction_review_test.go b/internal/cli/transaction_review_test.go new file mode 100644 index 000000000..c4f77cafc --- /dev/null +++ b/internal/cli/transaction_review_test.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "syscall" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +const transactionSignalTestChild = "CARTESI_CLI_TRANSACTION_SIGNAL_TEST_CHILD" + +func TestTransactHashWriteFailureStopsBroadcast(t *testing.T) { + t.Parallel() + cmd, stdout, _ := transactionCommand() + cmd.SetErr(failingTransactionWriter{}) + client := &transactionRPC{} + tx := transactionFixture() + actual, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorIs(t, err, io.ErrClosedPipe) + require.ErrorContains(t, err, "write transaction hash before broadcast") + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Zero(t, client.sends) + require.Zero(t, client.receipts) + require.Empty(t, stdout.String()) +} + +// Signals must reach only the subprocess. Other tests can have active +// transaction contexts in the parent process at the same time. +func TestTransactSignalCancellation(t *testing.T) { + if os.Getenv(transactionSignalTestChild) == "1" { + runTransactionSignalChild(t) + return + } + + executable, err := os.Executable() + require.NoError(t, err) + for _, signal := range []os.Signal{os.Interrupt, syscall.SIGTERM} { + t.Run(signal.String(), func(t *testing.T) { + const childTimeout = 10 * time.Second + ctx, cancel := context.WithTimeout(t.Context(), childTimeout) + defer cancel() + child := exec.CommandContext(ctx, executable, "-test.run=^TestTransactSignalCancellation$") + child.Env = append(os.Environ(), transactionSignalTestChild+"=1") + var stderr bytes.Buffer + child.Stderr = &stderr + stdout, err := child.StdoutPipe() + require.NoError(t, err) + require.NoError(t, child.Start()) + t.Cleanup(func() { + cancel() + if child.ProcessState == nil { + _ = child.Wait() + } + }) + + // The child acknowledges the receipt request after Transact installs + // its signal handler. No sleep or process-start timing is required. + scanner := bufio.NewScanner(stdout) + require.True(t, scanner.Scan(), "child exited before requesting a receipt") + require.Equal(t, "waiting-for-receipt", scanner.Text()) + require.NoError(t, child.Process.Signal(signal)) + require.NoError(t, child.Wait(), stderr.String()) + require.Contains(t, stderr.String(), transactionFixture().Hash().Hex()) + require.Contains(t, stderr.String(), "outcome unknown") + require.Contains(t, stderr.String(), "context canceled") + }) + } +} + +func runTransactionSignalChild(t *testing.T) { + t.Helper() + cmd, _, _ := transactionCommand() + cmd.SetErr(os.Stderr) + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return nil }, + receipt: func(ctx context.Context, _ common.Hash) (*types.Receipt, error) { + _, err := fmt.Fprintln(os.Stdout, "waiting-for-receipt") + require.NoError(t, err) + <-ctx.Done() + return nil, ctx.Err() + }, + } + tx := transactionFixture() + actual, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorIs(t, err, context.Canceled) + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Equal(t, 1, client.sends) + require.Equal(t, 1, client.receipts) + _, writeErr := fmt.Fprintln(os.Stderr, err) + require.NoError(t, writeErr) +} diff --git a/internal/cli/transaction_test.go b/internal/cli/transaction_test.go new file mode 100644 index 000000000..e6ef18f3d --- /dev/null +++ b/internal/cli/transaction_test.go @@ -0,0 +1,262 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +type transactionRPC struct { + send func(context.Context, *types.Transaction) error + receipt func(context.Context, common.Hash) (*types.Receipt, error) + sends int + receipts int +} + +func (*transactionRPC) SuggestGasPrice(context.Context) (*big.Int, error) { + return nil, errors.New("unexpected gas price request") +} + +func (r *transactionRPC) SendTransaction(ctx context.Context, tx *types.Transaction) error { + r.sends++ + return r.send(ctx, tx) +} + +func (r *transactionRPC) TransactionReceipt(ctx context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts++ + return r.receipt(ctx, hash) +} + +func transactionCommand() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + cmd := &cobra.Command{Use: "test-transaction"} + AddTransactionFlags(cmd) + cmd.Flags().Bool("json", false, "JSON output") + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + return cmd, stdout, stderr +} + +func transactionFixture() *types.Transaction { + return types.NewTx(&types.LegacyTx{Gas: 21000, GasPrice: big.NewInt(1)}) +} + +func TestTransactPolicy(t *testing.T) { + t.Parallel() + for _, noWait := range []bool{false, true} { + name := "wait" + if noWait { + name = "no_wait" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + if noWait { + require.NoError(t, cmd.Flags().Set("no-wait", "true")) + } + tx := transactionFixture() + opts := &bind.TransactOpts{GasLimit: 123456} + var built bool + client := &transactionRPC{ + send: func(_ context.Context, sent *types.Transaction) error { + require.True(t, built) + require.Same(t, tx, sent) + require.Contains(t, stderr.String(), tx.Hash().Hex(), "hash must be available before broadcast") + return nil + }, + receipt: func(_ context.Context, hash common.Hash) (*types.Receipt, error) { + require.False(t, noWait, "no-wait must not query receipts") + require.Equal(t, tx.Hash(), hash) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(9)}, nil + }, + } + actual, receipt, err := Transact(t.Context(), cmd, client, opts, + func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.NotSame(t, opts, prepared) + require.True(t, prepared.NoSend) + require.Equal(t, opts.GasLimit, prepared.GasLimit) + require.NoError(t, prepared.Context.Err()) + built = true + return tx, nil + }) + require.NoError(t, err) + require.Same(t, tx, actual) + require.False(t, opts.NoSend, "do not mutate caller options") + require.Equal(t, 1, client.sends) + require.Empty(t, stdout.String(), "the command owns its final result") + if noWait { + require.Nil(t, receipt) + require.Zero(t, client.receipts) + } else { + require.Equal(t, types.ReceiptStatusSuccessful, receipt.Status) + require.Equal(t, 1, client.receipts) + } + }) + } +} + +func TestTransactErrorsKeepHash(t *testing.T) { + t.Parallel() + rpcError := errors.New("RPC response lost") + tx := transactionFixture() + for _, test := range []struct { + name string + sendError error + receipt *types.Receipt + readError error + want string + cause error + }{ + {name: "broadcast", sendError: rpcError, want: "check its receipt before retrying", cause: rpcError}, + {name: "read", readError: rpcError, want: "outcome unknown", cause: rpcError}, + {name: "manual_gas_mined_failure", receipt: &types.Receipt{ + TxHash: tx.Hash(), Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(9)}, want: "failed in block 9"}, + {name: "wrong_receipt", receipt: &types.Receipt{TxHash: common.HexToHash("0x99")}, want: "different receipt hash"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + cmd, _, stderr := transactionCommand() + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return test.sendError }, + receipt: func(context.Context, common.Hash) (*types.Receipt, error) { return test.receipt, test.readError }, + } + actual, _, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{GasLimit: tx.Gas()}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorContains(t, err, test.want) + require.ErrorContains(t, err, tx.Hash().Hex()) + if test.cause != nil { + require.ErrorIs(t, err, test.cause) + } + require.Same(t, tx, actual) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.Equal(t, 1, client.sends, "never resubmit on send or receipt errors") + }) + } +} + +func TestTransactPendingTimeoutAndCancellation(t *testing.T) { + t.Parallel() + for _, cancelWait := range []bool{false, true} { + name := "timeout" + if cancelWait { + name = "cancel" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, _, _ := transactionCommand() + require.NoError(t, cmd.Flags().Set("wait-timeout", "10ms")) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return nil }, + receipt: func(context.Context, common.Hash) (*types.Receipt, error) { + if cancelWait { + cancel() + } + return nil, ethereum.NotFound + }, + } + tx := transactionFixture() + actual, receipt, err := Transact(ctx, cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorContains(t, err, "outcome unknown") + require.ErrorContains(t, err, tx.Hash().Hex()) + if cancelWait { + require.ErrorIs(t, err, context.Canceled) + } else { + require.ErrorIs(t, err, context.DeadlineExceeded) + } + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Equal(t, 1, client.sends) + require.Equal(t, 1, client.receipts) + }) + } +} + +func TestTransactPreparationStopsBeforeBroadcast(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + estimateError := errors.New("execution reverted during estimation") + client := &transactionRPC{} + tx, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(opts *bind.TransactOpts) (*types.Transaction, error) { + require.Zero(t, opts.GasLimit, "preserve the default estimation policy") + return nil, estimateError + }) + require.ErrorIs(t, err, estimateError) + require.Nil(t, tx) + require.Nil(t, receipt) + require.Zero(t, client.sends) + require.Empty(t, stdout.String()) + require.Empty(t, stderr.String()) +} + +func TestTransactionFlagValidation(t *testing.T) { + t.Parallel() + for _, timeout := range []string{"0s", "-1s"} { + t.Run(timeout, func(t *testing.T) { + t.Parallel() + cmd, _, _ := transactionCommand() + cmd.RunE = func(*cobra.Command, []string) error { t.Fatal("must reject before command runs"); return nil } + cmd.SetArgs([]string{"--wait-timeout", timeout}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "wait-timeout must be positive") + }) + } + t.Run("preserve_command_validation", func(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{Use: "test", PreRunE: func(*cobra.Command, []string) error { return io.ErrUnexpectedEOF }} + AddTransactionFlags(cmd) + require.ErrorIs(t, cmd.PreRunE(cmd, nil), io.ErrUnexpectedEOF) + }) +} + +type failingTransactionWriter struct{} + +func (failingTransactionWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestWriteTransactionResult(t *testing.T) { + t.Parallel() + for _, asJSON := range []bool{false, true} { + name := "text" + if asJSON { + name = "json" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + if asJSON { + require.NoError(t, cmd.Flags().Set("json", "true")) + } + tx := transactionFixture() + require.NoError(t, WriteTransactionResult(cmd, tx, nil)) + require.Contains(t, stdout.String(), tx.Hash().Hex()) + require.Contains(t, stdout.String(), "broadcast") + require.Empty(t, stderr.String()) + if asJSON { + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.NotContains(t, result, "block_number") + } + cmd.SetOut(failingTransactionWriter{}) + require.ErrorIs(t, WriteTransactionResult(cmd, tx, nil), io.ErrClosedPipe) + }) + } + result := NewTransactionResult(transactionFixture(), &types.Receipt{BlockNumber: big.NewInt(16)}) + require.Equal(t, "mined", result.Status) + require.Equal(t, "0x10", result.BlockNumber) +} diff --git a/internal/cli/types.go b/internal/cli/types.go index 449901a51..a6b20b402 100644 --- a/internal/cli/types.go +++ b/internal/cli/types.go @@ -2,23 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 (see LICENSE) // Package cli defines the JSON output types used by the CLI commands. -// Types here are CLI-specific (SendResult, ExecuteResult, ValidateResult). +// Types here are CLI-specific (SendResult, TransactionResult, ValidateResult). // Response envelope types (ListResponse, SingleResponse, Pagination) live // in internal/jsonrpc/api. package cli // SendResult is the JSON output of the "send" CLI command. -// In async mode, only ApplicationAddress and TransactionHash are populated. +// With --no-wait, the input index and block number are not yet known. type SendResult struct { + TransactionResult ApplicationAddress string `json:"application_address"` - TransactionHash string `json:"transaction_hash"` InputIndex string `json:"input_index,omitempty"` - BlockNumber string `json:"block_number,omitempty"` -} - -// ExecuteResult is the JSON output of the "execute" CLI command. -type ExecuteResult struct { - TransactionHash string `json:"transaction_hash"` } // ValidateResult is the JSON output of the "validate" CLI command. diff --git a/scripts/load-test.sh b/scripts/load-test.sh index 25bed5165..4951a94df 100755 --- a/scripts/load-test.sh +++ b/scripts/load-test.sh @@ -166,7 +166,7 @@ send_input() { fi CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX="$acct_idx" \ - cartesi-rollups-cli send "$app" "$payload" --yes --async > /dev/null 2>&1 & + cartesi-rollups-cli send "$app" "$payload" --yes --no-wait > /dev/null 2>&1 & SENDER_PIDS[$acct_idx]=$! } diff --git a/test/integration/cli_helpers_test.go b/test/integration/cli_helpers_test.go index 1f350f91e..537153478 100644 --- a/test/integration/cli_helpers_test.go +++ b/test/integration/cli_helpers_test.go @@ -268,7 +268,7 @@ func executeOutput(ctx context.Context, appName string, index uint64) (string, e if err != nil { return "", fmt.Errorf("execute: %w", err) } - var result cli.ExecuteResult + var result cli.TransactionResult if err := json.Unmarshal([]byte(out), &result); err != nil { return "", fmt.Errorf("parse execute output: %w", err) } From 7a81f756f628636f201a09a2d8d22cc5580232f4 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:16 -0300 Subject: [PATCH 15/41] feat(cli): adapt deposits to v3 transactions Use the renamed ERC-20 portal and the shared transaction policy. Wait for a successful approval receipt and exact token Approval event before preparing the deposit. Reject --approve with --no-wait before network work, so the command does not submit only part of the workflow. Verify the deposit input from the Application-selected InputBox at the receipt block. Match the portal, application, index, and complete packed token/depositor/amount/execution-data payload. Bound amounts to uint256. Keep manual gas, strict no-wait, and distinct output streams. Exercise the actual bindings with matching, missing, and malformed event evidence. --- .../root/deposit/deposit.go | 207 +++++++++----- .../root/deposit/deposit_test.go | 267 ++++++++++++++++++ .../root/deposit/receipt.go | 71 +++++ .../root/deposit/receipt_test.go | 218 ++++++++++++++ 4 files changed, 693 insertions(+), 70 deletions(-) create mode 100644 cmd/cartesi-rollups-cli/root/deposit/deposit_test.go create mode 100644 cmd/cartesi-rollups-cli/root/deposit/receipt.go create mode 100644 cmd/cartesi-rollups-cli/root/deposit/receipt_test.go diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit.go b/cmd/cartesi-rollups-cli/root/deposit/deposit.go index 5b08cf460..c446ff10a 100644 --- a/cmd/cartesi-rollups-cli/root/deposit/deposit.go +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit.go @@ -4,10 +4,10 @@ package deposit import ( + "bytes" "encoding/json" "fmt" "math/big" - "os" "strings" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" @@ -34,17 +34,22 @@ var Cmd = &cobra.Command{ var erc20Cmd = &cobra.Command{ Use: "erc20 [app-name-or-address]", - Short: "Deposit ERC-20 tokens through the ERC20Portal", + Short: "Deposit ERC-20 tokens through the Erc20Portal", Example: erc20Examples, Args: cobra.ExactArgs(1), - Run: runERC20, + RunE: runERC20, + PreRunE: validateERC20Flags, Long: ` -Calls ERC20Portal.depositERC20Tokens(token, app, amount, execData). +Calls Erc20Portal.depositErc20Tokens(token, app, amount, execData). The command does not approve token spending unless --approve is supplied. Without --approve, the signer must already have enough allowance for the portal. +--approve cannot be combined with --no-wait. The approval must succeed +before the deposit transaction is prepared. Submit approval separately +if the deposit must return without waiting for mining. + Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint @@ -52,10 +57,10 @@ Supported Environment Variables: CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, } -const erc20Examples = `# Deposit 100 units of a token through the ERC20Portal: +const erc20Examples = `# Deposit 100 units of a token through the Erc20Portal: cartesi-rollups-cli deposit erc20 echo-dapp \ - --portal 0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1 \ - --token 0x88A2120B7068E78692C8fd12E751d610B6377E4d \ + --portal 0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419 \ + --token 0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81 \ --amount 100 # Approve the portal first, then deposit: @@ -74,13 +79,14 @@ var ( func init() { Cmd.AddCommand(erc20Cmd) - erc20Cmd.Flags().StringVar(&portalParam, "portal", "", "ERC20Portal contract address") + erc20Cmd.Flags().StringVar(&portalParam, "portal", "", "Erc20Portal contract address") erc20Cmd.Flags().StringVar(&tokenParam, "token", "", "ERC-20 token contract address") erc20Cmd.Flags().StringVar(&amountParam, "amount", "", "Token amount to deposit (decimal or 0x-prefixed)") erc20Cmd.Flags().StringVar(&execDataParam, "exec-data", "0x", "Extra execution-layer data") erc20Cmd.Flags().BoolVar(&approveParam, "approve", false, "Approve the portal for --amount before depositing") erc20Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") erc20Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(erc20Cmd) cobra.CheckErr(erc20Cmd.MarkFlagRequired("portal")) cobra.CheckErr(erc20Cmd.MarkFlagRequired("token")) cobra.CheckErr(erc20Cmd.MarkFlagRequired("amount")) @@ -94,31 +100,65 @@ func init() { }) } -func runERC20(cmd *cobra.Command, args []string) { +func validateERC20Flags(cmd *cobra.Command, _ []string) error { + approve, err := cmd.Flags().GetBool("approve") + if err != nil { + return err + } + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return err + } + if approve && noWait { + return fmt.Errorf("--approve cannot be combined with --no-wait: the deposit requires a successful approval") + } + return nil +} + +func runERC20(cmd *cobra.Command, args []string) error { ctx := cmd.Context() appAddr, err := util.ResolveApplicationAddress(ctx, args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } portalAddr, err := parseAddress("portal", portalParam) - cobra.CheckErr(err) + if err != nil { + return err + } tokenAddr, err := parseAddress("token", tokenParam) - cobra.CheckErr(err) + if err != nil { + return err + } amount, err := parseAmount(amountParam) - cobra.CheckErr(err) + if err != nil { + return err + } execData, err := hexutil.Decode(execDataParam) - cobra.CheckErr(err) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOptsFactory, err := auth.GetTransactOptsFactory(ctx, chainID) - cobra.CheckErr(err) + if err != nil { + return err + } if !skipConfirmation { - fmt.Printf("Preparing ERC-20 deposit\n"+ + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing ERC-20 deposit\n"+ " signer: %s\n"+ " application: %s\n"+ " portal: %s\n"+ @@ -126,79 +166,112 @@ func runERC20(cmd *cobra.Command, args []string) { " amount: %s\n"+ " approve: %t\n", txOptsFactory.From(), appAddr, portalAddr, tokenAddr, amount.String(), approveParam) - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) + if err != nil { + return err + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err } } var approveHash *common.Hash if approveParam { token, err := ierc20metadata.NewIERC20Metadata(tokenAddr, client) - cobra.CheckErr(err) + if err != nil { + return err + } approveOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) - cobra.CheckErr(err) - tx, err := token.Approve(approveOpts, portalAddr, amount) - cobra.CheckErr(cli.DecorateRevert(err, - ierc20metadata.IERC20MetadataMetaData, - ierc20errors.IERC20ErrorsMetaData, - )) - receipt, err := bind.WaitMined(ctx, client, tx) - cobra.CheckErr(err) - cobra.CheckErr(checkReceiptStatus(receipt, "approve")) - hash := receipt.TxHash + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, approveOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return token.Approve(opts, portalAddr, amount) + }) + if err != nil { + return cli.DecorateRevert(err, ierc20metadata.IERC20MetadataMetaData, ierc20errors.IERC20ErrorsMetaData) + } + approved := false + for _, log := range receipt.Logs { + if log == nil || log.Address != tokenAddr || len(log.Data) != common.HashLength { + continue + } + event, err := token.ParseApproval(*log) + if err == nil && event.Owner == approveOpts.From && event.Spender == portalAddr && event.Value.Cmp(amount) == 0 { + approved = true + break + } + } + if !approved { + return fmt.Errorf("transaction %s mined, but its receipt has no matching Approval event", tx.Hash()) + } + hash := tx.Hash() approveHash = &hash } - portal, err := ierc20portal.NewIERC20Portal(portalAddr, client) - cobra.CheckErr(err) + portal, err := ierc20portal.NewIErc20Portal(portalAddr, client) + if err != nil { + return err + } depositOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) - cobra.CheckErr(err) - tx, err := portal.DepositERC20Tokens(depositOpts, tokenAddr, appAddr, amount, execData) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, depositOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return portal.DepositErc20Tokens(opts, tokenAddr, appAddr, amount, execData) + }) // The revert can come from three layers: the portal itself - // (ERC20TransferFailed), the token's transferFrom (ERC-6093 errors such - // as ERC20InsufficientBalance/Allowance), or the forwarded + // (Erc20TransferFailed or a balance-delta error), the token's transferFrom + // (ERC-6093 errors such as ERC20InsufficientBalance/Allowance), or the forwarded // InputBox.addInput (InputTooLarge and the application foreclosure-probe // family). - cobra.CheckErr(cli.DecorateRevert(err, - ierc20portal.IERC20PortalMetaData, - ierc20errors.IERC20ErrorsMetaData, - iinputbox.IInputBoxMetaData, - iapplication.IApplicationMetaData, - )) - receipt, err := bind.WaitMined(ctx, client, tx) - cobra.CheckErr(err) - cobra.CheckErr(checkReceiptStatus(receipt, "depositERC20Tokens")) + if err != nil { + return cli.DecorateRevert(err, + ierc20portal.IErc20PortalMetaData, + ierc20errors.IERC20ErrorsMetaData, + iinputbox.IInputBoxMetaData, + iapplication.IApplicationMetaData, + ) + } + if receipt != nil { + // Erc20Portal uses abi.encodePacked(token, sender, amount, execData). + payload := bytes.Join([][]byte{tokenAddr.Bytes(), depositOpts.From.Bytes(), common.LeftPadBytes(amount.Bytes(), common.HashLength), + execData}, nil) + if err := verifyDepositReceipt(ctx, client, receipt, appAddr, portalAddr, payload); err != nil { + return fmt.Errorf("transaction %s mined, but its deposit could not be confirmed: %w", tx.Hash(), err) + } + } if asJSONParam { result := struct { + cli.TransactionResult ApplicationAddress common.Address `json:"application_address"` PortalAddress common.Address `json:"portal_address"` TokenAddress common.Address `json:"token_address"` Amount string `json:"amount"` ApproveTxHash *common.Hash `json:"approve_transaction_hash,omitempty"` - TransactionHash common.Hash `json:"transaction_hash"` - BlockNumber string `json:"block_number"` }{ + TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddress: appAddr, PortalAddress: portalAddr, TokenAddress: tokenAddr, Amount: amount.String(), ApproveTxHash: approveHash, - TransactionHash: receipt.TxHash, - BlockNumber: fmt.Sprintf("0x%x", receipt.BlockNumber.Uint64()), } - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - if approveHash != nil { - fmt.Printf("approve tx-hash: %s\n", approveHash.Hex()) + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + if approveHash != nil { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "approve tx-hash: %s\n", approveHash.Hex()); err != nil { + return err } - fmt.Printf("deposit tx-hash: %s blockNumber: %d\n", receipt.TxHash, receipt.BlockNumber.Uint64()) } + return cli.WriteTransactionResult(cmd, tx, receipt) } func parseAddress(name string, value string) (common.Address, error) { @@ -226,15 +299,9 @@ func parseAmount(value string) (*big.Int, error) { if amount.Sign() <= 0 { return nil, fmt.Errorf("amount must be positive") } - return amount, nil -} - -func checkReceiptStatus(receipt *types.Receipt, action string) error { - if receipt == nil { - return fmt.Errorf("%s transaction has no receipt", action) - } - if receipt.Status != types.ReceiptStatusSuccessful { - return fmt.Errorf("%s transaction failed: %s", action, receipt.TxHash) + const uint256Bits = 256 + if amount.BitLen() > uint256Bits { + return nil, fmt.Errorf("amount must fit in uint256") } - return nil + return amount, nil } diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go b/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go new file mode 100644 index 000000000..d917146f2 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go @@ -0,0 +1,267 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20portal" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testPortal = "0x0000000000000000000000000000000000000002" + testToken = "0x0000000000000000000000000000000000000003" + testInputBox = "0x0000000000000000000000000000000000000004" + testGasLimit = 90000 + approveFlag = "--approve" +) + +// depositRPC exercises the generated token and portal bindings. The service +// counts receipt reads so a no-wait command cannot pass by waiting on an +// immediately successful receipt. +type depositRPC struct { + sent chan *types.Transaction + nonce atomic.Uint64 + receipts atomic.Int32 + estimates atomic.Int32 + chainReads atomic.Int32 + failed bool + transactions sync.Map + approvalLogs []*types.Log + depositLogs []*types.Log + inputBoxReads atomic.Int32 + inputBoxBlock atomic.Value + inputBoxResponse hexutil.Bytes +} + +func (r *depositRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // Ethereum RPC method eth_chainId. + r.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(31337)), nil +} + +func (*depositRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*depositRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (*depositRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{0x01}, nil +} + +func (r *depositRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return hexutil.Uint64(r.nonce.Load()), nil +} + +func (r *depositRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, nil +} + +func (r *depositRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + r.transactions.Store(tx.Hash(), &tx) + r.nonce.Add(1) + return tx.Hash(), nil +} + +func (r *depositRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + logs := r.depositLogs + if value, ok := r.transactions.Load(hash); ok && *value.(*types.Transaction).To() == common.HexToAddress(testToken) { + logs = r.approvalLogs + } + if logs == nil { + logs = []*types.Log{} + } + return &types.Receipt{TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit}, nil +} + +func (r *depositRPC) Call(_ context.Context, args map[string]json.RawMessage, block string) (hexutil.Bytes, error) { + r.inputBoxReads.Add(1) + r.inputBoxBlock.Store(block) + var target common.Address + var data hexutil.Bytes + if err := json.Unmarshal(args["to"], &target); err != nil { + return nil, err + } + if err := json.Unmarshal(args["input"], &data); err != nil { + return nil, err + } + parsed, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return nil, err + } + if target != common.HexToAddress(testApplication) || !bytes.Equal(data, parsed.Methods["getInputBox"].ID) { + return nil, fmt.Errorf("unexpected contract call") + } + return r.inputBoxResponse, nil +} + +func setupDepositRPC(t *testing.T, service *depositRPC) { + t.Helper() + erc20Cmd.SetContext(t.Context()) + previousSilenceUsage := Cmd.SilenceUsage + Cmd.SilenceUsage = true + t.Cleanup(func() { Cmd.SilenceUsage = previousSilenceUsage }) + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + t.Setenv(config.DATABASE_CONNECTION, "invalid-database") + signer := crypto.PubkeyToAddress(key.PublicKey) + service.approvalLogs = []*types.Log{approvalLog(t, signer, common.HexToAddress(testPortal), big.NewInt(100))} + service.depositLogs = []*types.Log{depositInputLog(t, signer)} + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + service.inputBoxResponse, err = appABI.Methods["getInputBox"].Outputs.Pack(common.HexToAddress(testInputBox)) + require.NoError(t, err) + erc20Cmd.Flags().VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) +} + +func approvalLog(t *testing.T, owner, spender common.Address, value *big.Int) *types.Log { + t.Helper() + parsed, err := ierc20metadata.IERC20MetadataMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["Approval"] + data, err := event.Inputs.NonIndexed().Pack(value) + require.NoError(t, err) + return &types.Log{Address: common.HexToAddress(testToken), Data: data, + Topics: []common.Hash{event.ID, common.BytesToHash(owner.Bytes()), common.BytesToHash(spender.Bytes())}} +} + +func depositInputLog(t *testing.T, signer common.Address) *types.Log { + t.Helper() + inputABI, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + payload := bytes.Join([][]byte{common.HexToAddress(testToken).Bytes(), signer.Bytes(), + common.LeftPadBytes(big.NewInt(100).Bytes(), common.HashLength)}, nil) + input, err := inputABI.Pack("EvmAdvance", big.NewInt(31337), common.HexToAddress(testApplication), + common.HexToAddress(testPortal), big.NewInt(10), big.NewInt(20), big.NewInt(0), big.NewInt(7), payload) + require.NoError(t, err) + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["InputAdded"] + data, err := event.Inputs.NonIndexed().Pack(input) + require.NoError(t, err) + return &types.Log{Address: common.HexToAddress(testInputBox), Data: data, + Topics: []common.Hash{event.ID, common.BytesToHash(common.HexToAddress(testApplication).Bytes()), common.BigToHash(big.NewInt(7))}} +} + +func TestDepositTransactionPolicy(t *testing.T) { + for _, tt := range []struct { + name string + extra []string + failed bool + wantErr string + wantTransactions int + wantReceipts int32 + }{ + {name: "default waits", wantTransactions: 1, wantReceipts: 1}, + {name: "no wait", extra: []string{"--no-wait"}, wantTransactions: 1}, + {name: "approval then deposit", extra: []string{approveFlag}, wantTransactions: 2, wantReceipts: 2}, + {name: "approval failure stops deposit", extra: []string{approveFlag}, failed: true, + wantErr: "failed in block", wantTransactions: 1, wantReceipts: 1}, + {name: "deposit failure", failed: true, wantErr: "failed in block", wantTransactions: 1, wantReceipts: 1}, + {name: "reject approval with no wait", extra: []string{approveFlag, "--no-wait"}, + wantErr: "--approve cannot be combined with --no-wait"}, + {name: "reject invalid timeout", extra: []string{"--wait-timeout", "0s"}, wantErr: "wait-timeout must be positive"}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &depositRPC{sent: make(chan *types.Transaction, 2), failed: tt.failed} + setupDepositRPC(t, service) + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + erc20Cmd.SetContext(t.Context()) + args := append([]string{"erc20", testApplication, "--portal", testPortal, "--token", testToken, + "--amount", "100", "--yes", "--json"}, tt.extra...) + Cmd.SetArgs(args) + err := Cmd.ExecuteContext(t.Context()) + if tt.wantErr == "" { + require.NoError(t, err, stderr.String()) + } + require.Len(t, service.sent, tt.wantTransactions) + require.Equal(t, tt.wantReceipts, service.receipts.Load()) + require.EqualValues(t, tt.wantTransactions, service.estimates.Load()) + if tt.wantTransactions == 0 { + require.Zero(t, service.chainReads.Load()) + } + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + return + } + require.NoError(t, err) + if tt.wantTransactions == 2 { + approval := <-service.sent + require.Equal(t, common.HexToAddress(testToken), *approval.To()) + tokenABI, err := ierc20metadata.IERC20MetadataMetaData.GetAbi() + require.NoError(t, err) + require.Equal(t, tokenABI.Methods["approve"].ID, approval.Data()[:4]) + } + deposit := <-service.sent + require.Equal(t, common.HexToAddress(testPortal), *deposit.To()) + portalABI, err := ierc20portal.IErc20PortalMetaData.GetAbi() + require.NoError(t, err) + require.Equal(t, portalABI.Methods["depositErc20Tokens"].ID, deposit.Data()[:4]) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, deposit.Hash().Hex(), result["transaction_hash"]) + if tt.wantReceipts == 0 { + require.Zero(t, service.inputBoxReads.Load()) + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.EqualValues(t, 1, service.inputBoxReads.Load()) + require.Equal(t, "0xa", service.inputBoxBlock.Load()) + require.Equal(t, "mined", result["status"]) + require.Equal(t, "0xa", result["block_number"]) + } + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/receipt.go b/cmd/cartesi-rollups-cli/root/deposit/receipt.go new file mode 100644 index 000000000..2858f357b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/receipt.go @@ -0,0 +1,71 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +// verifyDepositReceipt checks the input generated by the portal, not token +// Transfer events, which can also occur during other calls in the transaction. +func verifyDepositReceipt( + ctx context.Context, + client *ethclient.Client, + receipt *types.Receipt, + application, portal common.Address, + payload []byte, +) error { + app, err := iapplication.NewIApplication(application, client) + if err != nil { + return err + } + inputBoxAddress, err := app.GetInputBox(&bind.CallOpts{Context: ctx, BlockNumber: receipt.BlockNumber}) + if err != nil { + return fmt.Errorf("read application InputBox at receipt block: %w", err) + } + if inputBoxAddress == (common.Address{}) { + return fmt.Errorf("application returned a zero InputBox address") + } + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) + if err != nil { + return err + } + inputABI, err := inputs.InputsMetaData.GetAbi() + if err != nil { + return err + } + advance := inputABI.Methods["EvmAdvance"] + for _, log := range receipt.Logs { + if log == nil || log.Address != inputBoxAddress { + continue + } + event, err := inputBox.ParseInputAdded(*log) + if err != nil || event.AppContract != application || !bytes.HasPrefix(event.Input, advance.ID) { + continue + } + args := make(map[string]any) + if err := advance.Inputs.UnpackIntoMap(args, event.Input[len(advance.ID):]); err != nil { + continue + } + inputPayload, ok := args["payload"].([]byte) + index, indexOK := args["index"].(*big.Int) + if ok && indexOK && index.Cmp(event.Index) == 0 && args["appContract"] == application && args["msgSender"] == portal && + bytes.Equal(inputPayload, payload) { + return nil + } + } + return fmt.Errorf("receipt has no matching deposit InputAdded event") +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go b/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go new file mode 100644 index 000000000..e7dd31cfe --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go @@ -0,0 +1,218 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20portal" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +func TestDepositReceiptEvidence(t *testing.T) { + for _, tt := range []struct { + name string + change func(*testing.T, *depositRPC) + valid bool + }{ + {name: "matching input", valid: true}, + {name: "no events", change: func(_ *testing.T, r *depositRPC) { r.depositLogs = nil }}, + {name: "wrong input box", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Address = common.Address{} }}, + {name: "wrong outer application", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[1] = common.Hash{} }}, + {name: "wrong outer index", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[2] = common.Hash{} }}, + {name: "wrong event", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[0] = common.Hash{} }}, + {name: "malformed event", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Data = []byte{1} }}, + {name: "zero input box", change: func(_ *testing.T, r *depositRPC) { r.inputBoxResponse = make([]byte, common.HashLength) }}, + {name: "bad input box response", change: func(_ *testing.T, r *depositRPC) { r.inputBoxResponse = []byte{1} }}, + {name: "wrong input selector", change: func(t *testing.T, r *depositRPC) { + changeDepositInput(t, r.depositLogs[0], func(input []byte) []byte { input[0] ^= 1; return input }) + }}, + {name: "malformed input", change: func(t *testing.T, r *depositRPC) { + changeDepositInput(t, r.depositLogs[0], func(input []byte) []byte { return input[:5] }) + }}, + {name: "wrong inner application", change: changeAdvanceField(1, common.Address{})}, + {name: "wrong portal sender", change: changeAdvanceField(2, common.Address{})}, + {name: "wrong inner index", change: changeAdvanceField(6, big.NewInt(8))}, + {name: "wrong token", change: changePayloadByte(0)}, + {name: "wrong depositor", change: changePayloadByte(20)}, + {name: "wrong amount", change: changePayloadByte(71)}, + {name: "wrong execution data", change: func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[7] = append(args[7].([]byte), 1) }) + }}, + {name: "matching event after unrelated event", valid: true, change: func(_ *testing.T, r *depositRPC) { + wrong := *r.depositLogs[0] + wrong.Data = []byte{1} + r.depositLogs = append([]*types.Log{&wrong}, r.depositLogs...) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + if tt.change != nil { + tt.change(t, rpc) + } + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t) + require.Len(t, rpc.sent, 1) + tx := <-rpc.sent + require.Zero(t, rpc.estimates.Load()) + require.EqualValues(t, 1, rpc.receipts.Load()) + require.Equal(t, "0xa", rpc.inputBoxBlock.Load()) + if tt.valid { + require.NoError(t, err) + require.Contains(t, output, `"status": "mined"`) + } else { + require.ErrorContains(t, err, "deposit could not be confirmed") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, output, `"status": "mined"`) + } + }) + } +} + +func TestApprovalReceiptEvidence(t *testing.T) { + for _, tt := range []struct { + name string + change func(*depositRPC) + valid bool + }{ + {name: "matching approval", valid: true}, + {name: "no events", change: func(r *depositRPC) { r.approvalLogs = nil }}, + {name: "wrong token", change: func(r *depositRPC) { r.approvalLogs[0].Address = common.Address{} }}, + {name: "wrong event", change: func(r *depositRPC) { r.approvalLogs[0].Topics[0] = common.Hash{} }}, + {name: "wrong owner", change: func(r *depositRPC) { r.approvalLogs[0].Topics[1] = common.Hash{} }}, + {name: "wrong spender", change: func(r *depositRPC) { r.approvalLogs[0].Topics[2] = common.Hash{} }}, + {name: "wrong amount", change: func(r *depositRPC) { r.approvalLogs[0].Data[31]++ }}, + {name: "missing amount", change: func(r *depositRPC) { r.approvalLogs[0].Data = nil }}, + {name: "malformed data", change: func(r *depositRPC) { r.approvalLogs[0].Data = []byte{1} }}, + {name: "matching event after unrelated event", valid: true, change: func(r *depositRPC) { + wrong := *r.approvalLogs[0] + wrong.Data = nil + r.approvalLogs = append([]*types.Log{&wrong}, r.approvalLogs...) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + if tt.change != nil { + tt.change(rpc) + } + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t, approveFlag) + require.Zero(t, rpc.estimates.Load()) + if tt.valid { + require.NoError(t, err) + require.Len(t, rpc.sent, 2) + require.Contains(t, output, `"approve_transaction_hash"`) + } else { + require.Len(t, rpc.sent, 1, "a deposit must not follow an unconfirmed approval") + require.ErrorContains(t, err, (<-rpc.sent).Hash().Hex()) + require.ErrorContains(t, err, "no matching Approval event") + require.NotContains(t, output, `"status": "mined"`) + require.Zero(t, rpc.inputBoxReads.Load()) + } + }) + } +} + +func TestDepositNoWaitSkipsReceiptEvidence(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + rpc.depositLogs = nil + rpc.inputBoxResponse = nil + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t, "--no-wait") + require.NoError(t, err) + require.Contains(t, output, `"status": "broadcast"`) + require.Zero(t, rpc.receipts.Load()) + require.Zero(t, rpc.estimates.Load()) + require.Zero(t, rpc.inputBoxReads.Load()) +} + +func TestDepositReceiptWithExecutionData(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + execData := []byte{0xde, 0xad, 0xbe, 0xef} + changeAdvance(t, rpc.depositLogs[0], func(args []any) { args[7] = append(args[7].([]byte), execData...) }) + output, err := runDeposit(t, "--exec-data", "0xdeadbeef") + require.NoError(t, err) + require.Contains(t, output, `"status": "mined"`) + require.Len(t, rpc.sent, 1) + portalABI, err := ierc20portal.IErc20PortalMetaData.GetAbi() + require.NoError(t, err) + method := portalABI.Methods["depositErc20Tokens"] + tx := <-rpc.sent + require.Equal(t, method.ID, tx.Data()[:len(method.ID)]) + args, err := method.Inputs.Unpack(tx.Data()[len(method.ID):]) + require.NoError(t, err) + require.Equal(t, execData, args[3]) +} + +func TestParseAmountUint256(t *testing.T) { + limit := new(big.Int).Lsh(big.NewInt(1), 256) + maxAmount := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1)) + amount, err := parseAmount(maxAmount.String()) + require.NoError(t, err) + require.Equal(t, maxAmount, amount) + _, err = parseAmount(limit.String()) + require.ErrorContains(t, err, "uint256") +} + +func runDeposit(t *testing.T, extra ...string) (string, error) { + t.Helper() + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + args := append([]string{"erc20", testApplication, "--portal", testPortal, "--token", testToken, + "--amount", "100", "--yes", "--json"}, extra...) + Cmd.SetArgs(args) + err := Cmd.ExecuteContext(t.Context()) + return stdout.String(), err +} + +func changeDepositInput(t *testing.T, log *types.Log, change func([]byte) []byte) { + t.Helper() + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + args := parsed.Events["InputAdded"].Inputs.NonIndexed() + values, err := args.Unpack(log.Data) + require.NoError(t, err) + log.Data, err = args.Pack(change(values[0].([]byte))) + require.NoError(t, err) +} + +func changeAdvance(t *testing.T, log *types.Log, change func([]any)) { + t.Helper() + parsed, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + method := parsed.Methods["EvmAdvance"] + changeDepositInput(t, log, func(input []byte) []byte { + args, err := method.Inputs.Unpack(input[len(method.ID):]) + require.NoError(t, err) + change(args) + input, err = parsed.Pack("EvmAdvance", args...) + require.NoError(t, err) + return input + }) +} + +func changeAdvanceField(field int, value any) func(*testing.T, *depositRPC) { + return func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[field] = value }) + } +} + +func changePayloadByte(offset int) func(*testing.T, *depositRPC) { + return func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[7].([]byte)[offset] ^= 1 }) + } +} From 5d5ac9a43446ae83414dfdadfd196c0603c78398 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:16 -0300 Subject: [PATCH 16/41] feat(cli): deploy direct input box apps Deploy and register applications with a direct InputBox address and the new v3 factory arguments. Expose staging and PRT deployment settings through the existing CLI workflow. Remove data-availability selector handling. Add argument and registration tests for the supported contract configuration. Use the common transaction policy. Broadcast-only deployment returns the factory-calculated predicted_address, not confirmed metadata. Registration and deployment verification still require a successful receipt. Write summaries to stdout and progress to stderr; this changes the old summary stream. Cover all five factory paths with actual CLI subprocess tests. --- .../root/app/register/register.go | 156 +++--------- .../root/app/register/register_test.go | 6 + .../root/deploy/application.go | 183 +++++++------- .../root/deploy/application_test.go | 81 +++++++ .../root/deploy/authority.go | 36 ++- .../root/deploy/broadcast.go | 38 +++ .../root/deploy/broadcast_test.go | 225 ++++++++++++++++++ cmd/cartesi-rollups-cli/root/deploy/deploy.go | 2 +- cmd/cartesi-rollups-cli/root/deploy/quorum.go | 36 ++- .../root/deploy/quorum_test.go | 10 +- .../root/deploy/sentries.go | 58 +++++ .../root/deploy/sentries_test.go | 105 ++++++++ 12 files changed, 715 insertions(+), 221 deletions(-) create mode 100644 cmd/cartesi-rollups-cli/root/deploy/application_test.go create mode 100644 cmd/cartesi-rollups-cli/root/deploy/broadcast.go create mode 100644 cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go create mode 100644 cmd/cartesi-rollups-cli/root/deploy/sentries.go create mode 100644 cmd/cartesi-rollups-cli/root/deploy/sentries_test.go diff --git a/cmd/cartesi-rollups-cli/root/app/register/register.go b/cmd/cartesi-rollups-cli/root/app/register/register.go index 73db034ff..ef7e88822 100644 --- a/cmd/cartesi-rollups-cli/root/app/register/register.go +++ b/cmd/cartesi-rollups-cli/root/app/register/register.go @@ -5,7 +5,6 @@ package register import ( "context" - "encoding/hex" "encoding/json" "fmt" "math/big" @@ -17,7 +16,6 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository/factory" - "github.com/cartesi/rollups-node/pkg/contracts/dataavailability" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/contracts/iquorum" @@ -39,7 +37,6 @@ var Cmd = &cobra.Command{ Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS Input Box contract address CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED Enable machine hash check`, } @@ -54,9 +51,6 @@ var ( templateHash string epochLength uint64 claimStagingPeriod uint64 - inputBoxBlockNumber uint64 - inputBoxAddressFromEnv bool - dataAvailability string enableMachineHashCheck bool applicationTypePRT bool disabled bool @@ -87,17 +81,10 @@ func init() { ) Cmd.Flags().Uint64Var(&claimStagingPeriod, "claim-staging-period", 0, - "Consensus claim staging period in blocks (Authority/Quorum only). "+ + "Consensus claim staging period in blocks. "+ "(DO NOT USE IN PRODUCTION)\nThis value is retrieved from the consensus contract", ) - Cmd.Flags().StringVarP(&dataAvailability, "data-availability", "D", "", - "Application ABI encoded Data Availability. If not provided, it will be read from the InputBox Address", - ) - - Cmd.Flags().BoolVar(&inputBoxAddressFromEnv, "inputbox-from-env", false, "Read Input Box contract address from environment") - Cmd.Flags().Uint64Var(&inputBoxBlockNumber, "inputbox-block-number", 0, "InputBox deployment block number") - Cmd.Flags().BoolVarP(&disabled, "disabled", "d", false, "Registers the application with enabled=false") Cmd.Flags().BoolVarP(&printAsJSON, "print-json", "j", false, "Prints the application data as JSON") @@ -116,7 +103,6 @@ func init() { command.Flags().Lookup("verbose").Hidden = false command.Flags().Lookup("database-connection").Hidden = false command.Flags().Lookup("blockchain-http-endpoint").Hidden = false - command.Flags().Lookup("inputbox").Hidden = false origHelpFunc(command, strings) }) } @@ -186,7 +172,7 @@ func run(cmd *cobra.Command, _ []string) { } } - if !cmd.Flags().Changed("claim-staging-period") && !applicationTypePRT { + if !cmd.Flags().Changed("claim-staging-period") { claimStagingPeriod, err = getClaimStagingPeriod(ctx, consensus) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get claim staging period from consensus: %v\n", @@ -202,31 +188,32 @@ func run(cmd *cobra.Command, _ []string) { os.Exit(1) } - inputBoxAddress, encodedDA, err := processDataAvailability( - ctx, - address, - cmd.Flags().Changed("data-availability"), - cmd.Flags().Changed("inputbox") || cmd.Flags().Changed("inputbox-from-env"), - ) - cobra.CheckErr(err) + inputBoxAddress, err := getInputBox(ctx, address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get input box address from application: %v\n", + cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + os.Exit(1) + } - if !cmd.Flags().Changed("inputbox-block-number") { - block, err := getInputBoxDeploymentBlock(ctx, *inputBoxAddress) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to get deployment block number: %v\n", err) - os.Exit(1) - } - inputBoxBlockNumber = block.Uint64() + block, err := getInputBoxDeploymentBlock(ctx, inputBoxAddress) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get deployment block number: %v\n", err) + os.Exit(1) } + if !block.IsUint64() { + fmt.Fprintf(os.Stderr, "Input box deployment block does not fit uint64: %v\n", block) + os.Exit(1) + } + inputBoxBlockNumber := block.Uint64() // ensure there is a contract deployed at the input box address - hasCode, err := hasCodeAt(ctx, *inputBoxAddress) + hasCode, err := hasCodeAt(ctx, inputBoxAddress) if err != nil { fmt.Fprintf(os.Stderr, "failed to probe input box address for contract: %v\n", err) os.Exit(1) } if !hasCode { - fmt.Fprintf(os.Stderr, "input box address has no code: %v\n", consensus) + fmt.Fprintf(os.Stderr, "input box address has no code: %v\n", inputBoxAddress) os.Exit(1) } @@ -240,13 +227,12 @@ func run(cmd *cobra.Command, _ []string) { Name: validName, IApplicationAddress: address, IConsensusAddress: consensus, - IInputBoxAddress: *inputBoxAddress, + IInputBoxAddress: inputBoxAddress, TemplateURI: templatePath, TemplateHash: parsedTemplateHash, EpochLength: epochLength, ClaimStagingPeriod: claimStagingPeriod, WithdrawalConfig: withdrawalConfig, - DataAvailability: encodedDA, ConsensusType: consensusType, Enabled: applicationEnabled, Status: model.ApplicationStatus_OK, @@ -339,6 +325,21 @@ func getConsensus( return ethutil.GetConsensus(ctx, client, appAddress) } +func getInputBox( + ctx context.Context, + appAddress common.Address, +) (common.Address, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return common.Address{}, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := ethclient.Dial(ethEndpoint.Raw()) + if err != nil { + return common.Address{}, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetInputBox(ctx, client, appAddress) +} + func getEpochLength( ctx context.Context, consensusAddr common.Address, @@ -447,90 +448,3 @@ func getInputBoxDeploymentBlock( } return ethutil.GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) } - -func getDataAvailability( - ctx context.Context, - appAddress common.Address, -) ([]byte, error) { - ethEndpoint, err := config.GetBlockchainHttpEndpoint() - if err != nil { - return nil, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) - } - client, err := ethclient.Dial(ethEndpoint.Raw()) - if err != nil { - return nil, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) - } - return ethutil.GetDataAvailability(ctx, client, appAddress) -} - -func processDataAvailability( - ctx context.Context, - appAddress common.Address, - hasDataAvailabilityFlag bool, - hasInputBoxAddressFlag bool, -) (*common.Address, []byte, error) { - var inputBoxAddress common.Address - var encodedDA []byte - var err error - - parsedAbi, err := dataavailability.DataAvailabilityMetaData.GetAbi() - if err != nil { - return nil, nil, fmt.Errorf("failed to get ABI: %w", err) - } - - if hasInputBoxAddressFlag { - inputBoxAddress, err = config.GetContractsInputBoxAddress() - if err != nil { - return nil, nil, fmt.Errorf("failed to get input box address: %w", err) - } - - encodedDA, err = parsedAbi.Pack("InputBox", inputBoxAddress) - if err != nil { - return nil, nil, fmt.Errorf("failed to pack InputBox: %w", err) - } - } else { - if hasDataAvailabilityFlag { - if len(dataAvailability) < 3 || (!strings.HasPrefix(dataAvailability, "0x") && !strings.HasPrefix(dataAvailability, "0X")) { - return nil, nil, fmt.Errorf("data Availability should be an ABI encoded value") - } - - s := dataAvailability[2:] - encodedDA, err = hex.DecodeString(s) - if err != nil { - return nil, nil, fmt.Errorf("error parsing Data Availability value: %w", err) - } - } else { - encodedDA, err = getDataAvailability(ctx, appAddress) - if err != nil { - return nil, nil, fmt.Errorf("failed to get Data Availability from Application: %w", err) - } - } - - if len(encodedDA) < model.DATA_AVAILABILITY_SELECTOR_SIZE { - return nil, nil, fmt.Errorf("invalid Data Availability") - } - - method, err := parsedAbi.MethodById(encodedDA[:model.DATA_AVAILABILITY_SELECTOR_SIZE]) - if err != nil { - return nil, nil, fmt.Errorf("failed to get method by ID: %w", err) - } - - args, err := method.Inputs.Unpack(encodedDA[model.DATA_AVAILABILITY_SELECTOR_SIZE:]) - if err != nil { - return nil, nil, fmt.Errorf("failed to unpack inputs: %w", err) - } - - if len(args) == 0 { - return nil, nil, fmt.Errorf("invalid Data Availability. Should at least contain InputBox Address") - } - - switch addr := args[0].(type) { - case common.Address: - inputBoxAddress = addr - default: - return nil, nil, fmt.Errorf("first argument in Data Availability is not an address (got %T)", args[0]) - } - } - - return &inputBoxAddress, encodedDA, nil -} diff --git a/cmd/cartesi-rollups-cli/root/app/register/register_test.go b/cmd/cartesi-rollups-cli/root/app/register/register_test.go index 8cfd58b59..5720161cd 100644 --- a/cmd/cartesi-rollups-cli/root/app/register/register_test.go +++ b/cmd/cartesi-rollups-cli/root/app/register/register_test.go @@ -70,3 +70,9 @@ func TestConsensusTypeFromQuorumProbe_RejectsZeroValidatorQuorum(t *testing.T) { require.ErrorContains(t, err, "zero validators") require.True(t, probe.called) } + +func TestRegisterCommandDoesNotExposeLegacyInputBoxFlags(t *testing.T) { + require.Nil(t, Cmd.Flags().Lookup("data-availability")) + require.Nil(t, Cmd.Flags().Lookup("inputbox-from-env")) + require.Nil(t, Cmd.Flags().Lookup("inputbox-block-number")) +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/application.go b/cmd/cartesi-rollups-cli/root/deploy/application.go index ec26b27f0..e9865e4cd 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/application.go +++ b/cmd/cartesi-rollups-cli/root/deploy/application.go @@ -25,13 +25,13 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" ) var ( applicationConsensusAddressParam string - applicationDataAvailabilityParam string applicationEnableParam bool applicationOwnerAddressParam string applicationRegisterParam bool @@ -54,6 +54,7 @@ var applicationCmd = &cobra.Command{ }, Example: applicationExamples, Run: runDeployApplication, + PreRunE: validateApplicationDeployment, Long: ` Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string @@ -75,6 +76,9 @@ const applicationExamples = ` # deploy an application contract with a PRT consensus, then register the application - cli deploy application echo-dapp applications/echo-dapp/ --prt +# deploy a PRT application with two sentry slots and a manager that can rotate their addresses + - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ --prt --claim-staging-period=300 --sentry-manager=0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA --sentries=0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB,0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC + # deploy but don't register into the database - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ --register=false @@ -93,8 +97,6 @@ func init() { "PRT Application factory address. Default value is retrieved from configuration.") applicationCmd.Flags().StringVarP(&applicationOwnerAddressParam, "application-owner", "o", "", "Application owner address. If not defined, it will be derived from the auth method.") - applicationCmd.Flags().StringVarP(&applicationDataAvailabilityParam, "data-availability", "d", "", - "Data availability string. Default is input box.") applicationCmd.Flags().StringVarP(&applicationTemplateHashParam, "template-hash", "H", "", "Template hash. If not provided, it will be read from the template path") applicationCmd.Flags().BoolVarP(&applicationRegisterParam, "register", "r", true, @@ -107,6 +109,7 @@ func init() { "Authority Owner address. If not defined, it will be derived from the auth method.") applicationCmd.Flags().BoolVarP(&deploymentTypePRT, "prt", "", false, "Deploy a PRT application.") + addPRTSentryFlags(applicationCmd) origHelpFunc := applicationCmd.HelpFunc() applicationCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -116,6 +119,29 @@ func init() { command.Flags().Lookup("verbose").Hidden = false origHelpFunc(command, strings) }) + cli.AddTransactionFlags(applicationCmd) +} + +func validateApplicationDeployment(cmd *cobra.Command, args []string) error { + register, err := cmd.Flags().GetBool("register") + if err != nil { + return err + } + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return err + } + if noWait && register { + return fmt.Errorf("--no-wait requires --register=false; registration needs a confirmed deployment") + } + if register && len(args) < 1 { + return fmt.Errorf("missing application name: positional argument [application-name] is required when --register=true") + } + if !cmd.Flags().Changed("template-hash") && len(args) < 2 { + return fmt.Errorf("missing template: provide either positional [template-path] or --template-hash") + } + _, _, err = parsePRTSentryConfig(cmd) + return err } func runDeployApplication(cmd *cobra.Command, args []string) { @@ -124,24 +150,12 @@ func runDeployApplication(cmd *cobra.Command, args []string) { ctx := cmd.Context() - // Validate required application name when registering - if applicationRegisterParam && len(args) < 1 { - err := cmd.Help() - cobra.CheckErr(err) - cobra.CheckErr(fmt.Errorf("missing application name: positional argument [application-name] is required when --register=true")) - } - // Validate that a template is provided either as positional [template-path] or via --template-hash - if cmd.Flags().Changed("template-hash") && len(args) < 2 { - err := cmd.Help() - cobra.CheckErr(err) - cobra.CheckErr(fmt.Errorf("missing template: provide either positional [template-path] or --template-hash")) - } - ethEndpoint, err := config.GetBlockchainHttpEndpoint() cobra.CheckErr(err) client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) + defer client.Close() chainID, err := client.ChainID(ctx) cobra.CheckErr(err) @@ -205,7 +219,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { if executionParametersFileParam == "-" { filePath = os.Stdin.Name() } - contents, err := os.ReadFile(filePath) //nolint:gosec // The CLI user explicitly supplies this path. + contents, err := os.ReadFile(filePath) cobra.CheckErr(err) decoder := json.NewDecoder(strings.NewReader(string(contents))) @@ -239,7 +253,16 @@ func runDeployApplication(cmd *cobra.Command, args []string) { if verboseParam || !asJSONParam { fmt.Fprint(os.Stderr, "deploying...") } - _, result, err := deployment.Deploy(ctx, client, ethutil.NewStaticTransactOptsFactory(txOpts)) + var tx *types.Transaction + var receipt *types.Receipt + applicationAddress, result, err := deployment.DeployWithTransaction(ctx, client, ethutil.NewStaticTransactOptsFactory(txOpts), + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) // The revert surface spans the variant's factory plus the constructors it // invokes; selectors are content-matched, so passing every factory ABI is // harmless and covers all three deployment variants. @@ -250,10 +273,13 @@ func runDeployApplication(cmd *cobra.Command, args []string) { iquorumfactory.IQuorumFactoryMetaData, idaveappfactory.IDaveAppFactoryMetaData, )) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, applicationAddress)) + return + } if verboseParam || !asJSONParam { fmt.Fprint(os.Stderr, "success\n") - fmt.Fprint(os.Stderr, result) } // TODO(mpolitzer): can this be more concise? @@ -266,7 +292,6 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.TemplateHash = res.Deployment.TemplateHash application.EpochLength = res.Deployment.EpochLength application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod - application.DataAvailability = res.Deployment.DataAvailability application.IInputBoxBlock = res.Deployment.IInputBoxBlock application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) @@ -277,7 +302,6 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.TemplateHash = res.Deployment.TemplateHash application.EpochLength = res.Deployment.EpochLength application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod - application.DataAvailability = res.Deployment.DataAvailability application.IInputBoxBlock = res.Deployment.IInputBoxBlock if res.Deployment.ConsensusType != "" { application.ConsensusType = model.Consensus(res.Deployment.ConsensusType) @@ -290,7 +314,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.IInputBoxAddress = res.InputBoxAddress application.TemplateHash = res.Deployment.TemplateHash application.EpochLength = res.Deployment.EpochLength - application.DataAvailability = res.DataAvailability + application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod application.IInputBoxBlock = res.IInputBoxBlock application.ConsensusType = model.Consensus_PRT application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) @@ -337,9 +361,17 @@ func runDeployApplication(cmd *cobra.Command, args []string) { } if asJSONParam { - report, err := json.MarshalIndent(&application, "", " ") + report := struct { + model.Application + Transaction cli.TransactionResult `json:"transaction"` + }{Application: application, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprint(cmd.OutOrStdout(), result) cobra.CheckErr(err) - fmt.Println(string(report)) } } @@ -363,13 +395,8 @@ func buildSelfhostedApplicationDeployment( return nil, fmt.Errorf("error on parameter selfhosted-factory: %w", err) } - if !cmd.Flags().Changed("application-owner") { - request.ApplicationOwnerAddress = txOpts.From - } else { - request.ApplicationOwnerAddress, err = parseHexAddress(applicationOwnerAddressParam) - } - if err != nil { - return nil, fmt.Errorf("error on parameter application-owner: %w", err) + if cmd.Flags().Changed("application-owner") { + return nil, fmt.Errorf("application-owner is not supported for self-hosted deployments; application ownership is renounced") } if !cmd.Flags().Changed("authority-owner") { @@ -394,29 +421,9 @@ func buildSelfhostedApplicationDeployment( return nil, fmt.Errorf("error on parameter template-hash: %w", err) } - var dataAvailabilityErr error - if !cmd.Flags().Changed("data-availability") { - inputBoxAddress, err := config.GetContractsInputBoxAddress() - if err != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", err) - } - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = - ethutil.DefaultDA(client, inputBoxAddress) - } else { - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = - ethutil.CustomDA(client, applicationDataAvailabilityParam) - } - if dataAvailabilityErr != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", dataAvailabilityErr) - } - - // ensure there is a contract deployed at the input box address - code, err := client.CodeAt(ctx, request.InputBoxAddress, nil) + request.InputBoxAddress, request.IInputBoxBlock, err = deploymentInputBox(ctx, client) if err != nil { - return nil, fmt.Errorf("failed to probe input box address for contract: %v", err) - } - if len(code) == 0 { - return nil, fmt.Errorf("error input box address has no code: %v", request.InputBoxAddress) + return nil, err } request.Salt, err = ethutil.ParseSalt(saltParam) @@ -479,29 +486,9 @@ func buildApplicationOnlyDeployment( return nil, fmt.Errorf("error on parameter application-owner: %w", err) } - var dataAvailabilityErr error - if !cmd.Flags().Changed("data-availability") { - inputBoxAddress, err := config.GetContractsInputBoxAddress() - if err != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", err) - } - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = - ethutil.DefaultDA(client, inputBoxAddress) - } else { - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = - ethutil.CustomDA(client, applicationDataAvailabilityParam) - } - if dataAvailabilityErr != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", dataAvailabilityErr) - } - - // ensure there is a contract deployed at the input box address - code, err := client.CodeAt(ctx, request.InputBoxAddress, nil) + request.InputBoxAddress, request.IInputBoxBlock, err = deploymentInputBox(ctx, client) if err != nil { - return nil, fmt.Errorf("failed to probe input box address for contract: %v", err) - } - if len(code) == 0 { - return nil, fmt.Errorf("error input box address has no code: %v", request.InputBoxAddress) + return nil, err } request.Salt, err = ethutil.ParseSalt(saltParam) @@ -518,7 +505,7 @@ func buildApplicationOnlyDeployment( var consensusType model.Consensus request.Consensus, request.EpochLength, request.ClaimStagingPeriod, consensusType, err = - customConsensus(client, applicationConsensusAddressParam) + customConsensus(ctx, client, applicationConsensusAddressParam) if err != nil { return nil, fmt.Errorf("error on parameter consensus: %w", err) } @@ -527,6 +514,30 @@ func buildApplicationOnlyDeployment( return request, nil } +func deploymentInputBox(ctx context.Context, client *ethclient.Client) (common.Address, uint64, error) { + inputBoxAddress, err := config.GetContractsInputBoxAddress() + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to get input box address: %w", err) + } + + code, err := client.CodeAt(ctx, inputBoxAddress, nil) + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to probe input box address for contract: %w", err) + } + if len(code) == 0 { + return common.Address{}, 0, fmt.Errorf("input box address has no code: %v", inputBoxAddress) + } + + inputBoxBlock, err := ethutil.GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to get input box deployment block: %w", err) + } + if !inputBoxBlock.IsUint64() { + return common.Address{}, 0, fmt.Errorf("input box deployment block does not fit uint64: %v", inputBoxBlock) + } + return inputBoxAddress, inputBoxBlock.Uint64(), nil +} + func buildPrtApplicationDeployment( cmd *cobra.Command, args []string, @@ -536,6 +547,10 @@ func buildPrtApplicationDeployment( ) { var err error request := ðutil.PRTApplicationDeployment{} + request.SentryManager, request.Sentries, err = parsePRTSentryConfig(cmd) + if err != nil { + return nil, err + } if !cmd.Flags().Changed("prt-factory") { request.FactoryAddress, err = config.GetContractsDaveAppFactoryAddress() } else { @@ -568,6 +583,7 @@ func buildPrtApplicationDeployment( return nil, err } + request.ClaimStagingPeriod = claimStagingPeriodParam request.Verbose = verboseParam return request, nil } @@ -577,7 +593,9 @@ func parseHexHash(hash string) (common.Hash, error) { return out, out.UnmarshalText([]byte(hash)) } -func customConsensus(client *ethclient.Client, consensusString string) (common.Address, uint64, uint64, model.Consensus, error) { +func customConsensus( + ctx context.Context, client *ethclient.Client, consensusString string, +) (common.Address, uint64, uint64, model.Consensus, error) { consensusAddress, err := parseHexAddress(consensusString) if err != nil { return common.Address{}, 0, 0, "", err @@ -588,14 +606,15 @@ func customConsensus(client *ethclient.Client, consensusString string) (common.A return common.Address{}, 0, 0, "", err } - epochLengthBig, err := consensus.GetEpochLength(nil) + callOpts := &bind.CallOpts{Context: ctx} + epochLengthBig, err := consensus.GetEpochLength(callOpts) if err != nil { - return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus epoch length: %v", err) + return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus epoch length: %w", err) } - claimStagingPeriodBig, err := consensus.GetClaimStagingPeriod(nil) + claimStagingPeriodBig, err := consensus.GetClaimStagingPeriod(callOpts) if err != nil { - return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus claim staging period: %v", err) + return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus claim staging period: %w", err) } consensusType := model.Consensus_Authority @@ -603,7 +622,7 @@ func customConsensus(client *ethclient.Client, consensusString string) (common.A if err != nil { return common.Address{}, 0, 0, "", err } - numOfValidators, err := quorum.NumOfValidators(nil) + numOfValidators, err := quorum.NumOfValidators(callOpts) if err == nil { if numOfValidators.Sign() == 0 { return common.Address{}, 0, 0, "", fmt.Errorf("quorum consensus reports zero validators") diff --git a/cmd/cartesi-rollups-cli/root/deploy/application_test.go b/cmd/cartesi-rollups-cli/root/deploy/application_test.go new file mode 100644 index 000000000..cbc09d0b2 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/application_test.go @@ -0,0 +1,81 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestBuildSelfhostedDeploymentRejectsApplicationOwner(t *testing.T) { + t.Setenv("CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS", "0x1000000000000000000000000000000000000001") + for _, ownerProvided := range []bool{false, true} { + name := "omitted" + if ownerProvided { + name = "explicit" + } + t.Run(name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("application-owner", "", "") + if ownerProvided { + require.NoError(t, cmd.Flags().Set("application-owner", "0x2000000000000000000000000000000000000002")) + } + _, err := buildSelfhostedApplicationDeployment(t.Context(), cmd, nil, nil, &bind.TransactOpts{}) + if ownerProvided { + require.ErrorContains(t, err, "ownership is renounced") + } else { + // Without an owner flag, validation reaches the next required argument. + require.ErrorContains(t, err, "template-hash") + } + }) + } +} + +func TestApplicationCommandDoesNotExposeDataAvailabilityFlag(t *testing.T) { + require.Nil(t, applicationCmd.Flags().Lookup("data-availability")) +} + +func TestApplicationDeploymentValidatesBeforeRunning(t *testing.T) { + for _, test := range []struct { + name string + register bool + noWait bool + templateHash bool + args []string + wantError string + }{ + {name: "registration requires receipt", register: true, noWait: true, wantError: "--no-wait requires --register=false"}, + {name: "registration requires name", register: true, templateHash: true, wantError: "missing application name"}, + {name: "template required", wantError: "missing template"}, + {name: "template hash only", templateHash: true}, + {name: "no wait with template hash", noWait: true, templateHash: true}, + {name: "registered template hash", register: true, templateHash: true, args: []string{"example"}}, + {name: "positional template", register: true, args: []string{"example", "applications/example"}}, + } { + t.Run(test.name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("register", test.register, "") + cmd.Flags().String("template-hash", "", "") + cli.AddTransactionFlags(cmd) + if test.noWait { + require.NoError(t, cmd.Flags().Set("no-wait", "true")) + } + if test.templateHash { + require.NoError(t, cmd.Flags().Set("template-hash", "0x01")) + } + // Invoke the actual command hook. The test must fail if the argument + // validation is removed from the command's pre-run path. + err := applicationCmd.PreRunE(cmd, test.args) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/authority.go b/cmd/cartesi-rollups-cli/root/deploy/authority.go index 8a00f22e6..416ac2dc7 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/authority.go +++ b/cmd/cartesi-rollups-cli/root/deploy/authority.go @@ -4,6 +4,7 @@ package deploy import ( + "context" "encoding/json" "fmt" "os" @@ -14,6 +15,7 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" ) @@ -57,6 +59,7 @@ func init() { // the parameter for the authority contract being deployed. origHelpFunc(command, strings) }) + cli.AddTransactionFlags(authorityCmd) } func runDeployAuthority(cmd *cobra.Command, _ []string) { @@ -69,6 +72,7 @@ func runDeployAuthority(cmd *cobra.Command, _ []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) + defer client.Close() chainID, err := client.ChainID(ctx) cobra.CheckErr(err) @@ -105,21 +109,39 @@ func runDeployAuthority(cmd *cobra.Command, _ []string) { if verboseParam || !asJSONParam { fmt.Fprintf(os.Stderr, "deploying authority...") } - deployment.Address, err = deployment.Deploy(ctx, client, txOpts) + var tx *types.Transaction + var receipt *types.Receipt + deployment.Address, err = deployment.DeployWithTransaction(ctx, client, txOpts, + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) cobra.CheckErr(cli.DecorateRevert(err, iauthorityfactory.IAuthorityFactoryMetaData)) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, deployment.Address)) + return + } // report if verboseParam || !asJSONParam { fmt.Fprintf(os.Stderr, "success\n") - fmt.Fprintln(os.Stderr, "\tconsensus address: ", deployment.Address) - fmt.Fprintln(os.Stderr, "\tepoch length: ", deployment.EpochLength) } if asJSONParam { - report, err := json.MarshalIndent(&deployment, "", " ") - cobra.CheckErr(err) // deployed, but fail to print - - fmt.Println(string(report)) + report := struct { + *ethutil.AuthorityDeployment + Transaction cli.TransactionResult `json:"transaction"` + }{AuthorityDeployment: deployment, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Consensus address: %s\nEpoch length: %d\n", deployment.Address, deployment.EpochLength) + cobra.CheckErr(err) } } diff --git a/cmd/cartesi-rollups-cli/root/deploy/broadcast.go b/cmd/cartesi-rollups-cli/root/deploy/broadcast.go new file mode 100644 index 000000000..11e258503 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/broadcast.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/internal/cli" +) + +func writeDeploymentBroadcast(cmd *cobra.Command, tx *types.Transaction, predictedAddress common.Address) error { + asJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + result := struct { + cli.TransactionResult + PredictedAddress common.Address `json:"predicted_address"` + }{TransactionResult: cli.NewTransactionResult(tx, nil), PredictedAddress: predictedAddress} + if asJSON { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + err = encoder.Encode(result) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Transaction broadcast: %s\nPredicted address (deployment not confirmed): %s\n", + result.TransactionHash, predictedAddress.Hex()) + } + if err != nil { + return fmt.Errorf("write deployment broadcast result: %w", err) + } + return nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go b/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go new file mode 100644 index 000000000..e9dc7027b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go @@ -0,0 +1,225 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iselfhostedapplicationfactory" +) + +const deploymentCLIProcessEnv = "CARTESI_TEST_DEPLOYMENT_CLI_PROCESS" + +func TestDeploymentBroadcastPlainOutput(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("json", false, "") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + tx := types.NewTx(&types.LegacyTx{}) + address := common.HexToAddress("0x1234") + require.NoError(t, writeDeploymentBroadcast(cmd, tx, address)) + require.Equal(t, "Transaction broadcast: "+tx.Hash().Hex()+ + "\nPredicted address (deployment not confirmed): "+address.Hex()+"\n", stdout.String()) + require.Empty(t, stderr.String()) +} + +// Run the actual command in a child process. Existing deployment handlers use +// cobra.CheckErr, and this also checks Cobra's default stdout/stderr selection. +func TestDeploymentCLIProcess(t *testing.T) { + if os.Getenv(deploymentCLIProcessEnv) != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + Cmd.SetArgs(os.Args[i+1:]) + break + } + } + if err := Cmd.ExecuteContext(t.Context()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func TestDeploymentCLIReportsPredictedAddress(t *testing.T) { + testExecutable, err := os.Executable() + require.NoError(t, err) + factoryAddress := common.HexToAddress("0x1000") + inputBoxAddress := common.HexToAddress("0x2000") + predictedAddress := common.HexToAddress("0x3000") + secondaryAddress := common.HexToAddress("0x4000") + applicationArgs := []string{"application", "--register=false", "--template-hash=" + (common.Hash{}).Hex()} + for _, test := range []struct { + name string + args []string + }{ + {name: "authority", args: []string{"authority", "--authority-factory", factoryAddress.Hex()}}, + {name: "quorum", args: []string{"quorum", "--quorum-factory", factoryAddress.Hex(), "--validator", testValidatorAddress}}, + {name: "selfhosted", args: append(append([]string{}, applicationArgs...), "--factory", factoryAddress.Hex())}, + {name: "application", args: append(append([]string{}, applicationArgs...), "--factory", factoryAddress.Hex(), + "--consensus", inputBoxAddress.Hex())}, + {name: "prt", args: append(append([]string{}, applicationArgs...), "--prt", "--prt-factory", factoryAddress.Hex())}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &deploymentCLIBackend{ + responses: map[string]hexutil.Bytes{}, + sent: make(chan *types.Transaction, 2), predictedAddress: predictedAddress, secondaryAddress: secondaryAddress, + } + for _, response := range []struct { + metadata *bind.MetaData + method string + outputs []any + }{ + {iauthorityfactory.IAuthorityFactoryMetaData, "calculateAuthorityAddress", []any{predictedAddress}}, + {iquorumfactory.IQuorumFactoryMetaData, "calculateQuorumAddress", []any{predictedAddress}}, + {iapplicationfactory.IApplicationFactoryMetaData, "calculateApplicationAddress", []any{predictedAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "calculateAddresses", + []any{predictedAddress, secondaryAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "getApplicationFactory", []any{factoryAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "getAuthorityFactory", []any{factoryAddress}}, + {idaveappfactory.IDaveAppFactoryMetaData, "calculateDaveAppAddress", []any{predictedAddress, secondaryAddress}}, + {iinputbox.IInputBoxMetaData, "getDeploymentBlockNumber", []any{big.NewInt(1)}}, + {iconsensus.IConsensusMetaData, "getEpochLength", []any{big.NewInt(10)}}, + {iconsensus.IConsensusMetaData, "getClaimStagingPeriod", []any{big.NewInt(0)}}, + {iquorum.IQuorumMetaData, "numOfValidators", []any{big.NewInt(1)}}, + } { + parsed, err := response.metadata.GetAbi() + require.NoError(t, err) + method := parsed.Methods[response.method] + data, err := method.Outputs.Pack(response.outputs...) + require.NoError(t, err) + backend.responses[string(method.ID)] = data + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", backend)) + defer rpcServer.Stop() + server := httptest.NewServer(rpcServer) + defer server.Close() + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(deploymentCLIProcessEnv, "1") + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + t.Setenv(config.CONTRACTS_INPUT_BOX_ADDRESS, inputBoxAddress.Hex()) + t.Setenv(config.DATABASE_CONNECTION, "invalid") + args := append([]string{"-test.run=^TestDeploymentCLIProcess$", "--"}, test.args...) + args = append(args, "--no-wait", "--json") + process := exec.CommandContext(t.Context(), testExecutable, args...) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + require.NoError(t, process.Run(), stderr.String()) + require.Len(t, backend.sent, 1) + tx := <-backend.sent + require.Equal(t, factoryAddress, *tx.To()) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, map[string]string{ + "transaction_hash": tx.Hash().Hex(), "status": "broadcast", "predicted_address": predictedAddress.Hex(), + }, result) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.Zero(t, backend.receiptReads.Load()) + require.Zero(t, backend.postBroadcastReads.Load(), "no deployment verification before mining") + }) + } +} + +type deploymentCLIBackend struct { + responses map[string]hexutil.Bytes + sent chan *types.Transaction + predictedAddress common.Address + secondaryAddress common.Address + broadcast atomic.Bool + receiptReads atomic.Int32 + postBroadcastReads atomic.Int32 +} + +func (*deploymentCLIBackend) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*deploymentCLIBackend) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: 1_000_000} +} + +func (*deploymentCLIBackend) GasPrice(context.Context) *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1)) +} + +func (*deploymentCLIBackend) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (*deploymentCLIBackend) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + return 500_000 +} + +func (b *deploymentCLIBackend) GetCode(_ context.Context, address common.Address, _ string) hexutil.Bytes { + if b.broadcast.Load() { + b.postBroadcastReads.Add(1) + } + if address == b.predictedAddress || address == b.secondaryAddress { + return hexutil.Bytes{} + } + return hexutil.Bytes{0x01} +} + +func (b *deploymentCLIBackend) Call(_ context.Context, call struct { + Input hexutil.Bytes `json:"input"` +}, _ string) (hexutil.Bytes, error) { + if b.broadcast.Load() { + b.postBroadcastReads.Add(1) + } + if len(call.Input) >= 4 { + if response, found := b.responses[string(call.Input[:4])]; found { + return response, nil + } + } + return nil, fmt.Errorf("unexpected contract call: %x", call.Input) +} + +func (b *deploymentCLIBackend) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + b.sent <- &tx + b.broadcast.Store(true) + return tx.Hash(), nil +} + +func (b *deploymentCLIBackend) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + b.receiptReads.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(10), Logs: []*types.Log{}} +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/deploy.go b/cmd/cartesi-rollups-cli/root/deploy/deploy.go index 1d575ef3a..1b4d6fbca 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/deploy.go +++ b/cmd/cartesi-rollups-cli/root/deploy/deploy.go @@ -31,7 +31,7 @@ func init() { "Epoch length") cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("epoch-length")) Cmd.PersistentFlags().Uint64Var(&claimStagingPeriodParam, "claim-staging-period", 0, - "Number of blocks between a claim being submitted and accepted (Authority/Quorum only)") + "Number of blocks between claim staging and acceptance. Zero permits immediate acceptance; choose the delay for your deployment.") Cmd.PersistentFlags().StringVar(&withdrawalConfigParam, "withdrawal-config", "", "Inline JSON object describing the WithdrawalConfig "+ "(see docs/withdrawal-config-guide.md). Omit to deploy without foreclosure.") diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum.go b/cmd/cartesi-rollups-cli/root/deploy/quorum.go index 185cae245..35f21191c 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/quorum.go +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum.go @@ -4,6 +4,7 @@ package deploy import ( + "context" "encoding/json" "fmt" "os" @@ -12,7 +13,9 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" ) @@ -56,6 +59,7 @@ func init() { command.Flags().Lookup("verbose").Hidden = false origHelpFunc(command, strings) }) + cli.AddTransactionFlags(quorumCmd) } func runDeployQuorum(cmd *cobra.Command, _ []string) { @@ -68,6 +72,7 @@ func runDeployQuorum(cmd *cobra.Command, _ []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) + defer client.Close() chainID, err := client.ChainID(ctx) cobra.CheckErr(err) @@ -101,20 +106,39 @@ func runDeployQuorum(cmd *cobra.Command, _ []string) { if verboseParam || !asJSONParam { fmt.Fprintf(os.Stderr, "deploying quorum...") } - deployment.Address, err = deployment.Deploy(ctx, client, txOpts) + var tx *types.Transaction + var receipt *types.Receipt + deployment.Address, err = deployment.DeployWithTransaction(ctx, client, txOpts, + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) cobra.CheckErr(cli.DecorateRevert(err, iquorumfactory.IQuorumFactoryMetaData)) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, deployment.Address)) + return + } if verboseParam || !asJSONParam { fmt.Fprintf(os.Stderr, "success\n") - fmt.Fprintln(os.Stderr, "\tconsensus address: ", deployment.Address) - fmt.Fprintln(os.Stderr, "\tepoch length: ", deployment.EpochLength) - fmt.Fprintln(os.Stderr, "\tclaim staging period: ", deployment.ClaimStagingPeriod) } if asJSONParam { - report, err := json.MarshalIndent(&deployment, "", " ") + report := struct { + *ethutil.QuorumDeployment + Transaction cli.TransactionResult `json:"transaction"` + }{QuorumDeployment: deployment, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Consensus address: %s\nEpoch length: %d\nClaim staging period: %d\n", + deployment.Address, deployment.EpochLength, deployment.ClaimStagingPeriod) cobra.CheckErr(err) - fmt.Println(string(report)) } } diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go b/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go index 4692172c6..fc3aa6493 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go @@ -10,15 +10,17 @@ import ( "github.com/stretchr/testify/require" ) +const testValidatorAddress = "0x1111111111111111111111111111111111111111" + func TestParseValidatorAddresses_ValidRepeatedFlags(t *testing.T) { got, err := parseValidatorAddresses([]string{ - "0x1111111111111111111111111111111111111111", + testValidatorAddress, "0x2222222222222222222222222222222222222222", }) require.NoError(t, err) require.Equal(t, []common.Address{ - common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress(testValidatorAddress), common.HexToAddress("0x2222222222222222222222222222222222222222"), }, got) } @@ -46,8 +48,8 @@ func TestParseValidatorAddresses_RejectsZeroAddress(t *testing.T) { func TestParseValidatorAddresses_RejectsDuplicates(t *testing.T) { _, err := parseValidatorAddresses([]string{ - "0x1111111111111111111111111111111111111111", - "0x1111111111111111111111111111111111111111", + testValidatorAddress, + testValidatorAddress, }) require.Error(t, err) diff --git a/cmd/cartesi-rollups-cli/root/deploy/sentries.go b/cmd/cartesi-rollups-cli/root/deploy/sentries.go new file mode 100644 index 000000000..56054e407 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/sentries.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func addPRTSentryFlags(cmd *cobra.Command) { + cmd.Flags().String("sentry-manager", common.Address{}.Hex(), + "PRT only: immutable sentry manager address. Zero disables address rotation, not sentry claims.") + cmd.Flags().StringSlice("sentries", nil, + "PRT only: initial sentry addresses in slot order (IDs start at 1). Repeat or comma-separate. Slot count cannot change.") +} + +func parsePRTSentryConfig(cmd *cobra.Command) (common.Address, []common.Address, error) { + manager := common.Address{} + sentries := []common.Address{} + if !cmd.Flags().Changed("sentry-manager") && !cmd.Flags().Changed("sentries") { + return manager, sentries, nil + } + prt, err := cmd.Flags().GetBool("prt") + if err != nil { + return manager, nil, err + } + if !prt { + return manager, nil, fmt.Errorf("--sentry-manager and --sentries require --prt") + } + managerText, err := cmd.Flags().GetString("sentry-manager") + if err != nil { + return manager, nil, err + } + manager, err = parseHexAddress(managerText) + if err != nil { + return manager, nil, fmt.Errorf("invalid --sentry-manager: %w", err) + } + addresses, err := cmd.Flags().GetStringSlice("sentries") + if err != nil { + return manager, nil, err + } + for i, text := range addresses { + address, err := parseHexAddress(text) + if err != nil { + return manager, nil, fmt.Errorf("invalid --sentries entry %d: %w", i+1, err) + } + sentries = append(sentries, address) + } + if err := ethutil.ValidateSentryAddresses(sentries); err != nil { + return manager, nil, fmt.Errorf("invalid --sentries: %w", err) + } + return manager, sentries, nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go b/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go new file mode 100644 index 000000000..c3be1a4a7 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go @@ -0,0 +1,105 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/cli" +) + +const ( + prtFlag = "--prt" + managerFlag = "--sentry-manager" + sentriesFlag = "--sentries" + requiresPRT = "require --prt" +) + +func TestApplicationDeploymentSentryFlags(t *testing.T) { + manager := common.HexToAddress("0x10").Hex() + sentryOne := common.HexToAddress("0xab").Hex() + sentryTwo := common.HexToAddress("0xcd").Hex() + for _, name := range []string{"sentry-manager", "sentries"} { + require.NotNil(t, applicationCmd.Flags().Lookup(name), "the deployed command must expose %s", name) + } + for _, test := range []struct { + name string + flags []string + manager common.Address + sentries []common.Address + wantError string + }{ + {name: "non PRT defaults"}, + {name: "PRT defaults", flags: []string{prtFlag}}, + {name: "manager only", flags: []string{prtFlag, managerFlag, manager}, manager: common.HexToAddress(manager)}, + {name: "fixed sentries", flags: []string{prtFlag, sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryOne)}}, + {name: "explicit zero manager", flags: []string{prtFlag, managerFlag, common.Address{}.Hex(), sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryOne)}}, + {name: "ordered list", flags: []string{prtFlag, managerFlag, manager, sentriesFlag, sentryTwo + "," + sentryOne}, + manager: common.HexToAddress(manager), + sentries: []common.Address{common.HexToAddress(sentryTwo), common.HexToAddress(sentryOne)}}, + {name: "repeated flag", flags: []string{prtFlag, sentriesFlag, sentryTwo, sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryTwo), common.HexToAddress(sentryOne)}}, + {name: "empty list", flags: []string{prtFlag, "--sentries="}}, + {name: "manager without PRT", flags: []string{managerFlag, manager}, wantError: requiresPRT}, + {name: "sentries without PRT", flags: []string{sentriesFlag, sentryOne}, wantError: requiresPRT}, + {name: "explicit empty without PRT", flags: []string{"--sentries="}, wantError: requiresPRT}, + {name: "malformed manager", flags: []string{prtFlag, managerFlag, "0x01"}, wantError: "invalid --sentry-manager"}, + {name: "malformed sentry", flags: []string{prtFlag, sentriesFlag, "not-an-address"}, wantError: "invalid --sentries entry 1"}, + {name: "zero sentry", flags: []string{prtFlag, sentriesFlag, common.Address{}.Hex()}, wantError: "must not be the zero address"}, + {name: "duplicate sentry", flags: []string{prtFlag, sentriesFlag, sentryOne, sentriesFlag, strings.ToLower(sentryOne)}, + wantError: "duplicates address"}, + {name: "empty entry", flags: []string{prtFlag, sentriesFlag, sentryOne + ","}, wantError: "invalid --sentries entry 2"}, + } { + t.Run(test.name, func(t *testing.T) { + cmd := newSentryDeploymentTestCommand(t, test.flags) + // Exercise the command's real pre-run hook, not only the parser. + err := applicationCmd.PreRunE(cmd, nil) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + return + } + require.NoError(t, err) + gotManager, gotSentries, err := parsePRTSentryConfig(cmd) + require.NoError(t, err) + require.Equal(t, test.manager, gotManager) + require.Equal(t, append([]common.Address{}, test.sentries...), gotSentries) + }) + } +} + +func newSentryDeploymentTestCommand(t *testing.T, args []string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.Flags().Bool("prt", false, "") + cmd.Flags().Bool("register", false, "") + cmd.Flags().String("template-hash", "", "") + addPRTSentryFlags(cmd) + cli.AddTransactionFlags(cmd) + require.NoError(t, cmd.Flags().Set("template-hash", common.HexToHash("0x01").Hex())) + require.NoError(t, cmd.ParseFlags(args)) + return cmd +} + +func TestBuildPRTDeploymentPreservesSentryConfig(t *testing.T) { + t.Setenv("CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS", common.HexToAddress("0x100").Hex()) + previousHash := applicationTemplateHashParam + t.Cleanup(func() { applicationTemplateHashParam = previousHash }) + applicationTemplateHashParam = common.HexToHash("0x01").Hex() + manager := common.HexToAddress("0x10") + sentryOne, sentryTwo := common.HexToAddress("0x30"), common.HexToAddress("0x20") + cmd := newSentryDeploymentTestCommand(t, []string{ + prtFlag, managerFlag, manager.Hex(), sentriesFlag, sentryOne.Hex() + "," + sentryTwo.Hex(), + }) + request, err := buildPrtApplicationDeployment(cmd, nil) + require.NoError(t, err) + require.Equal(t, manager, request.SentryManager) + require.Equal(t, []common.Address{sentryOne, sentryTwo}, request.Sentries) +} From 6238c58cb024ad6fb48ef75d93ace6305601bedc Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:17 -0300 Subject: [PATCH 17/41] feat(cli): inspect v3 contract state Show the new application and consensus fields in contract inspection. Read tournament descriptors, standing, commitments, and match state through the v3 contract views. Keep formatting and inspector tests with this command adaptation so the CLI representation can be reviewed separately from the node observer. --- cmd/cartesi-rollups-cli/root/contract/app.go | 9 +- .../root/contract/commitment.go | 51 ++- .../root/contract/consensus.go | 39 ++- .../root/contract/contract.go | 6 +- .../root/contract/format.go | 34 ++ .../root/contract/format_test.go | 18 ++ .../root/contract/inputbox.go | 56 ++-- .../root/contract/inputbox_test.go | 40 +++ .../root/contract/match.go | 184 ++++++++--- .../root/contract/match_test.go | 78 +++++ .../root/contract/sentries_test.go | 257 +++++++++++++++ .../root/contract/summary.go | 102 ++++-- .../root/contract/tournament.go | 305 ++++++++++++------ .../root/contract/tournament_test.go | 186 ++++++++++- .../root/contract/types.go | 154 +++++---- 15 files changed, 1237 insertions(+), 282 deletions(-) create mode 100644 cmd/cartesi-rollups-cli/root/contract/inputbox_test.go create mode 100644 cmd/cartesi-rollups-cli/root/contract/match_test.go create mode 100644 cmd/cartesi-rollups-cli/root/contract/sentries_test.go diff --git a/cmd/cartesi-rollups-cli/root/contract/app.go b/cmd/cartesi-rollups-cli/root/contract/app.go index e1d6a6990..23b4536e2 100644 --- a/cmd/cartesi-rollups-cli/root/contract/app.go +++ b/cmd/cartesi-rollups-cli/root/contract/app.go @@ -4,7 +4,6 @@ package contract import ( - "encoding/hex" "encoding/json" "fmt" "log/slog" @@ -87,9 +86,9 @@ func (c *chainClient) queryApp() (*AppResult, error) { return nil, fmt.Errorf("GetOutputsMerkleRootValidator: %w", err) } - dataAvailability, err := app.GetDataAvailability(c.callOpts) + inputBox, err := app.GetInputBox(c.callOpts) if err != nil { - return nil, fmt.Errorf("GetDataAvailability: %w", err) + return nil, fmt.Errorf("GetInputBox: %w", err) } isForeclosed, err := app.IsForeclosed(c.callOpts) @@ -126,7 +125,7 @@ func (c *chainClient) queryApp() (*AppResult, error) { ExecutedOutputs: executedOutputs, ConsensusAddress: formatAddr(consensusAddr), ConsensusType: consensusLabel, - DataAvailability: "0x" + hex.EncodeToString(dataAvailability), + InputBox: formatAddr(inputBox), IsForeclosed: isForeclosed, Guardian: formatAddr(wc.Guardian), WithdrawalOutputBuilder: formatAddr(wc.WithdrawalOutputBuilder), @@ -152,7 +151,7 @@ func printAppFields(p *printer, r *AppResult) { p.field("Deployment Block", fmt.Sprintf("%d", r.DeploymentBlock)) p.field("Executed Outputs", fmt.Sprintf("%d", r.ExecutedOutputs)) p.field("Consensus", fmt.Sprintf("%s (%s)", r.ConsensusAddress, r.ConsensusType)) - p.field("Data Availability", r.DataAvailability) + p.field("InputBox", r.InputBox) p.field("Foreclosed", formatBool(r.IsForeclosed)) // WithdrawalConfig is logically grouped — a zero guardian means // no foreclosure was configured on deploy, so other fields are diff --git a/cmd/cartesi-rollups-cli/root/contract/commitment.go b/cmd/cartesi-rollups-cli/root/contract/commitment.go index ce35527ad..19c97413f 100644 --- a/cmd/cartesi-rollups-cli/root/contract/commitment.go +++ b/cmd/cartesi-rollups-cli/root/contract/commitment.go @@ -48,29 +48,42 @@ func runCommitment(cmd *cobra.Command, args []string) error { return fmt.Errorf("bind ITournament: %w", err) } - commitment, err := caller.GetCommitment(cc.callOpts, [32]byte(commitmentHash)) + commitment, err := caller.CommitmentStanding(cc.callOpts, [32]byte(commitmentHash)) if err != nil { - return fmt.Errorf("GetCommitment: %w", err) + return fmt.Errorf("CommitmentStanding: %w", err) } - levelConsts, err := caller.TournamentLevelConstants(cc.callOpts) + descriptor, err := caller.TournamentDescriptor(cc.callOpts) if err != nil { - return fmt.Errorf("TournamentLevelConstants: %w", err) + return fmt.Errorf("TournamentDescriptor: %w", err) + } + maxLevel, err := cc.tournamentMaxLevel() + if err != nil { + return err + } + if descriptor.Level > maxLevel { + return fmt.Errorf("tournament level %d exceeds maximum level %d", descriptor.Level, maxLevel) } - levelName := "root" - if levelConsts.Level == levelConsts.MaxLevel { - levelName = "leaf" - } else if levelConsts.Level > 0 { - levelName = "inner" + blocksRemaining := uint64(0) + if commitment.ClockRunning && commitment.ClockDeadline > cc.blockNum { + blocksRemaining = commitment.ClockDeadline - cc.blockNum + } + claimer := "" + if commitment.Claimer != (common.Address{}) { + claimer = formatAddr(commitment.Claimer) } result := &CommitmentResult{ Commitment: formatHash([32]byte(commitmentHash)), Tournament: formatAddr(tournamentAddr), - TournamentLevel: fmt.Sprintf("%d/%d (%s)", levelConsts.Level, levelConsts.MaxLevel, levelName), - ClockAllowance: commitment.Clock.Allowance, - ClockStartBlock: commitment.Clock.StartInstant, + TournamentLevel: fmt.Sprintf("%d/%d (%s)", descriptor.Level, maxLevel, tournamentLevelName(descriptor.Level, descriptor.Kind)), + Joined: commitment.Joined, + Claimer: claimer, + ClockRunning: commitment.ClockRunning, + ClockAllowance: commitment.ClockAllowance, + ClockDeadline: commitment.ClockDeadline, + BlocksRemaining: blocksRemaining, FinalMachineHash: formatHash(commitment.FinalState), } @@ -82,12 +95,18 @@ func runCommitment(cmd *cobra.Command, args []string) error { p.withSection(fmt.Sprintf("Commitment %s", result.Commitment), func() { p.field("Tournament", fmt.Sprintf("%s (level %s)", result.Tournament, result.TournamentLevel)) + p.field("Joined", formatBool(result.Joined)) + if result.Claimer != "" { + p.field("Claimer", result.Claimer) + } + p.field("Clock Running", formatBool(result.ClockRunning)) p.field("Clock Allowance", - fmt.Sprintf("%d blocks remaining", result.ClockAllowance)) - if result.ClockStartBlock > 0 { - p.field("Clock Start", fmt.Sprintf("block %d", result.ClockStartBlock)) + fmt.Sprintf("%d blocks (raw allowance)", result.ClockAllowance)) + if result.ClockRunning { + p.field("Clock Deadline", fmt.Sprintf("block %d (inclusive)", result.ClockDeadline)) + p.field("Blocks Remaining", fmt.Sprintf("%d", result.BlocksRemaining)) } else { - p.field("Clock Start", "not started") + p.field("Clock Deadline", "paused") } p.field("Final Machine Hash", result.FinalMachineHash) }) diff --git a/cmd/cartesi-rollups-cli/root/contract/consensus.go b/cmd/cartesi-rollups-cli/root/contract/consensus.go index f162e9114..dea3c849d 100644 --- a/cmd/cartesi-rollups-cli/root/contract/consensus.go +++ b/cmd/cartesi-rollups-cli/root/contract/consensus.go @@ -126,27 +126,58 @@ func (c *chainClient) printDave(addr common.Address) error { p.field("Input Range", fmt.Sprintf("[%d, %d)", result.InputLowerBound, result.InputUpperBound)) p.field("Root Tournament", result.RootTournament) + printDaveSentries(p, result) + printDaveStaging(p, result) }) p.footer(c.blockNum, c.chainID, c.resolveTimestamp(c.blockNum)) return nil } -// printTournamentFinished renders the IsFinished field, distinguishing winner from no-winner. -// Note: IsFinished means the tournament has concluded, NOT that settle() can be called -// successfully — settle() will revert if there is no winner. +func printDaveSentries(p *printer, r *DaveConsensusResult) { + p.field("Sentry Manager", r.SentryManager) + p.field("Sentries", fmt.Sprintf("%d", r.NumSentries)) + for _, sentry := range r.Sentries { + p.field(fmt.Sprintf(" Sentry #%d", sentry.ID), sentry.Address) + } +} + +// printTournamentFinished renders the IsFinished field and distinguishes a +// winner from a failed tournament. A failed tournament has no result to stage. func printTournamentFinished(p *printer, r *DaveConsensusResult) { if !r.IsFinished { p.field("Tournament Finished", "no") return } - if r.HasWinner != nil && !*r.HasWinner { + if r.IsTournamentFailed { p.field("Tournament Finished", "yes (NO WINNER — all commitments eliminated)") return } if r.WinnerCommitment != "" { p.field("Tournament Finished", fmt.Sprintf("yes (winner: %s)", r.WinnerCommitment)) + if r.WinnerPostEpochMachineHash != "" { + p.field("Winner Machine Hash", r.WinnerPostEpochMachineHash) + } return } p.field("Tournament Finished", "yes") } + +func printDaveStaging(p *printer, r *DaveConsensusResult) { + p.field("Claim Staging Period", fmt.Sprintf("%d blocks", r.ClaimStagingPeriod)) + p.field("Tournament Result Staged", formatBool(r.IsTournamentResultStaged)) + if !r.IsTournamentResultStaged { + return + } + if r.StagingBlock != nil { + p.field("Staging Block", fmt.Sprintf("%d", *r.StagingBlock)) + } + p.field("Staged Machine Hash", r.StagedMachineHash) + p.field("Staged Outputs Root", r.StagedOutputsMerkleRoot) + if r.AllSentriesAgree != nil { + p.field("All Sentries Agree", formatBool(*r.AllSentriesAgree)) + } + if r.ClaimStagingPeriodOver != nil { + p.field("Staging Period Over", formatBool(*r.ClaimStagingPeriodOver)) + } +} diff --git a/cmd/cartesi-rollups-cli/root/contract/contract.go b/cmd/cartesi-rollups-cli/root/contract/contract.go index 1c0420e70..ea20f74db 100644 --- a/cmd/cartesi-rollups-cli/root/contract/contract.go +++ b/cmd/cartesi-rollups-cli/root/contract/contract.go @@ -45,8 +45,7 @@ directly from on-chain contracts and may not reflect finalized state. Verify critical information independently before acting on it. Supported Environment Variables: - CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum RPC endpoint URL - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS InputBox contract address (optional override)`, + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum RPC endpoint URL`, Run: runContract, } @@ -66,9 +65,6 @@ func init() { if f := command.Flags().Lookup("blockchain-http-endpoint"); f != nil { f.Hidden = false } - if f := command.Flags().Lookup("inputbox"); f != nil { - f.Hidden = false - } origHelpFunc(command, args) }) diff --git a/cmd/cartesi-rollups-cli/root/contract/format.go b/cmd/cartesi-rollups-cli/root/contract/format.go index 0d6442cee..0927dec06 100644 --- a/cmd/cartesi-rollups-cli/root/contract/format.go +++ b/cmd/cartesi-rollups-cli/root/contract/format.go @@ -109,6 +109,40 @@ func matchWinner(winner uint8) string { } } +const ( + matchPhaseUninitialized uint8 = iota + matchPhaseBisecting + matchPhaseReadyToSeal + matchPhaseSealed +) + +func matchPhaseName(phase uint8) string { + names := [...]string{"UNINITIALIZED", "BISECTING", "READY_TO_SEAL", "SEALED"} + if int(phase) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", phase) + } + return names[phase] +} + +func matchTimeoutOutcomeName(outcome uint8) string { + names := [...]string{"NONE", "ONE_WINS", "TWO_WINS", "ELIMINATE_BOTH"} + if int(outcome) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", outcome) + } + return names[outcome] +} + +func commitmentSideName(side uint8) string { + switch side { + case 0: + return "ONE" + case 1: + return "TWO" + default: + return fmt.Sprintf("UNKNOWN(%d)", side) + } +} + // formatBlockTime returns " (2006-01-02 15:04:05 UTC)" if ts > 0, else "". func formatBlockTime(ts uint64) string { if ts == 0 { diff --git a/cmd/cartesi-rollups-cli/root/contract/format_test.go b/cmd/cartesi-rollups-cli/root/contract/format_test.go index a5e528d9d..1b753cf74 100644 --- a/cmd/cartesi-rollups-cli/root/contract/format_test.go +++ b/cmd/cartesi-rollups-cli/root/contract/format_test.go @@ -132,6 +132,24 @@ func TestMatchWinner(t *testing.T) { assert.Equal(t, "UNKNOWN(99)", matchWinner(99)) } +func TestMatchEnumNamesMatchSolidityOrder(t *testing.T) { + assert.Equal(t, "UNINITIALIZED", matchPhaseName(0)) + assert.Equal(t, "BISECTING", matchPhaseName(1)) + assert.Equal(t, "READY_TO_SEAL", matchPhaseName(2)) + assert.Equal(t, "SEALED", matchPhaseName(3)) + assert.Equal(t, "UNKNOWN(4)", matchPhaseName(4)) + + assert.Equal(t, "NONE", matchTimeoutOutcomeName(0)) + assert.Equal(t, "ONE_WINS", matchTimeoutOutcomeName(1)) + assert.Equal(t, "TWO_WINS", matchTimeoutOutcomeName(2)) + assert.Equal(t, "ELIMINATE_BOTH", matchTimeoutOutcomeName(3)) + assert.Equal(t, "UNKNOWN(4)", matchTimeoutOutcomeName(4)) + + assert.Equal(t, "ONE", commitmentSideName(0)) + assert.Equal(t, "TWO", commitmentSideName(1)) + assert.Equal(t, "UNKNOWN(2)", commitmentSideName(2)) +} + func TestFormatBlockTime(t *testing.T) { assert.Equal(t, "", formatBlockTime(0)) // Unix timestamp 1700000000 = 2023-11-14 22:13:20 UTC diff --git a/cmd/cartesi-rollups-cli/root/contract/inputbox.go b/cmd/cartesi-rollups-cli/root/contract/inputbox.go index aafdcfecf..72b624c2c 100644 --- a/cmd/cartesi-rollups-cli/root/contract/inputbox.go +++ b/cmd/cartesi-rollups-cli/root/contract/inputbox.go @@ -7,9 +7,9 @@ import ( "fmt" "os" - "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" ) @@ -50,7 +50,6 @@ func runInputBox(cmd *cobra.Command, args []string) error { } // queryInputBox returns the InputBox state for the application. -// The InputBox address is auto-discovered from DaveConsensus or provided via --inputbox flag. func (c *chainClient) queryInputBox() (*InputBoxResult, error) { inputBoxAddr, err := c.resolveInputBoxAddress() if err != nil { @@ -81,43 +80,26 @@ func (c *chainClient) queryInputBox() (*InputBoxResult, error) { }, nil } -// resolveInputBoxAddress discovers the InputBox address. -// Priority: (1) --inputbox flag / env var, (2) DaveConsensus.GetInputBox(). -func (c *chainClient) resolveInputBoxAddress() (common.Address, error) { - // Try config (--inputbox flag or CARTESI_CONTRACTS_INPUT_BOX_ADDRESS env var). - addr, err := config.GetContractsInputBoxAddress() - if err == nil && addr != (common.Address{}) { - return addr, nil - } +type applicationInputBoxCaller interface { + GetInputBox(opts *bind.CallOpts) (common.Address, error) +} - // Try auto-discovery from DaveConsensus. - consensusAddr, cErr := c.getConsensusAddress() - if cErr != nil { - return common.Address{}, fmt.Errorf( - "cannot determine InputBox address: no --inputbox flag and consensus lookup failed: %w", - cErr) +// resolveInputBoxAddress reads the InputBox selected by this application. +func (c *chainClient) resolveInputBoxAddress() (common.Address, error) { + app, err := iapplication.NewIApplicationCaller(c.appAddr, c.eth) + if err != nil { + return common.Address{}, fmt.Errorf("bind IApplication for InputBox discovery: %w", err) } + return readApplicationInputBox(app, c.callOpts) +} - cType, _, cErr := c.detectConsensus(consensusAddr) - if cErr != nil { - return common.Address{}, fmt.Errorf( - "cannot determine InputBox address: no --inputbox flag and consensus detection failed: %w", - cErr) +func readApplicationInputBox(caller applicationInputBoxCaller, opts *bind.CallOpts) (common.Address, error) { + inputBox, err := caller.GetInputBox(opts) + if err != nil { + return common.Address{}, fmt.Errorf("IApplication.GetInputBox: %w", err) } - - if cType == consensusDave { - daveCaller, dErr := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) - if dErr != nil { - return common.Address{}, fmt.Errorf("bind IDaveConsensus for InputBox discovery: %w", dErr) - } - inputBox, dErr := daveCaller.GetInputBox(c.callOpts) - if dErr != nil { - return common.Address{}, fmt.Errorf("IDaveConsensus.GetInputBox: %w", dErr) - } - return inputBox, nil + if inputBox == (common.Address{}) { + return common.Address{}, fmt.Errorf("IApplication.GetInputBox returned the zero address") } - - return common.Address{}, fmt.Errorf( - "cannot auto-discover InputBox address for %s consensus; "+ - "use --inputbox flag or set CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", cType) + return inputBox, nil } diff --git a/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go b/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go new file mode 100644 index 000000000..7de56723a --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go @@ -0,0 +1,40 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type applicationInputBoxCallerStub struct { + inputBox common.Address + err error +} + +func (s applicationInputBoxCallerStub) GetInputBox(*bind.CallOpts) (common.Address, error) { + return s.inputBox, s.err +} + +func TestReadApplicationInputBox(t *testing.T) { + want := common.HexToAddress("0x1234567890123456789012345678901234567890") + got, err := readApplicationInputBox(applicationInputBoxCallerStub{inputBox: want}, nil) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestReadApplicationInputBoxRejectsZeroAddress(t *testing.T) { + _, err := readApplicationInputBox(applicationInputBoxCallerStub{}, nil) + require.ErrorContains(t, err, "zero address") +} + +func TestReadApplicationInputBoxReturnsCallError(t *testing.T) { + wantErr := errors.New("call failed") + _, err := readApplicationInputBox(applicationInputBoxCallerStub{err: wantErr}, nil) + require.ErrorIs(t, err, wantErr) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/match.go b/cmd/cartesi-rollups-cli/root/contract/match.go index 70939ac11..419e863b3 100644 --- a/cmd/cartesi-rollups-cli/root/contract/match.go +++ b/cmd/cartesi-rollups-cli/root/contract/match.go @@ -20,7 +20,7 @@ import ( var matchCmd = &cobra.Command{ Use: "match ", - Short: "Inspect a specific match's bisection state", + Short: "Inspect a specific tournament match", Args: cobra.ExactArgs(2), //nolint:mnd RunE: runMatch, } @@ -54,29 +54,31 @@ func runMatch(cmd *cobra.Command, args []string) error { return fmt.Errorf("bind ITournament: %w", err) } - matchState, err := caller.GetMatch(cc.callOpts, [32]byte(matchIDHash)) + bisecting, err := caller.BisectingMatch(cc.callOpts, [32]byte(matchIDHash)) if err != nil { - return fmt.Errorf("GetMatch: %w", err) - } - - cycle, err := caller.GetMatchCycle(cc.callOpts, [32]byte(matchIDHash)) - if err != nil { - return fmt.Errorf("GetMatchCycle: %w", err) + return fmt.Errorf("BisectingMatch: %w", err) } // Discover commitment hashes for this match from MatchCreated events. - // We need the two commitment hashes to call CanWinMatchByTimeout and to show players. + // The timeout view takes the full match ID. The event also identifies the players. commitOne, commitTwo, lookupErr := cc.findMatchCommitments( tournamentAddr, deployBlock, [32]byte(matchIDHash)) if lookupErr != nil { - slog.Warn("failed to look up match commitments", "error", lookupErr) + return fmt.Errorf("look up MatchCreated commitments: %w", lookupErr) } - // Attempt CanWinMatchByTimeout if we have both commitments. - var canWinByTimeout bool - if commitOne != ([32]byte{}) && commitTwo != ([32]byte{}) { - canWinByTimeout, _ = caller.CanWinMatchByTimeout(cc.callOpts, - itournament.MatchId{CommitmentOne: commitOne, CommitmentTwo: commitTwo}) + timeout, err := caller.ClassifyMatchTimeout(cc.callOpts, + itournament.MatchId{CommitmentOne: commitOne, CommitmentTwo: commitTwo}) + if err != nil { + return fmt.Errorf("ClassifyMatchTimeout: %w", err) + } + if timeout.ActualPhase != bisecting.ActualPhase { + return fmt.Errorf( + "inconsistent match phase at pinned block %d: BisectingMatch=%s, ClassifyMatchTimeout=%s", + cc.blockNum, + matchPhaseName(bisecting.ActualPhase), + matchPhaseName(timeout.ActualPhase), + ) } // Build commitment registry for player address resolution. @@ -93,19 +95,48 @@ func runMatch(cmd *cobra.Command, args []string) error { } result := &MatchResult{ - MatchIDHash: formatHash([32]byte(matchIDHash)), - Tournament: formatAddr(tournamentAddr), - CommitmentOne: formatHash(commitOne), - CommitmentTwo: formatHash(commitTwo), - PlayerOneAddr: registry.resolve(commitOne), - PlayerTwoAddr: registry.resolve(commitTwo), - CurrentHeight: matchState.CurrentHeight, - RunningLeafPosition: matchState.RunningLeafPosition.String(), - MachineCycle: cycle.String(), - CanWinByTimeout: canWinByTimeout, - LeftNode: formatHash(matchState.LeftNode), - RightNode: formatHash(matchState.RightNode), - OtherParent: formatHash(matchState.OtherParent), + MatchIDHash: formatHash([32]byte(matchIDHash)), + Tournament: formatAddr(tournamentAddr), + CommitmentOne: formatHash(commitOne), + CommitmentTwo: formatHash(commitTwo), + PlayerOneAddr: registry.resolve(commitOne), + PlayerTwoAddr: registry.resolve(commitTwo), + ActualPhase: matchPhaseName(bisecting.ActualPhase), + TimeoutOutcome: matchTimeoutOutcomeName(timeout.Outcome), + DeferredCharge: timeout.DeferredCharge, + } + + switch bisecting.ActualPhase { + case matchPhaseUninitialized: + // No phase payload exists. + case matchPhaseBisecting: + if err := populateBisectingMatchResult(result, bisecting.Value); err != nil { + return err + } + case matchPhaseReadyToSeal: + ready, rErr := caller.ReadyToSealMatch(cc.callOpts, [32]byte(matchIDHash)) + if rErr != nil { + return fmt.Errorf("ReadyToSealMatch: %w", rErr) + } + if ready.ActualPhase != bisecting.ActualPhase { + return inconsistentMatchPhaseError(cc.blockNum, "ReadyToSealMatch", ready.ActualPhase, bisecting.ActualPhase) + } + if err := populateReadyToSealMatchResult(result, ready.Value); err != nil { + return err + } + case matchPhaseSealed: + sealed, sErr := caller.SealedMatch(cc.callOpts, [32]byte(matchIDHash)) + if sErr != nil { + return fmt.Errorf("SealedMatch: %w", sErr) + } + if sealed.ActualPhase != bisecting.ActualPhase { + return inconsistentMatchPhaseError(cc.blockNum, "SealedMatch", sealed.ActualPhase, bisecting.ActualPhase) + } + if err := populateSealedMatchResult(result, sealed.Value); err != nil { + return err + } + default: + return fmt.Errorf("unknown match phase %d", bisecting.ActualPhase) } if jsonParam { @@ -113,20 +144,89 @@ func runMatch(cmd *cobra.Command, args []string) error { } p := &printer{w: os.Stdout} + printMatchResult(p, result) + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} + +func populateBisectingMatchResult(result *MatchResult, value itournament.ITournamentBisectingMatchView) error { + if value.SegmentStartPosition == nil || value.SegmentStartCycle == nil { + return errors.New("BisectingMatch returned nil position or cycle in BISECTING phase") + } + currentHeight := value.CurrentHeight + result.CurrentHeight = ¤tHeight + result.SegmentStartPosition = value.SegmentStartPosition.String() + result.SegmentStartCycle = value.SegmentStartCycle.String() + result.RevealingParent = formatHash(value.RevealingParent) + result.WaitingLeft = formatHash(value.WaitingLeft) + result.WaitingRight = formatHash(value.WaitingRight) + result.Responder = commitmentSideName(value.Responder) + return nil +} + +func populateReadyToSealMatchResult(result *MatchResult, value itournament.ITournamentReadyToSealMatchView) error { + if value.SegmentStartPosition == nil || value.SegmentStartCycle == nil { + return errors.New("ReadyToSealMatch returned nil position or cycle in READY_TO_SEAL phase") + } + result.SegmentStartPosition = value.SegmentStartPosition.String() + result.SegmentStartCycle = value.SegmentStartCycle.String() + result.RevealingParent = formatHash(value.RevealingParent) + result.WaitingLeft = formatHash(value.WaitingLeft) + result.WaitingRight = formatHash(value.WaitingRight) + result.Responder = commitmentSideName(value.Responder) + return nil +} + +func populateSealedMatchResult(result *MatchResult, value itournament.ITournamentSealedMatchView) error { + if value.DivergencePosition == nil || value.DivergenceCycle == nil { + return errors.New("SealedMatch returned nil position or cycle in SEALED phase") + } + result.AgreeState = formatHash(value.AgreeState) + result.DivergencePosition = value.DivergencePosition.String() + result.DivergenceCycle = value.DivergenceCycle.String() + result.FinalStateOne = formatHash(value.FinalStateOne) + result.FinalStateTwo = formatHash(value.FinalStateTwo) + return nil +} + +func inconsistentMatchPhaseError(block uint64, view string, got, want uint8) error { + return fmt.Errorf( + "inconsistent match phase at pinned block %d: %s=%s, expected %s", + block, + view, + matchPhaseName(got), + matchPhaseName(want), + ) +} + +func printMatchResult(p *printer, result *MatchResult) { p.withSection(fmt.Sprintf("Match %s", result.MatchIDHash), func() { p.field("Tournament", result.Tournament) printMatchPlayer(p, "Player One", result.CommitmentOne, result.PlayerOneAddr) printMatchPlayer(p, "Player Two", result.CommitmentTwo, result.PlayerTwoAddr) - p.field("Current Height", fmt.Sprintf("%d", result.CurrentHeight)) - p.field("Running Leaf Pos", result.RunningLeafPosition) - p.field("Machine Cycle", result.MachineCycle) - p.field("Can Win by Timeout", fmt.Sprintf("%t", result.CanWinByTimeout)) - p.field("Left Node", result.LeftNode) - p.field("Right Node", result.RightNode) - p.field("Other Parent", result.OtherParent) + p.field("Actual Phase", result.ActualPhase) + p.field("Timeout Outcome", result.TimeoutOutcome) + p.field("Deferred Charge", fmt.Sprintf("%d blocks", result.DeferredCharge)) + + switch result.ActualPhase { + case matchPhaseName(matchPhaseBisecting), matchPhaseName(matchPhaseReadyToSeal): + if result.CurrentHeight != nil { + p.field("Current Height", fmt.Sprintf("%d", *result.CurrentHeight)) + } + p.field("Segment Start Position", result.SegmentStartPosition) + p.field("Segment Start Cycle", result.SegmentStartCycle) + p.field("Revealing Parent", result.RevealingParent) + p.field("Waiting Left", result.WaitingLeft) + p.field("Waiting Right", result.WaitingRight) + p.field("Responder", result.Responder) + case matchPhaseName(matchPhaseSealed): + p.field("Agree State", result.AgreeState) + p.field("Divergence Position", result.DivergencePosition) + p.field("Divergence Cycle", result.DivergenceCycle) + p.field("Final State One", result.FinalStateOne) + p.field("Final State Two", result.FinalStateTwo) + } }) - p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) - return nil } func printMatchPlayer(p *printer, label, commitment, addr string) { @@ -154,6 +254,7 @@ func (c *chainClient) findMatchCommitments( } var commitOne, commitTwo [32]byte + found := false oracle := func(ctx context.Context, block uint64) (*big.Int, error) { opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} @@ -183,6 +284,7 @@ func (c *chainClient) findMatchCommitments( if ev.MatchIdHash == matchIDHash { commitOne = ev.One commitTwo = ev.Two + found = true return errFound } } @@ -195,5 +297,13 @@ func (c *chainClient) findMatchCommitments( if err != nil && !errors.Is(err, errFound) { return [32]byte{}, [32]byte{}, fmt.Errorf("find match commitments: %w", err) } + if !found { + return [32]byte{}, [32]byte{}, fmt.Errorf( + "MatchCreated event %s was not found between blocks %d and %d", + formatHash(matchIDHash), + deployBlock, + c.blockNum, + ) + } return commitOne, commitTwo, nil } diff --git a/cmd/cartesi-rollups-cli/root/contract/match_test.go b/cmd/cartesi-rollups-cli/root/contract/match_test.go new file mode 100644 index 000000000..05f9724c7 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/match_test.go @@ -0,0 +1,78 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPopulateBisectingMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentBisectingMatchView{ + RevealingParent: [32]byte{0x01}, + WaitingLeft: [32]byte{0x02}, + WaitingRight: [32]byte{0x03}, + SegmentStartPosition: big.NewInt(4), + SegmentStartCycle: big.NewInt(5), + CurrentHeight: 6, + Responder: 1, + } + + require.NoError(t, populateBisectingMatchResult(result, value)) + require.NotNil(t, result.CurrentHeight) + assert.Equal(t, uint64(6), *result.CurrentHeight) + assert.Equal(t, "4", result.SegmentStartPosition) + assert.Equal(t, "5", result.SegmentStartCycle) + assert.Equal(t, formatHash(value.RevealingParent), result.RevealingParent) + assert.Equal(t, formatHash(value.WaitingLeft), result.WaitingLeft) + assert.Equal(t, formatHash(value.WaitingRight), result.WaitingRight) + assert.Equal(t, "TWO", result.Responder) +} + +func TestPopulateReadyToSealMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentReadyToSealMatchView{ + RevealingParent: [32]byte{0x01}, + WaitingLeft: [32]byte{0x02}, + WaitingRight: [32]byte{0x03}, + SegmentStartPosition: big.NewInt(7), + SegmentStartCycle: big.NewInt(8), + Responder: 0, + } + + require.NoError(t, populateReadyToSealMatchResult(result, value)) + assert.Nil(t, result.CurrentHeight) + assert.Equal(t, "7", result.SegmentStartPosition) + assert.Equal(t, "8", result.SegmentStartCycle) + assert.Equal(t, "ONE", result.Responder) +} + +func TestPopulateSealedMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentSealedMatchView{ + AgreeState: [32]byte{0x01}, + DivergencePosition: big.NewInt(9), + DivergenceCycle: big.NewInt(10), + FinalStateOne: [32]byte{0x02}, + FinalStateTwo: [32]byte{0x03}, + } + + require.NoError(t, populateSealedMatchResult(result, value)) + assert.Equal(t, formatHash(value.AgreeState), result.AgreeState) + assert.Equal(t, "9", result.DivergencePosition) + assert.Equal(t, "10", result.DivergenceCycle) + assert.Equal(t, formatHash(value.FinalStateOne), result.FinalStateOne) + assert.Equal(t, formatHash(value.FinalStateTwo), result.FinalStateTwo) +} + +func TestMatchPayloadRejectsNilPositions(t *testing.T) { + assert.Error(t, populateBisectingMatchResult(&MatchResult{}, itournament.ITournamentBisectingMatchView{})) + assert.Error(t, populateReadyToSealMatchResult(&MatchResult{}, itournament.ITournamentReadyToSealMatchView{})) + assert.Error(t, populateSealedMatchResult(&MatchResult{}, itournament.ITournamentSealedMatchView{})) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/sentries_test.go b/cmd/cartesi-rollups-cli/root/contract/sentries_test.go new file mode 100644 index 000000000..b91de37fc --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/sentries_test.go @@ -0,0 +1,257 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "os" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" +) + +const getSentryByIDMethod = "getSentryById" + +type sentryInspectionRPC struct{ mock.Mock } + +func (m *sentryInspectionRPC) GetCode(_ context.Context, address common.Address, block rpc.BlockNumber) hexutil.Bytes { + args := m.Called(address, block) + return args.Get(0).([]byte) +} + +func (m *sentryInspectionRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +type sentryInspectionFixture struct { + manager common.Address + sentries []common.Address + count *big.Int + failMethod string + failID uint64 +} + +func newSentryInspectionClient(t *testing.T, fixture sentryInspectionFixture) (*chainClient, common.Address) { + t.Helper() + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x100") + block := rpc.BlockNumber(1234) + backend := &sentryInspectionRPC{} + backend.On("GetCode", address, block).Return([]byte{0x01}).Once() + expectCall := func(name string, inputs, outputs []any) { + t.Helper() + input, packErr := contractABI.Pack(name, inputs...) + require.NoError(t, packErr) + var encoded []byte + var callErr error + if name == fixture.failMethod && (name != getSentryByIDMethod || inputs[0].(*big.Int).Uint64() == fixture.failID) { + callErr = errors.New("RPC read failed") + } else { + encoded, packErr = contractABI.Methods[name].Outputs.Pack(outputs...) + require.NoError(t, packErr) + } + backend.On("Call", address, input, block).Return(encoded, callErr).Once() + } + if fixture.count == nil { + fixture.count = big.NewInt(int64(len(fixture.sentries))) + } + for _, call := range []struct { + name string + values []any + }{ + {"canStageTournamentResult", []any{false, false, false, big.NewInt(1), [32]byte{}, [32]byte{}}}, + {"getCurrentSealedEpoch", []any{ + big.NewInt(1), big.NewInt(2), big.NewInt(3), common.HexToAddress("0x200"), false, big.NewInt(0), [32]byte{}, [32]byte{}, + }}, + {"getDeploymentBlockNumber", []any{big.NewInt(100)}}, + {"getInputBox", []any{common.HexToAddress("0x300")}}, + {"getTournamentFactory", []any{common.HexToAddress("0x400")}}, + {"getClaimStagingPeriod", []any{big.NewInt(300)}}, + {"getSentryManager", []any{fixture.manager}}, + {"getNumberOfSentries", []any{fixture.count}}, + } { + expectCall(call.name, nil, call.values) + if call.name == fixture.failMethod { + break + } + } + if fixture.failMethod == "" || fixture.failMethod == getSentryByIDMethod { + for index, sentry := range fixture.sentries { + id := uint64(index + 1) + expectCall(getSentryByIDMethod, []any{new(big.Int).SetUint64(id)}, []any{sentry}) + if sentry == (common.Address{}) || (fixture.failMethod == getSentryByIDMethod && fixture.failID == id) { + break + } + } + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + t.Cleanup(func() { backend.AssertExpectations(t) }) + return &chainClient{ + eth: client, + callOpts: &bind.CallOpts{Context: t.Context(), BlockNumber: big.NewInt(int64(block))}, + blockNum: uint64(block), + chainID: 31337, + }, address +} + +func TestQueryDaveSentriesAtPinnedBlock(t *testing.T) { + manager := common.HexToAddress("0x500") + first := common.HexToAddress("0x501") + second := common.HexToAddress("0x502") + rotated := common.HexToAddress("0x503") + for _, test := range []struct { + name string + manager common.Address + sentries []common.Address + }{ + {name: "no sentries"}, + {name: "two sentries", manager: manager, sentries: []common.Address{first, second}}, + {name: "rotated first slot", manager: manager, sentries: []common.Address{rotated, second}}, + } { + t.Run(test.name, func(t *testing.T) { + client, address := newSentryInspectionClient(t, sentryInspectionFixture{manager: test.manager, sentries: test.sentries}) + result, err := client.queryDave(address) + require.NoError(t, err) + require.Equal(t, test.manager.Hex(), result.SentryManager) + require.EqualValues(t, len(test.sentries), result.NumSentries) + require.NotNil(t, result.Sentries) + require.Len(t, result.Sentries, len(test.sentries)) + for index, sentry := range test.sentries { + require.Equal(t, SentryResult{ID: uint64(index + 1), Address: sentry.Hex()}, result.Sentries[index]) + } + encoded, err := json.Marshal(result) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &fields)) + require.JSONEq(t, fmt.Sprintf("%q", test.manager.Hex()), string(fields["sentry_manager"])) + require.Equal(t, fmt.Sprint(len(test.sentries)), string(fields["num_sentries"])) + if len(test.sentries) == 0 { + require.Equal(t, "[]", string(fields["sentries"])) + } else { + var sentries []SentryResult + require.NoError(t, json.Unmarshal(fields["sentries"], &sentries)) + require.Equal(t, result.Sentries, sentries) + } + var text bytes.Buffer + printConsensusSummary(&printer{w: &text}, consensusResult{result: result}) + require.Contains(t, text.String(), "Sentry Manager "+test.manager.Hex()) + require.Contains(t, text.String(), "Sentries "+fmt.Sprint(len(test.sentries))) + for index, sentry := range test.sentries { + require.Contains(t, text.String(), fmt.Sprintf(" Sentry #%d %s", index+1, sentry.Hex())) + } + }) + } +} + +func TestQueryDaveRejectsIncompleteSentryRoster(t *testing.T) { + for _, test := range []struct { + name string + fixture sentryInspectionFixture + wantErr string + }{ + {name: "manager read fails", fixture: sentryInspectionFixture{failMethod: "getSentryManager"}, wantErr: "GetSentryManager"}, + {name: "count read fails", fixture: sentryInspectionFixture{failMethod: "getNumberOfSentries"}, wantErr: "GetNumberOfSentries"}, + { + name: "second member read fails", + fixture: sentryInspectionFixture{ + sentries: []common.Address{common.HexToAddress("0x501"), common.HexToAddress("0x502")}, + failMethod: getSentryByIDMethod, failID: 2, + }, + wantErr: "GetSentryById(2)", + }, + { + name: "missing member", + fixture: sentryInspectionFixture{sentries: []common.Address{common.HexToAddress("0x501"), {}}}, + wantErr: "GetSentryById(2): registered sentry has zero address", + }, + { + name: "count exceeds inspection limit", + fixture: sentryInspectionFixture{count: big.NewInt(10001)}, + wantErr: "number of sentries 10001 exceeds inspection limit 10000", + }, + { + name: "count exceeds uint64", + fixture: sentryInspectionFixture{count: new(big.Int).Lsh(big.NewInt(1), 64)}, + wantErr: "number of sentries", + }, + } { + t.Run(test.name, func(t *testing.T) { + client, address := newSentryInspectionClient(t, test.fixture) + result, err := client.queryDave(address) + require.ErrorContains(t, err, test.wantErr) + require.Nil(t, result, "a failed member read must not expose a partial roster") + }) + } +} + +func TestPrintDaveIncludesSentryRoster(t *testing.T) { + previousJSON := jsonParam + t.Cleanup(func() { jsonParam = previousJSON }) + manager := common.HexToAddress("0x500") + sentry := common.HexToAddress("0x501") + for _, asJSON := range []bool{false, true} { + t.Run(fmt.Sprintf("json=%t", asJSON), func(t *testing.T) { + client, address := newSentryInspectionClient(t, sentryInspectionFixture{manager: manager, sentries: []common.Address{sentry}}) + jsonParam = asJSON + output := captureSentryInspectionOutput(t, func() { require.NoError(t, client.printDave(address)) }) + if asJSON { + var result DaveConsensusResult + require.NoError(t, json.Unmarshal([]byte(output), &result)) + require.Equal(t, manager.Hex(), result.SentryManager) + require.EqualValues(t, 1, result.NumSentries) + require.Equal(t, []SentryResult{{ID: 1, Address: sentry.Hex()}}, result.Sentries) + return + } + require.Contains(t, output, "Sentry Manager "+manager.Hex()) + require.Contains(t, output, "Sentries 1") + require.Contains(t, output, " Sentry #1 "+sentry.Hex()) + }) + } +} + +func captureSentryInspectionOutput(t *testing.T, fn func()) string { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "stdout") + require.NoError(t, err) + defer file.Close() + previous := os.Stdout + os.Stdout = file + defer func() { os.Stdout = previous }() + fn() + _, err = file.Seek(0, io.SeekStart) + require.NoError(t, err) + output, err := io.ReadAll(file) + require.NoError(t, err) + return string(output) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/summary.go b/cmd/cartesi-rollups-cli/root/contract/summary.go index a54924594..97b7b995f 100644 --- a/cmd/cartesi-rollups-cli/root/contract/summary.go +++ b/cmd/cartesi-rollups-cli/root/contract/summary.go @@ -18,6 +18,8 @@ import ( "golang.org/x/sync/errgroup" ) +const daveConsensusContractName = "DaveConsensus" + var summaryCmd = &cobra.Command{ Use: "summary ", Short: "Full diagnostic snapshot (composes app + consensus + inputbox + root tournament)", @@ -238,6 +240,8 @@ func printConsensusSummary(p *printer, cr consensusResult) { printTournamentFinished(p, r) p.field("Current Sealed Epoch", fmt.Sprintf("%d", r.CurrentEpochNumber)) p.field("Root Tournament", r.RootTournament) + printDaveSentries(p, r) + printDaveStaging(p, r) }) } } @@ -246,7 +250,7 @@ func printConsensusSummary(p *printer, cr consensusResult) { func (c *chainClient) queryAuthority( addr common.Address, contractVersion string, ) (*AuthorityConsensusResult, error) { - if err := c.ensureContract(addr, "Authority"); err != nil { + if err := c.ensureContract(addr, consensusAuthority.String()); err != nil { return nil, err } caller, err := iauthority.NewIAuthorityCaller(addr, c.eth) @@ -296,7 +300,7 @@ func (c *chainClient) queryAuthority( } return &AuthorityConsensusResult{ - Type: "Authority", + Type: consensusAuthority.String(), Address: formatAddr(addr), Owner: formatAddr(owner), EpochLength: epochLength, @@ -311,7 +315,7 @@ func (c *chainClient) queryAuthority( func (c *chainClient) queryQuorum( addr common.Address, contractVersion string, ) (*QuorumConsensusResult, error) { - if err := c.ensureContract(addr, "Quorum"); err != nil { + if err := c.ensureContract(addr, consensusQuorum.String()); err != nil { return nil, err } caller, err := iquorum.NewIQuorumCaller(addr, c.eth) @@ -382,7 +386,7 @@ func (c *chainClient) queryQuorum( } return &QuorumConsensusResult{ - Type: "Quorum", + Type: consensusQuorum.String(), Address: formatAddr(addr), NumValidators: numVal, QuorumThreshold: threshold, @@ -399,7 +403,7 @@ func (c *chainClient) queryQuorum( func (c *chainClient) queryDave( addr common.Address, ) (*DaveConsensusResult, error) { - if err := c.ensureContract(addr, "DaveConsensus"); err != nil { + if err := c.ensureContract(addr, daveConsensusContractName); err != nil { return nil, err } caller, err := idaveconsensus.NewIDaveConsensusCaller(addr, c.eth) @@ -407,9 +411,9 @@ func (c *chainClient) queryDave( return nil, fmt.Errorf("bind IDaveConsensus: %w", err) } - settleInfo, err := caller.CanSettle(c.callOpts) + stageInfo, err := caller.CanStageTournamentResult(c.callOpts) if err != nil { - return nil, fmt.Errorf("CanSettle: %w", err) + return nil, fmt.Errorf("CanStageTournamentResult: %w", err) } sealed, err := caller.GetCurrentSealedEpoch(c.callOpts) @@ -450,25 +454,85 @@ func (c *chainClient) queryDave( if err != nil { return nil, fmt.Errorf("GetTournamentFactory: %w", err) } + stagingPeriodRaw, err := caller.GetClaimStagingPeriod(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetClaimStagingPeriod: %w", err) + } + stagingPeriod, err := safeUint64(stagingPeriodRaw, "claim staging period") + if err != nil { + return nil, err + } result := &DaveConsensusResult{ - Type: "DaveConsensus", - Address: formatAddr(addr), - InputBox: formatAddr(inputBox), - Factory: formatAddr(factory), - DeploymentBlock: deployBlock, - IsFinished: settleInfo.IsFinished, - CurrentEpochNumber: epochNumber, - InputLowerBound: inputLower, - InputUpperBound: inputUpper, - RootTournament: formatAddr(sealed.Tournament), + Type: daveConsensusContractName, + Address: formatAddr(addr), + InputBox: formatAddr(inputBox), + Factory: formatAddr(factory), + DeploymentBlock: deployBlock, + ClaimStagingPeriod: stagingPeriod, + IsFinished: stageInfo.IsFinished, + IsTournamentFailed: stageInfo.IsTournamentFailed, + IsTournamentResultStaged: sealed.IsTournamentResultStaged, + CurrentEpochNumber: epochNumber, + InputLowerBound: inputLower, + InputUpperBound: inputUpper, + RootTournament: formatAddr(sealed.Tournament), + } + sentryManager, err := caller.GetSentryManager(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetSentryManager: %w", err) + } + numSentriesRaw, err := caller.GetNumberOfSentries(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetNumberOfSentries: %w", err) + } + numSentries, err := safeUint64(numSentriesRaw, "number of sentries") + if err != nil { + return nil, err + } + // Bound the work for untrusted contract addresses. Never print a partial roster. + const maxSentries = 10000 + if numSentries > maxSentries { + return nil, fmt.Errorf("number of sentries %d exceeds inspection limit %d", numSentries, maxSentries) + } + result.SentryManager = formatAddr(sentryManager) + result.NumSentries = numSentries + result.Sentries = make([]SentryResult, 0, numSentries) + for id := uint64(1); id <= numSentries; id++ { + sentry, err := caller.GetSentryById(c.callOpts, new(big.Int).SetUint64(id)) + if err != nil { + return nil, fmt.Errorf("GetSentryById(%d): %w", id, err) + } + if sentry == (common.Address{}) { + return nil, fmt.Errorf("GetSentryById(%d): registered sentry has zero address", id) + } + result.Sentries = append(result.Sentries, SentryResult{ID: id, Address: formatAddr(sentry)}) } if result.IsFinished { - hasWinner := settleInfo.WinnerCommitment != [32]byte{} + hasWinner := !stageInfo.IsTournamentFailed && stageInfo.WinnerCommitment != [32]byte{} result.HasWinner = &hasWinner if hasWinner { - result.WinnerCommitment = formatHash(settleInfo.WinnerCommitment) + result.WinnerCommitment = formatHash(stageInfo.WinnerCommitment) + result.WinnerPostEpochMachineHash = formatHash(stageInfo.WinnerPostEpochMachineStateHash) + } + } + if sealed.IsTournamentResultStaged { + stagingBlock, err := safeUint64(sealed.StagingBlockNumber, "staging block") + if err != nil { + return nil, err + } + result.StagingBlock = &stagingBlock + result.StagedMachineHash = formatHash(sealed.StagedPostEpochMachineStateHash) + result.StagedOutputsMerkleRoot = formatHash(sealed.StagedPostEpochOutputsMerkleRoot) + + acceptInfo, err := caller.CanAcceptStagedTournamentResult(c.callOpts) + if err != nil { + return nil, fmt.Errorf("CanAcceptStagedTournamentResult: %w", err) } + allSentriesAgree := acceptInfo.DoAllSentriesAgreeWithStagedTournamentResult + claimStagingPeriodOver := acceptInfo.IsClaimStagingPeriodOver + result.AllSentriesAgree = &allSentriesAgree + result.ClaimStagingPeriodOver = &claimStagingPeriodOver } return result, nil } diff --git a/cmd/cartesi-rollups-cli/root/contract/tournament.go b/cmd/cartesi-rollups-cli/root/contract/tournament.go index f16d2c7b7..fae045a67 100644 --- a/cmd/cartesi-rollups-cli/root/contract/tournament.go +++ b/cmd/cartesi-rollups-cli/root/contract/tournament.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/imultileveltournamentfactory" "github.com/cartesi/rollups-node/pkg/contracts/itournament" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum" @@ -299,19 +300,23 @@ func (c *chainClient) queryTournament(addr common.Address) (*TournamentResult, e return nil, fmt.Errorf("bind ITournament: %w", err) } - levelConsts, err := caller.TournamentLevelConstants(c.callOpts) + descriptor, err := caller.TournamentDescriptor(c.callOpts) if err != nil { - return nil, fmt.Errorf("TournamentLevelConstants: %w", err) + return nil, fmt.Errorf("TournamentDescriptor: %w", err) } - - closed, err := caller.IsClosed(c.callOpts) + if descriptor.BaseCycle == nil || descriptor.BaseCycle.Sign() < 0 { + return nil, errors.New("TournamentDescriptor: invalid base cycle") + } + standing, err := caller.TournamentStanding(c.callOpts) if err != nil { - return nil, fmt.Errorf("IsClosed: %w", err) + return nil, fmt.Errorf("TournamentStanding: %w", err) } - - finished, err := caller.IsFinished(c.callOpts) + maxLevel, err := c.tournamentMaxLevel() if err != nil { - return nil, fmt.Errorf("IsFinished: %w", err) + return nil, err + } + if descriptor.Level > maxLevel { + return nil, fmt.Errorf("tournament level %d exceeds maximum level %d", descriptor.Level, maxLevel) } bondWei, err := caller.BondValue(c.callOpts) @@ -365,77 +370,166 @@ func (c *chainClient) queryTournament(addr common.Address) (*TournamentResult, e } result := &TournamentResult{ - Address: formatAddr(addr), - Level: levelConsts.Level, - MaxLevel: levelConsts.MaxLevel, - Log2Step: levelConsts.Log2step, - Height: levelConsts.Height, - Closed: closed, - Finished: finished, - BondWei: bondWei.String(), - BondETH: weiToETH(bondWei), - CommitmentsJoined: commitments, - MatchesCreated: matchesCreated, - MatchesAdvanced: matchesAdvanced, - MatchesDeleted: matchesDeleted, - InnerTournaments: inner, - } - - // Query finish details if tournament is finished. - if finished { - isFinished, finBlock, tErr := caller.TimeFinished(c.callOpts) - if tErr == nil && isFinished { - result.FinishedAtBlock = &finBlock + Address: formatAddr(addr), + Level: descriptor.Level, + MaxLevel: maxLevel, + Log2Step: descriptor.Log2Stride, + Height: descriptor.Height, + Kind: tournamentKindName(descriptor.Kind), + InitialMachineHash: formatHash(descriptor.InitialHash), + BaseCycle: descriptor.BaseCycle.String(), + StartBlock: descriptor.StartInstant, + Allowance: descriptor.Allowance, + BondWei: bondWei.String(), + BondETH: weiToETH(bondWei), + CommitmentsJoined: commitments, + MatchesCreated: matchesCreated, + MatchesAdvanced: matchesAdvanced, + MatchesDeleted: matchesDeleted, + InnerTournaments: inner, + } + + err = populateTournamentStandingResult(result, standing, func(commitment [32]byte) ([32]byte, error) { + commitmentStanding, cErr := caller.CommitmentStanding(c.callOpts, commitment) + if cErr != nil { + return [32]byte{}, fmt.Errorf("CommitmentStanding: %w", cErr) } + return commitmentStanding.FinalState, nil + }) + if err != nil { + return nil, err + } - isRoot := levelConsts.Level == 0 - tFinished, hasWin, winner, finalState, tErr := c.tournamentResult(caller, isRoot) - if tErr == nil && tFinished { - result.HasWinner = &hasWin - if hasWin { - result.WinnerCommitment = formatHash(winner) - if finalState != [32]byte{} { - result.FinalMachineHash = formatHash(finalState) - } - } - } + return result, nil +} + +type commitmentFinalStateReader func(commitment [32]byte) ([32]byte, error) + +func populateTournamentStandingResult( + result *TournamentResult, + standing itournament.ITournamentTournamentStandingView, + readCommitmentFinalState commitmentFinalStateReader, +) error { + result.Standing = tournamentStandingName(standing.Standing) + result.Closed = !standing.AcceptsJoins + result.Finished = standing.FinishedAt != 0 + + if result.Level > 0 { + canEliminate := standing.Standing == tournamentStandingInnerEliminableNoWinner || + standing.Standing == tournamentStandingInnerEliminableWinnerExpired + result.CanBeEliminated = &canEliminate + } + if !result.Finished { + return nil } - // CanBeEliminated for non-root tournaments. - if levelConsts.Level > 0 { - canElim, cErr := caller.CanBeEliminated(c.callOpts) - if cErr == nil { - result.CanBeEliminated = &canElim + finishedAt := standing.FinishedAt + result.FinishedAtBlock = &finishedAt + hasWinner := standing.HasCandidate + result.HasWinner = &hasWinner + if standing.WinnerExpiresAt != 0 { + winnerExpiresAt := standing.WinnerExpiresAt + result.WinnerExpiresAt = &winnerExpiresAt + } + if !hasWinner { + return nil + } + + result.WinnerCommitment = formatHash(standing.Candidate) + finalState := standing.FinalState + if finalState == ([32]byte{}) && standing.Standing == tournamentStandingInnerEliminableWinnerExpired { + var err error + finalState, err = readCommitmentFinalState(standing.Candidate) + if err != nil { + return err } } + if finalState != ([32]byte{}) { + result.FinalMachineHash = formatHash(finalState) + } + return nil +} + +const ( + tournamentStandingMatchesActive uint8 = iota + tournamentStandingAwaitingClosure + tournamentStandingRootWinner + tournamentStandingRootFailed + tournamentStandingInnerWinner + tournamentStandingInnerEliminableNoWinner + tournamentStandingInnerEliminableWinnerExpired +) + +const ( + tournamentKindLeaf uint8 = iota + tournamentKindNonLeaf +) + +func tournamentStandingName(standing uint8) string { + names := [...]string{ + "MATCHES_ACTIVE", "AWAITING_CLOSURE", "ROOT_WINNER", "ROOT_FAILED", + "INNER_WINNER", "INNER_ELIMINABLE_NO_WINNER", "INNER_ELIMINABLE_WINNER_EXPIRED", + } + if int(standing) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", standing) + } + return names[standing] +} - return result, nil +func tournamentKindName(kind uint8) string { + switch kind { + case tournamentKindLeaf: + return "LEAF" + case tournamentKindNonLeaf: + return "NON_LEAF" + default: + return fmt.Sprintf("UNKNOWN(%d)", kind) + } } -// tournamentResult handles ArbitrationResult (root) or InnerTournamentWinner (non-root) -// with TournamentFailedNoWinner revert detection. -func (c *chainClient) tournamentResult( - caller *itournament.ITournamentCaller, - isRoot bool, -) (finished bool, hasWinner bool, winner [32]byte, finalState [32]byte, err error) { - if !isRoot { - isFinished, _, winnerCommitment, _, iErr := caller.InnerTournamentWinner(c.callOpts) - if iErr != nil { - return false, false, [32]byte{}, [32]byte{}, iErr +func tournamentLevelName(level uint64, kind uint8) string { + switch kind { + case tournamentKindLeaf: + return "leaf" + case tournamentKindNonLeaf: + if level == 0 { + return "root" } - return isFinished, isFinished && winnerCommitment != [32]byte{}, - winnerCommitment, [32]byte{}, nil + return "inner" + default: + return fmt.Sprintf("unknown-kind-%d", kind) } +} - result, err := caller.ArbitrationResult(c.callOpts) +func maxLevelFromCount(levelCount uint64) (uint64, error) { + if levelCount == 0 { + return 0, errors.New("tournament level count is zero") + } + return levelCount - 1, nil +} + +func (c *chainClient) tournamentMaxLevel() (uint64, error) { + consensusAddr, err := c.getConsensusAddress() if err != nil { - if ethutil.IsCustomError(err, itournament.ITournamentMetaData, "TournamentFailedNoWinner") { - return true, false, [32]byte{}, [32]byte{}, nil - } - return false, false, [32]byte{}, [32]byte{}, err + return 0, fmt.Errorf("get consensus address: %w", err) + } + dave, err := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) + if err != nil { + return 0, fmt.Errorf("bind IDaveConsensus: %w", err) + } + factoryAddr, err := dave.GetTournamentFactory(c.callOpts) + if err != nil { + return 0, fmt.Errorf("GetTournamentFactory: %w", err) } - hasWin := result.WinnerCommitment != [32]byte{} - return result.Finished, hasWin, result.WinnerCommitment, result.FinalState, nil + factory, err := imultileveltournamentfactory.NewIMultiLevelTournamentFactoryCaller(factoryAddr, c.eth) + if err != nil { + return 0, fmt.Errorf("bind IMultiLevelTournamentFactory: %w", err) + } + levelCount, err := factory.TournamentLevelCount(c.callOpts) + if err != nil { + return 0, fmt.Errorf("TournamentLevelCount: %w", err) + } + return maxLevelFromCount(levelCount) } // commitmentRegistry maps commitment hashes to submitter addresses. @@ -465,20 +559,23 @@ type rawCommitmentJoined struct { } type rawMatchCreated struct { - matchIDHash [32]byte - one [32]byte - two [32]byte - leftOfTwo [32]byte - blockNumber uint64 - txHash common.Hash + matchIDHash [32]byte + one [32]byte + two [32]byte + leftOfTwo [32]byte + eliminableAt uint64 + blockNumber uint64 + txHash common.Hash } type rawMatchAdvanced struct { - matchIDHash [32]byte - otherParent [32]byte - leftNode [32]byte - blockNumber uint64 - txHash common.Hash + matchIDHash [32]byte + otherParent [32]byte + leftNode [32]byte + segmentStartPosition *big.Int + eliminableAt uint64 + blockNumber uint64 + txHash common.Hash } type rawMatchDeleted struct { @@ -550,12 +647,13 @@ func (c *chainClient) fetchTournamentEvents( return false, pErr } events.matchesCreated = append(events.matchesCreated, rawMatchCreated{ - matchIDHash: ev.MatchIdHash, - one: ev.One, - two: ev.Two, - leftOfTwo: ev.LeftOfTwo, - blockNumber: log.BlockNumber, - txHash: log.TxHash, + matchIDHash: ev.MatchIdHash, + one: ev.One, + two: ev.Two, + leftOfTwo: ev.LeftOfTwo, + eliminableAt: ev.EliminableAt, + blockNumber: log.BlockNumber, + txHash: log.TxHash, }) return false, nil }, @@ -578,11 +676,13 @@ func (c *chainClient) fetchTournamentEvents( return false, pErr } events.matchesAdvanced = append(events.matchesAdvanced, rawMatchAdvanced{ - matchIDHash: ev.MatchIdHash, - otherParent: ev.OtherParent, - leftNode: ev.LeftNode, - blockNumber: log.BlockNumber, - txHash: log.TxHash, + matchIDHash: ev.MatchIdHash, + otherParent: ev.OtherParent, + leftNode: ev.LeftNode, + segmentStartPosition: ev.SegmentStartPosition, + eliminableAt: ev.EliminableAt, + blockNumber: log.BlockNumber, + txHash: log.TxHash, }) return false, nil }, @@ -917,6 +1017,7 @@ func formatMatchEvents( PlayerOneAddr: registry.resolve(r.one), PlayerTwoAddr: registry.resolve(r.two), LeftOfTwo: formatHash(r.leftOfTwo), + EliminableAt: r.eliminableAt, BlockNumber: r.blockNumber, TxHash: r.txHash.Hex(), } @@ -948,12 +1049,18 @@ func formatAdvanceEvents(raw []rawMatchAdvanced) []MatchAdvanceEvent { } out := make([]MatchAdvanceEvent, len(raw)) for i, r := range raw { + segmentStartPosition := "" + if r.segmentStartPosition != nil { + segmentStartPosition = r.segmentStartPosition.String() + } out[i] = MatchAdvanceEvent{ - MatchIDHash: formatHash(r.matchIDHash), - OtherParent: formatHash(r.otherParent), - LeftNode: formatHash(r.leftNode), - BlockNumber: r.blockNumber, - TxHash: r.txHash.Hex(), + MatchIDHash: formatHash(r.matchIDHash), + OtherParent: formatHash(r.otherParent), + LeftNode: formatHash(r.leftNode), + SegmentStartPosition: segmentStartPosition, + EliminableAt: r.eliminableAt, + BlockNumber: r.blockNumber, + TxHash: r.txHash.Hex(), } } return out @@ -972,6 +1079,14 @@ func printTournamentBasic(p *printer, r *TournamentResult) { p.withSection(fmt.Sprintf("%s Tournament %s (level %d/%d)", levelName, r.Address, r.Level, r.MaxLevel), func() { p.field("Status", tournamentStatus(r.Closed, r.Finished)) + p.field("Standing", r.Standing) + p.field("Kind", r.Kind) + p.field("Initial Machine Hash", r.InitialMachineHash) + p.field("Base Cycle", r.BaseCycle) + p.field("Log2 Stride", fmt.Sprintf("%d", r.Log2Step)) + p.field("Height", fmt.Sprintf("%d", r.Height)) + p.field("Start Block", fmt.Sprintf("%d", r.StartBlock)) + p.field("Allowance", fmt.Sprintf("%d blocks", r.Allowance)) if r.Finished && r.FinishedAtBlock != nil { p.field("Finished", fmt.Sprintf("yes (block %d)", *r.FinishedAtBlock)) } @@ -989,6 +1104,9 @@ func printTournamentBasic(p *printer, r *TournamentResult) { p.field("Winner", "NONE (all commitments eliminated)") } } + if r.WinnerExpiresAt != nil { + p.field("Winner Expires At", fmt.Sprintf("block %d (inclusive)", *r.WinnerExpiresAt)) + } p.field("Bond", r.BondETH) p.field("Commitments Joined", fmt.Sprintf("%d", r.CommitmentsJoined)) p.field("Matches Created", fmt.Sprintf("%d", r.MatchesCreated)) @@ -1029,6 +1147,7 @@ func printTournamentEvents(p *printer, r *TournamentResult) { twoInfo += fmt.Sprintf(" (%s)", m.PlayerTwoAddr) } p.field("Player Two", twoInfo) + p.field("Eliminable At", fmt.Sprintf("block %d (inclusive)", m.EliminableAt)) p.field("Block", fmt.Sprintf("%d", m.BlockNumber)) p.field("Tx", m.TxHash) if m.DeletionReason != "" { @@ -1046,6 +1165,8 @@ func printTournamentEvents(p *printer, r *TournamentResult) { p.withSection("Match Advances:", func() { for i, a := range r.Advances { p.withSection(fmt.Sprintf("[%d] Match %s", i+1, a.MatchIDHash), func() { + p.field("Segment Start Position", a.SegmentStartPosition) + p.field("Eliminable At", fmt.Sprintf("block %d (inclusive)", a.EliminableAt)) p.field("Block", fmt.Sprintf("%d", a.BlockNumber)) p.field("Tx", a.TxHash) }) diff --git a/cmd/cartesi-rollups-cli/root/contract/tournament_test.go b/cmd/cartesi-rollups-cli/root/contract/tournament_test.go index 3d4facd3d..921911bec 100644 --- a/cmd/cartesi-rollups-cli/root/contract/tournament_test.go +++ b/cmd/cartesi-rollups-cli/root/contract/tournament_test.go @@ -4,8 +4,11 @@ package contract import ( + "errors" + "math/big" "testing" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" ) @@ -80,12 +83,13 @@ func TestFormatMatchEvents(t *testing.T) { created := []rawMatchCreated{ { - matchIDHash: matchID, - one: commitOne, - two: commitTwo, - leftOfTwo: [32]byte{0x33}, - blockNumber: 100, - txHash: common.HexToHash("0xC1"), + matchIDHash: matchID, + one: commitOne, + two: commitTwo, + leftOfTwo: [32]byte{0x33}, + eliminableAt: 120, + blockNumber: 100, + txHash: common.HexToHash("0xC1"), }, { matchIDHash: matchID2, @@ -117,6 +121,7 @@ func TestFormatMatchEvents(t *testing.T) { assert.Equal(t, formatHash(matchID), m0.MatchIDHash) assert.Equal(t, "0x1111111111111111111111111111111111111111", m0.PlayerOneAddr) assert.Equal(t, "0x2222222222222222222222222222222222222222", m0.PlayerTwoAddr) + assert.Equal(t, uint64(120), m0.EliminableAt) assert.Equal(t, "TIMEOUT", m0.DeletionReason) assert.Equal(t, "ONE", m0.Winner) assert.NotNil(t, m0.DeletionBlock) @@ -184,11 +189,13 @@ func TestFormatAdvanceEvents(t *testing.T) { raw := []rawMatchAdvanced{ { - matchIDHash: [32]byte{0xAA}, - otherParent: [32]byte{0xBB}, - leftNode: [32]byte{0xCC}, - blockNumber: 100, - txHash: common.HexToHash("0xDD"), + matchIDHash: [32]byte{0xAA}, + otherParent: [32]byte{0xBB}, + leftNode: [32]byte{0xCC}, + segmentStartPosition: big.NewInt(42), + eliminableAt: 120, + blockNumber: 100, + txHash: common.HexToHash("0xDD"), }, { matchIDHash: [32]byte{0xEE}, @@ -204,8 +211,165 @@ func TestFormatAdvanceEvents(t *testing.T) { assert.Equal(t, formatHash(raw[0].matchIDHash), result[0].MatchIDHash) assert.Equal(t, formatHash(raw[0].otherParent), result[0].OtherParent) assert.Equal(t, formatHash(raw[0].leftNode), result[0].LeftNode) + assert.Equal(t, "42", result[0].SegmentStartPosition) + assert.Equal(t, uint64(120), result[0].EliminableAt) assert.Equal(t, uint64(100), result[0].BlockNumber) assert.Equal(t, raw[0].txHash.Hex(), result[0].TxHash) assert.Equal(t, uint64(200), result[1].BlockNumber) } + +func TestTournamentEnumNamesMatchSolidityOrder(t *testing.T) { + assert.Equal(t, "MATCHES_ACTIVE", tournamentStandingName(0)) + assert.Equal(t, "AWAITING_CLOSURE", tournamentStandingName(1)) + assert.Equal(t, "ROOT_WINNER", tournamentStandingName(2)) + assert.Equal(t, "ROOT_FAILED", tournamentStandingName(3)) + assert.Equal(t, "INNER_WINNER", tournamentStandingName(4)) + assert.Equal(t, "INNER_ELIMINABLE_NO_WINNER", tournamentStandingName(5)) + assert.Equal(t, "INNER_ELIMINABLE_WINNER_EXPIRED", tournamentStandingName(6)) + assert.Equal(t, "UNKNOWN(7)", tournamentStandingName(7)) + + assert.Equal(t, "LEAF", tournamentKindName(0)) + assert.Equal(t, "NON_LEAF", tournamentKindName(1)) + assert.Equal(t, "UNKNOWN(2)", tournamentKindName(2)) + assert.Equal(t, "root", tournamentLevelName(0, tournamentKindNonLeaf)) + assert.Equal(t, "inner", tournamentLevelName(1, tournamentKindNonLeaf)) + assert.Equal(t, "leaf", tournamentLevelName(2, tournamentKindLeaf)) +} + +func TestMaxLevelFromCount(t *testing.T) { + maxLevel, err := maxLevelFromCount(1) + assert.NoError(t, err) + assert.Equal(t, uint64(0), maxLevel) + + maxLevel, err = maxLevelFromCount(3) + assert.NoError(t, err) + assert.Equal(t, uint64(2), maxLevel) + + _, err = maxLevelFromCount(0) + assert.EqualError(t, err, "tournament level count is zero") +} + +func TestPopulateTournamentStandingResult(t *testing.T) { + candidate := [32]byte{0x11} + directFinalState := [32]byte{0x22} + recoveredFinalState := [32]byte{0x33} + + tests := []struct { + name string + level uint64 + standing itournament.ITournamentTournamentStandingView + wantClosed bool + wantFinished bool + wantFinishedAt *uint64 + wantHasWinner *bool + wantWinner string + wantFinalState string + wantWinnerExpiresAt *uint64 + wantCanBeEliminated *bool + wantFinalStateReadCount int + }{ + { + name: "active root", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingMatchesActive, AcceptsJoins: true, + }, + }, + { + name: "root winner", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingRootWinner, HasCandidate: true, + Candidate: candidate, FinalState: directFinalState, FinishedAt: 42, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(42), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(directFinalState), + }, + { + name: "failed root", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingRootFailed, FinishedAt: 43, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(43), + wantHasWinner: boolPtr(false), + }, + { + name: "inner winner", + level: 1, + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerWinner, HasCandidate: true, + Candidate: candidate, FinalState: directFinalState, FinishedAt: 44, WinnerExpiresAt: 60, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(44), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(directFinalState), + wantWinnerExpiresAt: uint64Ptr(60), + wantCanBeEliminated: boolPtr(false), + }, + { + name: "expired inner winner", + level: 1, + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerEliminableWinnerExpired, HasCandidate: true, + Candidate: candidate, FinishedAt: 45, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(45), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(recoveredFinalState), + wantCanBeEliminated: boolPtr(true), + wantFinalStateReadCount: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := &TournamentResult{Level: test.level} + readCount := 0 + err := populateTournamentStandingResult(result, test.standing, func(got [32]byte) ([32]byte, error) { + readCount++ + assert.Equal(t, candidate, got) + return recoveredFinalState, nil + }) + + assert.NoError(t, err) + assert.Equal(t, test.wantClosed, result.Closed) + assert.Equal(t, test.wantFinished, result.Finished) + assert.Equal(t, test.wantFinishedAt, result.FinishedAtBlock) + assert.Equal(t, test.wantHasWinner, result.HasWinner) + assert.Equal(t, test.wantWinner, result.WinnerCommitment) + assert.Equal(t, test.wantFinalState, result.FinalMachineHash) + assert.Equal(t, test.wantWinnerExpiresAt, result.WinnerExpiresAt) + assert.Equal(t, test.wantCanBeEliminated, result.CanBeEliminated) + assert.Equal(t, test.wantFinalStateReadCount, readCount) + }) + } +} + +func TestPopulateTournamentStandingResultReturnsFinalStateError(t *testing.T) { + wantErr := errors.New("read failed") + result := &TournamentResult{Level: 1} + err := populateTournamentStandingResult(result, itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, + Candidate: [32]byte{1}, + FinishedAt: 10, + }, func([32]byte) ([32]byte, error) { + return [32]byte{}, wantErr + }) + assert.ErrorIs(t, err, wantErr) +} + +func uint64Ptr(value uint64) *uint64 { return &value } + +func boolPtr(value bool) *bool { return &value } diff --git a/cmd/cartesi-rollups-cli/root/contract/types.go b/cmd/cartesi-rollups-cli/root/contract/types.go index 56f010865..1b2b664e8 100644 --- a/cmd/cartesi-rollups-cli/root/contract/types.go +++ b/cmd/cartesi-rollups-cli/root/contract/types.go @@ -14,7 +14,7 @@ type AppResult struct { ExecutedOutputs uint64 `json:"executed_outputs"` ConsensusAddress string `json:"consensus_address"` ConsensusType string `json:"consensus_type"` - DataAvailability string `json:"data_availability"` + InputBox string `json:"input_box"` IsForeclosed bool `json:"is_foreclosed"` Guardian string `json:"guardian"` WithdrawalOutputBuilder string `json:"withdrawal_output_builder"` @@ -54,18 +54,38 @@ type QuorumConsensusResult struct { // tournament). Epochs 0..CurrentEpochNumber-1 have been settled; epoch CurrentEpochNumber // is sealed but not yet settled. type DaveConsensusResult struct { - Type string `json:"type"` - Address string `json:"address"` - InputBox string `json:"inputbox"` - Factory string `json:"factory"` - DeploymentBlock uint64 `json:"deployment_block"` - IsFinished bool `json:"is_finished"` - HasWinner *bool `json:"has_winner,omitempty"` - WinnerCommitment string `json:"winner_commitment,omitempty"` - CurrentEpochNumber uint64 `json:"current_epoch_number"` - InputLowerBound uint64 `json:"input_lower_bound"` - InputUpperBound uint64 `json:"input_upper_bound"` - RootTournament string `json:"root_tournament"` + Type string `json:"type"` + Address string `json:"address"` + InputBox string `json:"inputbox"` + Factory string `json:"factory"` + DeploymentBlock uint64 `json:"deployment_block"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + IsFinished bool `json:"is_finished"` + IsTournamentFailed bool `json:"is_tournament_failed"` + IsTournamentResultStaged bool `json:"is_tournament_result_staged"` + HasWinner *bool `json:"has_winner,omitempty"` + WinnerCommitment string `json:"winner_commitment,omitempty"` + WinnerPostEpochMachineHash string `json:"winner_post_epoch_machine_hash,omitempty"` + CurrentEpochNumber uint64 `json:"current_epoch_number"` + InputLowerBound uint64 `json:"input_lower_bound"` + InputUpperBound uint64 `json:"input_upper_bound"` + RootTournament string `json:"root_tournament"` + StagingBlock *uint64 `json:"staging_block,omitempty"` + StagedMachineHash string `json:"staged_machine_hash,omitempty"` + StagedOutputsMerkleRoot string `json:"staged_outputs_merkle_root,omitempty"` + AllSentriesAgree *bool `json:"all_sentries_agree,omitempty"` + ClaimStagingPeriodOver *bool `json:"claim_staging_period_over,omitempty"` + + SentryManager string `json:"sentry_manager"` + NumSentries uint64 `json:"num_sentries"` + Sentries []SentryResult `json:"sentries"` +} + +// SentryResult identifies a sentry slot and its address at the queried block. +// IDs start at one and remain unchanged when the manager rotates a sentry. +type SentryResult struct { + ID uint64 `json:"id"` + Address string `json:"address"` } // InputBoxResult is the JSON output for InputBox state. @@ -91,6 +111,7 @@ type MatchEvent struct { PlayerOneAddr string `json:"player_one_addr,omitempty"` PlayerTwoAddr string `json:"player_two_addr,omitempty"` LeftOfTwo string `json:"left_of_two"` + EliminableAt uint64 `json:"eliminable_at"` BlockNumber uint64 `json:"block_number"` TxHash string `json:"tx_hash"` DeletionReason string `json:"deletion_reason,omitempty"` @@ -102,39 +123,48 @@ type MatchEvent struct { // MatchAdvanceEvent is a match advance (bisection step) event. type MatchAdvanceEvent struct { - MatchIDHash string `json:"match_id_hash"` - OtherParent string `json:"other_parent"` - LeftNode string `json:"left_node"` - BlockNumber uint64 `json:"block_number"` - TxHash string `json:"tx_hash"` + MatchIDHash string `json:"match_id_hash"` + OtherParent string `json:"other_parent"` + LeftNode string `json:"left_node"` + SegmentStartPosition string `json:"segment_start_position"` + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash string `json:"tx_hash"` } // TournamentResult is the JSON output for a tournament. type TournamentResult struct { - Address string `json:"address"` - Level uint64 `json:"level"` - MaxLevel uint64 `json:"max_level"` - Log2Step uint64 `json:"log2step"` - Height uint64 `json:"height"` - Closed bool `json:"closed"` - Finished bool `json:"finished"` - FinishedAtBlock *uint64 `json:"finished_at_block,omitempty"` - HasWinner *bool `json:"has_winner,omitempty"` - WinnerCommitment string `json:"winner_commitment,omitempty"` - WinnerAddress string `json:"winner_address,omitempty"` - FinalMachineHash string `json:"final_machine_hash,omitempty"` - BondWei string `json:"bond_wei"` - BondETH string `json:"bond_eth"` - CommitmentsJoined uint64 `json:"commitments_joined"` - MatchesCreated uint64 `json:"matches_created"` - MatchesAdvanced uint64 `json:"matches_advanced"` - MatchesDeleted uint64 `json:"matches_deleted"` - InnerTournaments uint64 `json:"inner_tournaments"` - CanBeEliminated *bool `json:"can_be_eliminated,omitempty"` - Commitments []CommitmentEvent `json:"commitments,omitempty"` - Matches []MatchEvent `json:"matches,omitempty"` - Advances []MatchAdvanceEvent `json:"advances,omitempty"` - Children []*TournamentResult `json:"children,omitempty"` + Address string `json:"address"` + Level uint64 `json:"level"` + MaxLevel uint64 `json:"max_level"` + Log2Step uint64 `json:"log2step"` + Height uint64 `json:"height"` + Kind string `json:"kind"` + InitialMachineHash string `json:"initial_machine_hash"` + BaseCycle string `json:"base_cycle"` + StartBlock uint64 `json:"start_block"` + Allowance uint64 `json:"allowance"` + Standing string `json:"standing"` + Closed bool `json:"closed"` + Finished bool `json:"finished"` + FinishedAtBlock *uint64 `json:"finished_at_block,omitempty"` + HasWinner *bool `json:"has_winner,omitempty"` + WinnerCommitment string `json:"winner_commitment,omitempty"` + WinnerAddress string `json:"winner_address,omitempty"` + FinalMachineHash string `json:"final_machine_hash,omitempty"` + BondWei string `json:"bond_wei"` + BondETH string `json:"bond_eth"` + CommitmentsJoined uint64 `json:"commitments_joined"` + MatchesCreated uint64 `json:"matches_created"` + MatchesAdvanced uint64 `json:"matches_advanced"` + MatchesDeleted uint64 `json:"matches_deleted"` + InnerTournaments uint64 `json:"inner_tournaments"` + CanBeEliminated *bool `json:"can_be_eliminated,omitempty"` + WinnerExpiresAt *uint64 `json:"winner_expires_at,omitempty"` + Commitments []CommitmentEvent `json:"commitments,omitempty"` + Matches []MatchEvent `json:"matches,omitempty"` + Advances []MatchAdvanceEvent `json:"advances,omitempty"` + Children []*TournamentResult `json:"children,omitempty"` } // ClaimEvent is a claim event in the epoch history. @@ -200,24 +230,36 @@ type CommitmentResult struct { Commitment string `json:"commitment"` Tournament string `json:"tournament"` TournamentLevel string `json:"tournament_level"` + Joined bool `json:"joined"` + Claimer string `json:"claimer,omitempty"` + ClockRunning bool `json:"clock_running"` ClockAllowance uint64 `json:"clock_allowance"` - ClockStartBlock uint64 `json:"clock_start_block"` + ClockDeadline uint64 `json:"clock_deadline"` + BlocksRemaining uint64 `json:"blocks_remaining"` FinalMachineHash string `json:"final_machine_hash"` } // MatchResult is the JSON output for a match's bisection state. type MatchResult struct { - MatchIDHash string `json:"match_id_hash"` - Tournament string `json:"tournament"` - CommitmentOne string `json:"commitment_one"` - CommitmentTwo string `json:"commitment_two"` - PlayerOneAddr string `json:"player_one_addr,omitempty"` - PlayerTwoAddr string `json:"player_two_addr,omitempty"` - CurrentHeight uint64 `json:"current_height"` - RunningLeafPosition string `json:"running_leaf_position"` - MachineCycle string `json:"machine_cycle"` - CanWinByTimeout bool `json:"can_win_by_timeout"` - LeftNode string `json:"left_node"` - RightNode string `json:"right_node"` - OtherParent string `json:"other_parent"` + MatchIDHash string `json:"match_id_hash"` + Tournament string `json:"tournament"` + CommitmentOne string `json:"commitment_one"` + CommitmentTwo string `json:"commitment_two"` + PlayerOneAddr string `json:"player_one_addr,omitempty"` + PlayerTwoAddr string `json:"player_two_addr,omitempty"` + ActualPhase string `json:"actual_phase"` + CurrentHeight *uint64 `json:"current_height,omitempty"` + SegmentStartPosition string `json:"segment_start_position,omitempty"` + SegmentStartCycle string `json:"segment_start_cycle,omitempty"` + TimeoutOutcome string `json:"timeout_outcome"` + DeferredCharge uint64 `json:"deferred_charge"` + RevealingParent string `json:"revealing_parent,omitempty"` + WaitingLeft string `json:"waiting_left,omitempty"` + WaitingRight string `json:"waiting_right,omitempty"` + Responder string `json:"responder,omitempty"` + AgreeState string `json:"agree_state,omitempty"` + DivergencePosition string `json:"divergence_position,omitempty"` + DivergenceCycle string `json:"divergence_cycle,omitempty"` + FinalStateOne string `json:"final_state_one,omitempty"` + FinalStateTwo string `json:"final_state_two,omitempty"` } From ab8ed638b8ca9e3e9a8facbb1603fc5e90b2bba8 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:17 -0300 Subject: [PATCH 18/41] feat(cli): read match advance events List advances within one application, epoch, tournament, and match. Use the returned transaction hash and log index for a single-event read. Apply the same identity rules to the JSON-RPC and direct repository backends. Add command and backend tests; do not add generic advance search across applications or matches. --- .../root/read/matchadvances/matchadvances.go | 73 ++++--- .../read/matchadvances/matchadvances_test.go | 126 ++++++++++++ .../root/read/service/jsonrpc.go | 7 +- .../root/read/service/matchadvances_test.go | 188 ++++++++++++++++++ .../root/read/service/repository.go | 11 +- 5 files changed, 369 insertions(+), 36 deletions(-) create mode 100644 cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go create mode 100644 cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go diff --git a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go index d1d2b712c..e17c6dc33 100644 --- a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go +++ b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go @@ -5,6 +5,7 @@ package matchadvances import ( "bytes" + "context" "encoding/json" "fmt" "os" @@ -17,19 +18,31 @@ import ( "github.com/spf13/cobra" ) +const ( + listArgCount = 4 + getArgCount = 6 +) + var Cmd = &cobra.Command{ - Use: "match_advances [parent]", + Use: "match_advances [transaction hash log index]", Short: "Reads match advances", Example: examples, - Args: cobra.RangeArgs(4, 5), //nolint: mnd - Run: run, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != listArgCount && len(args) != getArgCount { + return fmt.Errorf("requires %d arguments to list a match's advances, or %d with transaction hash and log index to read one", + listArgCount, getArgCount) + } + return nil + }, + Run: run, Long: ` Arguments: application name or address decimal or hex encoded hex encoded hex encoded - [parent] hex encoded + [transaction hash] hex encoded; provide together with log index + [log index] decimal or hex encoded block log index Supported Environment Variables: CARTESI_JSONRPC_API_URL JSON-RPC API URL @@ -37,10 +50,10 @@ Supported Environment Variables: } //nolint:lll // Long CLI examples are kept copy-pasteable. -const examples = `# Read specific match advanced: -cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 +const examples = `# Read one match advance by transaction hash and block log index: +cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 0xa24a91c7ce97fb16b2f679875966dc50f84747fd54e006763ed0dd702c260370 3 -# Read all match advances: +# Read all advances for one match (includes each event's transaction hash and log index): cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 # Read all match advances with pagination: @@ -89,30 +102,7 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) defer readServ.Close() - var result json.RawMessage - if len(args) >= 5 { //nolint:mnd // Five positional arguments select the get operation. - var params api.GetMatchAdvanceParams - params.Application = args[0] - params.EpochIndex, err = config.AsHexString(args[1]) - cobra.CheckErr(err) - params.TournamentAddress = args[2] - params.IDHash = args[3] - params.Parent = args[4] - - result, err = readServ.GetMatchAdvanced(ctx, params) - } else { - var params api.ListMatchAdvancesParams - params.Application = args[0] - params.EpochIndex, err = config.AsHexString(args[1]) - cobra.CheckErr(err) - params.TournamentAddress = args[2] - params.IDHash = args[3] - params.Limit = limit - params.Offset = offset - params.Descending = descending - - result, err = readServ.ListMatchAdvances(ctx, params) - } + result, err := readMatchAdvances(ctx, readServ, args) cobra.CheckErr(err) var out bytes.Buffer @@ -123,3 +113,24 @@ func run(cmd *cobra.Command, args []string) { _, err = out.WriteTo(os.Stdout) cobra.CheckErr(err) } + +func readMatchAdvances(ctx context.Context, readServ service.ReadService, args []string) (json.RawMessage, error) { + epochIndex, err := config.AsHexString(args[1]) + if err != nil { + return nil, err + } + if len(args) == getArgCount { + logIndex, err := config.AsHexString(args[getArgCount-1]) + if err != nil { + return nil, err + } + return readServ.GetMatchAdvanced(ctx, api.GetMatchAdvanceParams{ + Application: args[0], EpochIndex: epochIndex, TournamentAddress: args[2], IDHash: args[3], + TxHash: args[listArgCount], LogIndex: logIndex, + }) + } + return readServ.ListMatchAdvances(ctx, api.ListMatchAdvancesParams{ + Application: args[0], EpochIndex: epochIndex, TournamentAddress: args[2], IDHash: args[3], + Limit: limit, Offset: offset, Descending: descending, + }) +} diff --git a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go new file mode 100644 index 000000000..26de105f8 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go @@ -0,0 +1,126 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package matchadvances + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestMatchAdvancesCommandRequiresScopeAndCompleteEventIdentity(t *testing.T) { + for count := range 8 { + err := Cmd.Args(Cmd, make([]string, count)) + if count == 4 || count == 6 { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } + require.Contains(t, Cmd.Use, "transaction hash log index") + require.NotContains(t, Cmd.Use, "parent") + require.NotContains(t, Cmd.Example, "[parent]") +} + +func TestMatchAdvancesGetExampleHasValidEventIdentity(t *testing.T) { + _, example, found := strings.Cut(Cmd.Example, "cartesi-rollups-cli read match_advances ") + require.True(t, found) + line, _, _ := strings.Cut(example, "\n") + args := strings.Fields(line) + require.Len(t, args, getArgCount) + require.NoError(t, Cmd.Args(Cmd, args)) + _, err := config.ToHashFromString(args[listArgCount]) + require.NoError(t, err) + _, err = config.AsHexString(args[getArgCount-1]) + require.NoError(t, err) +} + +type matchAdvanceReader struct { + service.ReadService + get func(context.Context, api.GetMatchAdvanceParams) (json.RawMessage, error) + list func(context.Context, api.ListMatchAdvancesParams) (json.RawMessage, error) +} + +func (reader *matchAdvanceReader) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { + return reader.get(ctx, params) +} + +func (reader *matchAdvanceReader) ListMatchAdvances(ctx context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) { + return reader.list(ctx, params) +} + +func matchAdvanceCommandArgs() []string { + return []string{"echo-dapp", "16", common.HexToAddress("0xabc").Hex(), common.HexToHash("0xdef").Hex()} +} + +func TestMatchAdvancesCommandMapsGetEventIdentity(t *testing.T) { + for _, logIndex := range []string{"0", "0X2A"} { + t.Run(logIndex, func(t *testing.T) { + args := append(matchAdvanceCommandArgs(), common.HexToHash("0x123").Hex(), logIndex) + require.NoError(t, Cmd.Args(Cmd, args)) + expectedIndex := "0x0" + if logIndex == "0X2A" { + expectedIndex = "0x2a" + } + called := false + reader := &matchAdvanceReader{get: func(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { + called = true + require.Equal(t, t.Context(), ctx) + require.Equal(t, api.GetMatchAdvanceParams{Application: args[0], EpochIndex: "0x10", + TournamentAddress: args[2], IDHash: args[3], TxHash: args[4], LogIndex: expectedIndex}, params) + return json.RawMessage(`{"data":{}}`), nil + }} + result, err := readMatchAdvances(t.Context(), reader, args) + require.NoError(t, err) + require.True(t, called) + require.JSONEq(t, `{"data":{}}`, string(result)) + }) + } +} + +func TestMatchAdvancesCommandKeepsListPaginationFlags(t *testing.T) { + previousLimit, previousOffset, previousDescending := limit, offset, descending + t.Cleanup(func() { limit, offset, descending = previousLimit, previousOffset, previousDescending }) + require.NoError(t, Cmd.ParseFlags([]string{"--limit", "3", "--offset", "2", "--descending"})) + args := matchAdvanceCommandArgs() + require.NoError(t, Cmd.Args(Cmd, args)) + require.NoError(t, Cmd.PreRunE(Cmd, args)) + called := false + reader := &matchAdvanceReader{list: func(_ context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) { + called = true + require.Equal(t, api.ListMatchAdvancesParams{Application: args[0], EpochIndex: "0x10", + TournamentAddress: args[2], IDHash: args[3], Limit: 3, Offset: 2, Descending: true}, params) + return json.RawMessage(`{"data":[]}`), nil + }} + _, err := readMatchAdvances(t.Context(), reader, args) + require.NoError(t, err) + require.True(t, called) + limit = 0 + require.NoError(t, Cmd.PreRunE(Cmd, args)) + require.Equal(t, uint64(jsonrpc.LIST_ITEM_LIMIT), limit) + limit = jsonrpc.LIST_ITEM_LIMIT + 1 + require.Error(t, Cmd.PreRunE(Cmd, args)) +} + +func TestMatchAdvancesCommandRejectsInvalidNumericIndices(t *testing.T) { + for _, epochIndex := range []string{"-1", "0x10000000000000000"} { + args := matchAdvanceCommandArgs() + args[1] = epochIndex + _, err := readMatchAdvances(t.Context(), nil, args) + require.Error(t, err) + } + for _, logIndex := range []string{"-1", "0x10000000000000000"} { + args := append(matchAdvanceCommandArgs(), common.HexToHash("0x123").Hex(), logIndex) + _, err := readMatchAdvances(t.Context(), nil, args) + require.Error(t, err) + } +} diff --git a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go index 1a96b2099..c4c93c2a2 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go +++ b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go @@ -355,8 +355,11 @@ func (s *JsonrpcReadService) GetMatchAdvanced(ctx context.Context, params api.Ge if _, err := config.ToHashFromString(params.IDHash); err != nil { return nil, fmt.Errorf("invalid ID hash: %w", err) } - if _, err := config.ToHashFromString(params.Parent); err != nil { - return nil, fmt.Errorf("invalid parent: %w", err) + if _, err := config.ToHashFromString(params.TxHash); err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + if _, err := config.ToIndexFromString(params.LogIndex); err != nil { + return nil, fmt.Errorf("invalid log index: %w", err) } var resp json.RawMessage diff --git a/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go b/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go new file mode 100644 index 000000000..b84453fb0 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go @@ -0,0 +1,188 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/jsonrpc/client" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const invalidMatchAdvanceLogIndex = "invalid log index" + +func validMatchAdvanceParams() api.GetMatchAdvanceParams { + return api.GetMatchAdvanceParams{Application: "echo-dapp", EpochIndex: "0x10", + TournamentAddress: common.HexToAddress("0xabc").Hex(), IDHash: common.HexToHash("0xdef").Hex(), + TxHash: common.HexToHash("0x123").Hex(), LogIndex: "0x0"} +} + +func TestMatchAdvanceBackendsRejectInvalidIdentityBeforeIO(t *testing.T) { + for _, test := range []struct { + name string + change func(*api.GetMatchAdvanceParams) + message string + }{ + {"application", func(p *api.GetMatchAdvanceParams) { p.Application = "" }, "invalid application"}, + {"epoch", func(p *api.GetMatchAdvanceParams) { p.EpochIndex = "" }, "invalid epoch index"}, + {"tournament", func(p *api.GetMatchAdvanceParams) { p.TournamentAddress = "" }, "invalid tournament address"}, + {"match", func(p *api.GetMatchAdvanceParams) { p.IDHash = "" }, "invalid ID hash"}, + {"transaction absent", func(p *api.GetMatchAdvanceParams) { p.TxHash = "" }, "invalid transaction hash"}, + {"transaction short", func(p *api.GetMatchAdvanceParams) { p.TxHash = "0x123" }, "invalid transaction hash"}, + {"log index absent", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "" }, invalidMatchAdvanceLogIndex}, + {"log index decimal", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "12" }, invalidMatchAdvanceLogIndex}, + {"log index negative", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "-1" }, invalidMatchAdvanceLogIndex}, + {"log index overflow", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "0x10000000000000000" }, invalidMatchAdvanceLogIndex}, + } { + for _, backend := range []struct { + name string + service ReadService + }{ + {"repository", &RepositoryReadService{}}, {"jsonrpc", &JsonrpcReadService{}}, + } { + t.Run(backend.name+"/"+test.name, func(t *testing.T) { + params := validMatchAdvanceParams() + test.change(¶ms) + // Both dependencies are nil: reaching I/O before validation fails this test. + _, err := backend.service.GetMatchAdvanced(t.Context(), params) + require.ErrorContains(t, err, test.message) + }) + } + } +} + +type matchAdvanceRepository struct { + repository.Repository + get func(context.Context, string, uint64, string, string, common.Hash, uint64) (*model.MatchAdvanced, error) + list func(context.Context, string, uint64, string, string, repository.Pagination, bool) ([]*model.MatchAdvanced, uint64, error) +} + +func (r *matchAdvanceRepository) GetMatchAdvanced(ctx context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, logIndex uint64) (*model.MatchAdvanced, error) { + return r.get(ctx, app, epoch, tournament, id, tx, logIndex) +} + +func (r *matchAdvanceRepository) ListMatchAdvances(ctx context.Context, app string, epoch uint64, tournament, id string, + pagination repository.Pagination, descending bool) ([]*model.MatchAdvanced, uint64, error) { + return r.list(ctx, app, epoch, tournament, id, pagination, descending) +} + +func TestRepositoryMatchAdvanceUsesScopedEventIdentity(t *testing.T) { + params := validMatchAdvanceParams() + called := false + repo := &matchAdvanceRepository{get: func(ctx context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, logIndex uint64) (*model.MatchAdvanced, error) { + called = true + require.Equal(t, t.Context(), ctx) + require.Equal(t, params.Application, app) + require.Equal(t, uint64(16), epoch) + require.Equal(t, params.TournamentAddress, tournament) + require.Equal(t, params.IDHash, id) + require.Equal(t, common.HexToHash(params.TxHash), tx) + require.Zero(t, logIndex, "block log index zero is valid") + return &model.MatchAdvanced{TxHash: tx, LogIndex: logIndex}, nil + }} + s := &RepositoryReadService{Repository: repo} + result, err := s.GetMatchAdvanced(t.Context(), params) + require.NoError(t, err) + require.True(t, called) + var response api.SingleResponse[*model.MatchAdvanced] + require.NoError(t, json.Unmarshal(result, &response)) + require.Equal(t, common.HexToHash(params.TxHash), response.Data.TxHash) + require.Zero(t, response.Data.LogIndex) +} + +func TestRepositoryMatchAdvanceListKeepsMatchScopeAndPagination(t *testing.T) { + identity := validMatchAdvanceParams() + params := api.ListMatchAdvancesParams{Application: identity.Application, EpochIndex: identity.EpochIndex, + TournamentAddress: identity.TournamentAddress, IDHash: identity.IDHash, Limit: 3, Offset: 2, Descending: true} + called := false + repo := &matchAdvanceRepository{list: func(_ context.Context, app string, epoch uint64, tournament, id string, + pagination repository.Pagination, descending bool) ([]*model.MatchAdvanced, uint64, error) { + called = true + require.Equal(t, params.Application, app) + require.Equal(t, uint64(16), epoch) + require.Equal(t, params.TournamentAddress, tournament) + require.Equal(t, params.IDHash, id) + require.Equal(t, repository.Pagination{Limit: 3, Offset: 2}, pagination) + require.True(t, descending) + return []*model.MatchAdvanced{{LogIndex: 4}}, 7, nil + }} + result, err := (&RepositoryReadService{Repository: repo}).ListMatchAdvances(t.Context(), params) + require.NoError(t, err) + require.True(t, called) + var response api.ListResponse[*model.MatchAdvanced] + require.NoError(t, json.Unmarshal(result, &response)) + require.Equal(t, uint64(7), response.Pagination.TotalCount) + require.Equal(t, uint64(3), response.Pagination.Limit) + require.Equal(t, uint64(2), response.Pagination.Offset) +} + +type matchAdvanceTransport func(*http.Request) (*http.Response, error) + +func (transport matchAdvanceTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestJSONRPCMatchAdvanceSendsScopedParams(t *testing.T) { + identity := validMatchAdvanceParams() + for _, get := range []bool{false, true} { + name := "list" + method := "cartesi_listMatchAdvances" + if get { + name, method = "get", "cartesi_getMatchAdvance" + } + t.Run(name, func(t *testing.T) { + called := false + transport := matchAdvanceTransport(func(request *http.Request) (*http.Response, error) { + called = true + var wire struct { + Method string + Params map[string]any + } + require.NoError(t, json.NewDecoder(request.Body).Decode(&wire)) + require.NoError(t, request.Body.Close()) + require.Equal(t, method, wire.Method) + require.Equal(t, identity.Application, wire.Params["application"]) + require.Equal(t, identity.EpochIndex, wire.Params["epoch_index"]) + require.Equal(t, identity.TournamentAddress, wire.Params["tournament_address"]) + require.Equal(t, identity.IDHash, wire.Params["id_hash"]) + require.NotContains(t, wire.Params, "parent") + if get { + require.Equal(t, identity.TxHash, wire.Params["tx_hash"]) + require.Equal(t, identity.LogIndex, wire.Params["log_index"]) + } else { + require.NotContains(t, wire.Params, "tx_hash") + require.NotContains(t, wire.Params, "log_index") + require.Equal(t, float64(3), wire.Params["limit"]) + require.Equal(t, float64(2), wire.Params["offset"]) + require.Equal(t, true, wire.Params["descending"]) + } + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"data":[]}}`))}, nil + }) + s := &JsonrpcReadService{Client: &client.Client{URL: "http://example.invalid/rpc", + HTTPClient: &http.Client{Transport: transport}}} + var err error + if get { + _, err = s.GetMatchAdvanced(t.Context(), identity) + } else { + _, err = s.ListMatchAdvances(t.Context(), api.ListMatchAdvancesParams{Application: identity.Application, + EpochIndex: identity.EpochIndex, TournamentAddress: identity.TournamentAddress, IDHash: identity.IDHash, + Limit: 3, Offset: 2, Descending: true}) + } + require.NoError(t, err) + require.True(t, called) + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/read/service/repository.go b/cmd/cartesi-rollups-cli/root/read/service/repository.go index 01a59fc70..4912e5d8e 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/repository.go +++ b/cmd/cartesi-rollups-cli/root/read/service/repository.go @@ -806,11 +806,16 @@ func (s *RepositoryReadService) GetMatchAdvanced(ctx context.Context, params api if _, err := config.ToHashFromString(params.IDHash); err != nil { return nil, fmt.Errorf("invalid ID hash: %w", err) } - if _, err := config.ToHashFromString(params.Parent); err != nil { - return nil, fmt.Errorf("invalid parent: %w", err) + txHash, err := config.ToHashFromString(params.TxHash) + if err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + logIndex, err := config.ToIndexFromString(params.LogIndex) + if err != nil { + return nil, fmt.Errorf("invalid log index: %w", err) } - data, err := repo.GetMatchAdvanced(ctx, application, epochIndex, params.TournamentAddress, params.IDHash, params.Parent) + data, err := repo.GetMatchAdvanced(ctx, application, epochIndex, params.TournamentAddress, params.IDHash, txHash, logIndex) if err != nil { return nil, err } From a72b6bb14838f36f764d56088b8b6da9f8fd15d2 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:17 -0300 Subject: [PATCH 19/41] fix(cli): use v3 withdrawal account layout Read and construct withdrawal account-drive entries with the v3 USD layout. Keep the machine-tool layout change and its unit tests together. The matching withdrawal application fixture is updated with the final integration coverage. --- .../accountdrive/accountdrive.go | 42 +++++----- .../accountdrive/accountdrive_test.go | 76 +++++++++++++++---- 2 files changed, 85 insertions(+), 33 deletions(-) diff --git a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go index 85e255b6b..35e0b93ba 100644 --- a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go +++ b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go @@ -5,10 +5,10 @@ package accountdrive import ( "bytes" - "encoding/binary" "errors" "fmt" "math" + "math/big" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -18,6 +18,7 @@ const ( Log2AccountSize = 5 AccountSize = 1 << Log2AccountSize DefaultLog2MaxAccount = 17 + usdBalanceSize = 12 ) var ( @@ -43,43 +44,44 @@ func DriveSize(log2MaxNumOfAccounts uint8, log2LeavesPerAccount uint8) (uint64, return 1 << (Log2AccountSize + log2MaxNumOfAccounts), nil } -func Encode(address common.Address, balance uint64) ([AccountSize]byte, error) { +func Encode(address common.Address, balance *big.Int) ([AccountSize]byte, error) { var account [AccountSize]byte if address == (common.Address{}) { return account, errors.New("account address must not be zero") } - if balance == 0 { + if balance == nil || balance.Sign() <= 0 { return account, errors.New("account balance must be positive") } - if balance > math.MaxInt64 { - return account, fmt.Errorf("account balance %d exceeds int64 accounts-drive limit", balance) + if balance.BitLen() > 8*usdBalanceSize { + return account, fmt.Errorf("account balance %s exceeds uint96 accounts-drive limit", balance) } - binary.LittleEndian.PutUint64(account[:8], balance) - copy(account[8:28], address.Bytes()) + balanceBytes := balance.Bytes() + for i := range balanceBytes { + account[i] = balanceBytes[len(balanceBytes)-1-i] + } + copy(account[usdBalanceSize:], address.Bytes()) return account, nil } -func Decode(account []byte) (common.Address, uint64, bool, error) { +func Decode(account []byte) (common.Address, *big.Int, bool, error) { var zero [AccountSize]byte if len(account) != AccountSize { - return common.Address{}, 0, false, fmt.Errorf("account record must be %d bytes, got %d", AccountSize, len(account)) + return common.Address{}, nil, false, fmt.Errorf("account record must be %d bytes, got %d", AccountSize, len(account)) } if bytes.Equal(account, zero[:]) { - return common.Address{}, 0, false, nil + return common.Address{}, nil, false, nil } - balance := binary.LittleEndian.Uint64(account[:8]) - if balance == 0 { - return common.Address{}, 0, false, errors.New("non-empty account has zero balance") + var balanceBytes [usdBalanceSize]byte + for i := range balanceBytes { + balanceBytes[len(balanceBytes)-1-i] = account[i] } - if balance > math.MaxInt64 { - return common.Address{}, 0, false, fmt.Errorf("account balance %d exceeds int64 accounts-drive limit", balance) + balance := new(big.Int).SetBytes(balanceBytes[:]) + if balance.Sign() == 0 { + return common.Address{}, nil, false, errors.New("non-empty account has zero balance") } - address := common.BytesToAddress(account[8:28]) + address := common.BytesToAddress(account[usdBalanceSize:]) if address == (common.Address{}) { - return common.Address{}, 0, false, errors.New("non-empty account has zero address") - } - if !bytes.Equal(account[28:32], []byte{0, 0, 0, 0}) { - return common.Address{}, 0, false, errors.New("non-empty account has non-zero padding") + return common.Address{}, nil, false, errors.New("non-empty account has zero address") } return address, balance, true, nil } diff --git a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go index 3ab33e5dc..94a57709a 100644 --- a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go +++ b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go @@ -6,23 +6,66 @@ package accountdrive import ( "encoding/hex" "errors" + "math/big" "testing" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" ) -func TestEncode_MatchesEwtoolsLayout(t *testing.T) { - account, err := Encode(common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), 7) +func TestEncodeMatchesLibUsdAccountLayout(t *testing.T) { + address := common.HexToAddress("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266") + balance, ok := new(big.Int).SetString("0123456789abcdef01234567", 16) + require.True(t, ok) + + account, err := Encode(address, balance) require.NoError(t, err) require.Equal(t, - "0700000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb00000000", + "67452301efcdab8967452301f39fd6e51aad88f6f4ce6ab8827279cfffb92266", hex.EncodeToString(account[:]), ) + + decodedAddress, decodedBalance, nonEmpty, err := Decode(account[:]) + require.NoError(t, err) + require.True(t, nonEmpty) + require.Equal(t, address, decodedAddress) + require.Zero(t, balance.Cmp(decodedBalance)) +} + +func TestEncodeSupportsUint96(t *testing.T) { + maxUint96 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 96), big.NewInt(1)) + address := common.HexToAddress("0x1111111111111111111111111111111111111111") + + account, err := Encode(address, maxUint96) + require.NoError(t, err) + require.Equal(t, "ffffffffffffffffffffffff1111111111111111111111111111111111111111", + hex.EncodeToString(account[:])) + + decodedAddress, decodedBalance, nonEmpty, err := Decode(account[:]) + require.NoError(t, err) + require.True(t, nonEmpty) + require.Equal(t, address, decodedAddress) + require.Zero(t, maxUint96.Cmp(decodedBalance)) } -func TestDecode_RejectsCorruptRecords(t *testing.T) { +func TestEncodeRejectsInvalidBalances(t *testing.T) { + address := common.HexToAddress("0x1111111111111111111111111111111111111111") + tooLarge := new(big.Int).Lsh(big.NewInt(1), 96) + for _, balance := range []*big.Int{nil, big.NewInt(0), big.NewInt(-1), tooLarge} { + _, err := Encode(address, balance) + require.Error(t, err) + } +} + +func TestDecodeRejectsCorruptRecords(t *testing.T) { + t.Run("wrong record size is invalid", func(t *testing.T) { + for _, size := range []int{AccountSize - 1, AccountSize + 1} { + _, _, _, err := Decode(make([]byte, size)) + require.ErrorContains(t, err, "must be 32 bytes") + } + }) + t.Run("zero record is empty", func(t *testing.T) { var zero [AccountSize]byte _, _, ok, err := Decode(zero[:]) @@ -30,22 +73,29 @@ func TestDecode_RejectsCorruptRecords(t *testing.T) { require.False(t, ok) }) - t.Run("non-zero padding is invalid", func(t *testing.T) { - account, err := Encode(common.HexToAddress("0x1111111111111111111111111111111111111111"), 1) - require.NoError(t, err) - account[31] = 1 + t.Run("zero balance is invalid", func(t *testing.T) { + var account [AccountSize]byte + copy(account[usdBalanceSize:], common.HexToAddress("0x1111111111111111111111111111111111111111").Bytes()) + + _, _, _, err := Decode(account[:]) + require.ErrorContains(t, err, "zero balance") + }) + + t.Run("zero address is invalid", func(t *testing.T) { + var account [AccountSize]byte + account[0] = 1 - _, _, _, err = Decode(account[:]) - require.ErrorContains(t, err, "padding") + _, _, _, err := Decode(account[:]) + require.ErrorContains(t, err, "zero address") }) } func TestBuildProof_BuildsVerifiableAccountProof(t *testing.T) { addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222") - account1, err := Encode(addr1, 10) + account1, err := Encode(addr1, big.NewInt(10)) require.NoError(t, err) - account2, err := Encode(addr2, 20) + account2, err := Encode(addr2, big.NewInt(20)) require.NoError(t, err) drive := make([]byte, 1<<(Log2AccountSize+DefaultLog2MaxAccount)) @@ -74,7 +124,7 @@ func TestBuildProof_RejectsUnsupportedLayout(t *testing.T) { func TestBuildProof_RejectsNonCompactAccountTable(t *testing.T) { addr := common.HexToAddress("0x1111111111111111111111111111111111111111") - account, err := Encode(addr, 10) + account, err := Encode(addr, big.NewInt(10)) require.NoError(t, err) drive := make([]byte, 3*AccountSize) copy(drive[2*AccountSize:3*AccountSize], account[:]) From c8f8ffde58930692139f04c179e3f65fbca61557 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:54:00 -0300 Subject: [PATCH 20/41] fix(cli): clarify and confirm recovery actions Use the shared transaction path for foreclosure, drive-root proof, and withdrawal. Keep manual gas limits, check mined receipts by default, and expose --no-wait. Preserve signed hashes and put results on stdout. Require the Application drive-root event with the exact submitted root. Check its full event data, including the zero-root case. Test missing, malformed, unrelated, and matching events through the actual binding. Explain when FAILED blocks foreclosure work and require repair before operators clear it. Show the blocked state in application status output. --- .../root/app/status/status.go | 10 +- .../root/foreclose/foreclose.go | 87 +++++---- .../root/provedriveroot/provedriveroot.go | 105 ++++++++--- .../root/provedriveroot/receipt_test.go | 177 ++++++++++++++++++ .../root/withdraw/withdraw.go | 98 ++++++---- 5 files changed, 377 insertions(+), 100 deletions(-) create mode 100644 cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go diff --git a/cmd/cartesi-rollups-cli/root/app/status/status.go b/cmd/cartesi-rollups-cli/root/app/status/status.go index 7ba3482d5..5f65b2993 100644 --- a/cmd/cartesi-rollups-cli/root/app/status/status.go +++ b/cmd/cartesi-rollups-cli/root/app/status/status.go @@ -25,6 +25,10 @@ var Cmd = &cobra.Command{ Args: cobra.RangeArgs(1, 2), //nolint:mnd Run: run, Long: ` +FAILED stops local processing and foreclosure work. Repair the failure cause +and verify snapshot/database consistency before you enable the application. +Enabling clears FAILED. It does not repair state or cancel foreclosure. + Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string`, } @@ -36,7 +40,7 @@ cartesi-rollups-cli app status echo-dapp cartesi-rollups-cli app status echo-dapp enabled cartesi-rollups-cli app status echo-dapp disabled -# Re-enable a FAILED application without confirmation prompt: +# After repairing the cause, re-enable a FAILED application without a prompt: cartesi-rollups-cli app status echo-dapp enabled --yes` func init() { @@ -84,6 +88,9 @@ func run(cmd *cobra.Command, args []string) { } if app.ForecloseBlock != 0 { fmt.Printf("Foreclose block: 0x%x\n", app.ForecloseBlock) + if app.Status == model.ApplicationStatus_Failed { + fmt.Println("Foreclosure work is blocked by FAILED. Repair the cause before enabling the application.") + } if app.ForecloseTransaction != nil { fmt.Printf("Foreclose transaction: %s\n", app.ForecloseTransaction.Hex()) } @@ -127,6 +134,7 @@ func run(cmd *cobra.Command, args []string) { fmt.Printf("Reason: %s\n", *app.Reason) } fmt.Println("Re-enabling will attempt to restart processing from the last snapshot.") + fmt.Println("Repair the failure cause and verify snapshot/database consistency before you proceed.") confirmed, err := cli.ConfirmPrompt("Proceed?") if err != nil { fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) diff --git a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go index cd63eb2fd..c71632267 100644 --- a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go +++ b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go @@ -6,10 +6,10 @@ package foreclose import ( "encoding/json" "fmt" - "os" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" @@ -24,7 +24,7 @@ var Cmd = &cobra.Command{ Short: "Foreclose an application (guardian-only)", Example: examples, Args: cobra.ExactArgs(1), - Run: run, + RunE: run, Long: ` Calls IApplication.foreclose() on the application contract. The transaction must be signed by the guardian wallet configured at deploy time, otherwise it @@ -61,6 +61,7 @@ var ( func init() { Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -71,72 +72,92 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) - cobra.CheckErr(err) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) + if err != nil { + return err + } appContract, err := iapplication.NewIApplication(appAddr, client) - cobra.CheckErr(err) + if err != nil { + return err + } // Surface the guardian / signer mismatch early as a hint, instead of letting // the on-chain revert produce an opaque "NotGuardian" error. guardian, err := appContract.GetGuardian(&bind.CallOpts{Context: ctx}) - cobra.CheckErr(err) + if err != nil { + return err + } if guardian != txOpts.From { - fmt.Fprintf(os.Stderr, + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: signer %s does not match the application guardian %s — foreclose() will revert with NotGuardian\n", txOpts.From, guardian) + if err != nil { + return err + } } if !skipConfirmation { - fmt.Printf("Preparing to foreclose application %v with signer %v\n", + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to foreclose application %v with signer %v\n", appAddr, txOpts.From) + if err != nil { + return err + } - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err } } - tx, err := appContract.Foreclose(txOpts) - // go-ethereum's binding returns (signedTx, sendErr) when signing - // succeeded but the broadcast/response read failed — the tx may already - // be in the mempool. Surface the hash on stderr so the operator can find - // it even when CheckErr below aborts. - if tx != nil { - fmt.Fprintf(os.Stderr, "broadcast attempt sent — tx hash %s\n", tx.Hash().Hex()) + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.Foreclose(opts) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) } - cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - txHash := tx.Hash() if asJSONParam { result := struct { - TransactionHash string `json:"transaction_hash"` + cli.TransactionResult ApplicationAddr common.Address `json:"application_address"` - }{TransactionHash: txHash.Hex(), ApplicationAddr: appAddr} - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("Foreclose tx-hash: %v\n", txHash) + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) } + return cli.WriteTransactionResult(cmd, tx, receipt) } diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go index 05a995b9a..d47f240c5 100644 --- a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go @@ -9,8 +9,10 @@ import ( "fmt" "os" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" @@ -25,7 +27,7 @@ var Cmd = &cobra.Command{ Short: "Anchor the accounts-drive Merkle root on a foreclosed application", Example: examples, Args: cobra.ExactArgs(1), - Run: run, + RunE: run, Long: ` Calls IApplication.proveAccountsDriveMerkleRoot(accountsDriveMerkleRoot, proof). This must be done ONCE per foreclosed application before any user can call @@ -72,6 +74,7 @@ func init() { cobra.CheckErr(Cmd.MarkFlagRequired("proof-file")) Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -82,67 +85,107 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } root, proof, err := loadProof(proofFileParam) - cobra.CheckErr(err) + if err != nil { + return err + } appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) - cobra.CheckErr(err) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) + if err != nil { + return err + } appContract, err := iapplication.NewIApplication(appAddr, client) - cobra.CheckErr(err) + if err != nil { + return err + } if !skipConfirmation { - fmt.Printf("Preparing to prove the accounts-drive Merkle root for application %v\n"+ + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to prove the accounts-drive Merkle root for application %v\n"+ " signer: %v\n"+ " root: 0x%x\n"+ " proof size: %d siblings\n", appAddr, txOpts.From, root, len(proof)) - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) + if err != nil { + return err + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err } } - tx, err := appContract.ProveAccountsDriveMerkleRoot(txOpts, root, proof) - // go-ethereum's binding returns (signedTx, sendErr) when signing - // succeeded but the broadcast/response read failed — the tx may already - // be in the mempool. Surface the hash on stderr so the operator can find - // it even when CheckErr below aborts. - if tx != nil { - fmt.Fprintf(os.Stderr, "broadcast attempt sent — tx hash %s\n", tx.Hash().Hex()) + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.ProveAccountsDriveMerkleRoot(opts, root, proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) + } + if receipt != nil && !hasMatchingDriveRootEvent(appContract, receipt, appAddr, root) { + return fmt.Errorf("transaction %s mined, but its receipt has no matching AccountsDriveMerkleRootProved event", tx.Hash()) } - cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - txHash := tx.Hash() if asJSONParam { result := struct { - TransactionHash string `json:"transaction_hash"` + cli.TransactionResult ApplicationAddr common.Address `json:"application_address"` - }{TransactionHash: txHash.Hex(), ApplicationAddr: appAddr} - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("prove-drive-root tx-hash: %v\n", txHash) + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} + +func hasMatchingDriveRootEvent( + application *iapplication.IApplication, + receipt *types.Receipt, + address common.Address, + root [32]byte, +) bool { + for _, log := range receipt.Logs { + // The event has one non-indexed bytes32 field. Reject missing data even + // for a zero root; the binding otherwise leaves that field zero-valued. + if log == nil || log.Address != address || len(log.Data) != common.HashLength { + continue + } + event, err := application.ParseAccountsDriveMerkleRootProved(*log) + if err == nil && event.AccountsDriveMerkleRoot == root { + return true + } } + return false } func loadProof(path string) ([32]byte, [][32]byte, error) { diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go b/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go new file mode 100644 index 000000000..a63be077f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go @@ -0,0 +1,177 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package provedriveroot + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +func TestCommandRequiresMatchingDriveRootEvent(t *testing.T) { + address := common.HexToAddress("0x1234") + root := common.HexToHash("0x42") + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["AccountsDriveMerkleRootProved"] + matching := &types.Log{Address: address, Topics: []common.Hash{event.ID}, Data: root.Bytes()} + wrongEmitter := &types.Log{Address: common.HexToAddress("0x5678"), Topics: matching.Topics, Data: matching.Data} + wrongRoot := &types.Log{Address: address, Topics: matching.Topics, Data: common.HexToHash("0x43").Bytes()} + wrongTopic := &types.Log{Address: address, Topics: []common.Hash{common.HexToHash("0x01")}, Data: matching.Data} + malformed := &types.Log{Address: address, Topics: matching.Topics, Data: []byte{0x42}} + emptyData := &types.Log{Address: address, Topics: matching.Topics} + extraData := &types.Log{Address: address, Topics: matching.Topics, Data: append(root.Bytes(), root.Bytes()...)} + for _, test := range []struct { + name string + logs []*types.Log + estimated bool + noWait bool + zeroRoot bool + wantErr bool + }{ + {name: "matching manual gas", logs: []*types.Log{matching}}, + {name: "matching estimated gas", logs: []*types.Log{matching}, estimated: true}, + {name: "empty receipt at EOA", logs: []*types.Log{}, wantErr: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantErr: true}, + {name: "wrong root", logs: []*types.Log{wrongRoot}, wantErr: true}, + {name: "wrong event", logs: []*types.Log{wrongTopic}, wantErr: true}, + {name: "malformed data", logs: []*types.Log{malformed}, wantErr: true}, + {name: "empty data for zero root", logs: []*types.Log{emptyData}, zeroRoot: true, wantErr: true}, + {name: "extra data", logs: []*types.Log{extraData}, wantErr: true}, + {name: "later match", logs: []*types.Log{nil, wrongEmitter, wrongRoot, wrongTopic, malformed, matching}}, + {name: "no wait with empty receipt", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &driveRootRPC{logs: test.logs, sent: make(chan *types.Transaction, 1), emptyCode: len(test.logs) == 0} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", service)) + defer server.Stop() + httpServer := httptest.NewServer(server) + defer httpServer.Close() + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, httpServer.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.DATABASE_CONNECTION, "invalid") + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + if test.estimated { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + } + Cmd.Flags().VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) + Cmd.SilenceUsage = true + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + proofRoot := root + if test.zeroRoot { + proofRoot = common.Hash{} + } + proof, err := json.Marshal(proveDriveRootJSON{AccountsDriveMerkleRoot: proofRoot.Hex(), Proof: []string{root.Hex()}}) + require.NoError(t, err) + args := []string{address.Hex(), "--proof-file", writeProofFile(t, string(proof)), "--yes", "--json"} + if test.noWait { + args = append(args, "--no-wait") + } + Cmd.SetArgs(args) + err = Cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.EqualValues(t, 123456, tx.Gas()) + if test.estimated { + require.EqualValues(t, 1, service.estimates.Load()) + } else { + require.Zero(t, service.estimates.Load()) + } + require.Contains(t, stderr.String(), tx.Hash().Hex()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantErr { + require.ErrorContains(t, err, "no matching AccountsDriveMerkleRootProved event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + status := "mined" + if test.noWait { + status = "broadcast" + } + require.Equal(t, status, result["status"]) + }) + } +} + +type driveRootRPC struct { + logs []*types.Log + sent chan *types.Transaction + emptyCode bool + estimates atomic.Int32 + receipts atomic.Int32 +} + +func (*driveRootRPC) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*driveRootRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: 1_000_000} +} + +func (*driveRootRPC) GasPrice(context.Context) *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1)) +} + +func (r *driveRootRPC) GetCode(context.Context, common.Address, string) hexutil.Bytes { + if r.emptyCode { + return hexutil.Bytes{} + } + return hexutil.Bytes{0x01} +} + +func (*driveRootRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (r *driveRootRPC) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + r.estimates.Add(1) + return 123456 +} + +func (r *driveRootRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), nil +} + +func (r *driveRootRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + r.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(10), Logs: r.logs} +} diff --git a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go index 66a968c41..86d92ba0c 100644 --- a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go +++ b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go @@ -13,6 +13,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" @@ -28,7 +29,7 @@ var Cmd = &cobra.Command{ Short: "Withdraw the funds of a single account from a foreclosed application", Example: examples, Args: cobra.ExactArgs(1), - Run: run, + RunE: run, Long: ` Calls IApplication.withdraw(account, AccountValidityProof). The signer is just the gas-payer; the recipient of the funds is encoded inside the 'account' @@ -79,6 +80,7 @@ func init() { cobra.CheckErr(Cmd.MarkFlagRequired("proof-file")) Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -89,53 +91,77 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } account, proof, err := loadProof(proofFileParam) - cobra.CheckErr(err) + if err != nil { + return err + } appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) - cobra.CheckErr(err) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) + if err != nil { + return err + } appContract, err := iapplication.NewIApplication(appAddr, client) - cobra.CheckErr(err) + if err != nil { + return err + } // Identify the WithdrawalOutputBuilder and try to surface a decoded // recipient + amount. A hand-edit that flips a few characters in // `account` would otherwise produce a self-consistent proof against // the wrong recipient and the withdraw would silently succeed. builderAddr, err := appContract.GetWithdrawalOutputBuilder(&bind.CallOpts{Context: ctx}) - cobra.CheckErr(err) + if err != nil { + return err + } accountDesc, matched, err := ethutil.DescribeWithdrawalAccount(ctx, client, builderAddr, account) - cobra.CheckErr(err) + if err != nil { + return err + } if !matched { // Unknown builder family. Print the raw bytes so the operator can // verify character-for-character, and force interactive // confirmation even when --yes is set. - fmt.Fprintf(os.Stderr, + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: builder %s is not a recognized WithdrawalOutputBuilder family.\n"+ " The recipient cannot be auto-decoded. Verify the bytes below\n"+ " match your intended account before confirming; --yes is ignored.\n%s", builderAddr, hex.Dump(account)) + if err != nil { + return err + } } if !skipConfirmation || !matched { - fmt.Printf("Preparing to withdraw an account from application %v\n"+ + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to withdraw an account from application %v\n"+ " gas-payer: %v (does NOT have to be the funds recipient)\n"+ " withdrawal builder: %v\n"+ " account size: %d bytes\n"+ @@ -143,39 +169,41 @@ func run(cmd *cobra.Command, args []string) { " proof siblings: %d\n", appAddr, txOpts.From, builderAddr, len(account), proof.AccountIndex, len(proof.AccountRootSiblings)) + if err != nil { + return err + } if matched { - fmt.Println(accountDesc) + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), accountDesc); err != nil { + return err + } + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr } - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err } } - tx, err := appContract.Withdraw(txOpts, account, proof) - // go-ethereum's binding returns (signedTx, sendErr) when signing - // succeeded but the broadcast/response read failed — the tx may already - // be in the mempool. Surface the hash on stderr so the operator can find - // it even when CheckErr below aborts. - if tx != nil { - fmt.Fprintf(os.Stderr, "broadcast attempt sent — tx hash %s\n", tx.Hash().Hex()) + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.Withdraw(opts, account, proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) } - cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - txHash := tx.Hash() if asJSONParam { result := struct { - TransactionHash string `json:"transaction_hash"` + cli.TransactionResult ApplicationAddr common.Address `json:"application_address"` - }{TransactionHash: txHash.Hex(), ApplicationAddr: appAddr} - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("withdraw tx-hash: %v\n", txHash) + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) } + return cli.WriteTransactionResult(cmd, tx, receipt) } func loadProof(path string) ([]byte, iapplication.AccountValidityProof, error) { From 4e10ce4ed5bd3d457ed6517a83f3f120bdb67f6d Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:17 -0300 Subject: [PATCH 21/41] feat(cli): refund unfinalized deposits Add a refund command for deposits left unfinalized after foreclosure. Submit issueRefund with the application-wide input index and complete original input bytes. The contract checks eligibility and constructs the refund output; the CLI does not duplicate those checks. Use the shared signing, confirmation, broadcast, and receipt policy. Require an Application RefundIssued event with the exact index and input before reporting a mined result. Preserve manual gas and strict no-wait. Test argument parsing, signed calldata, output, and receipt evidence. Authority, Quorum, and PRT lifecycle tests follow in the integration section of this series. --- cmd/cartesi-rollups-cli/root/refund/refund.go | 230 ++++++++ .../root/refund/refund_test.go | 497 ++++++++++++++++++ cmd/cartesi-rollups-cli/root/root.go | 2 + 3 files changed, 729 insertions(+) create mode 100644 cmd/cartesi-rollups-cli/root/refund/refund.go create mode 100644 cmd/cartesi-rollups-cli/root/refund/refund_test.go diff --git a/cmd/cartesi-rollups-cli/root/refund/refund.go b/cmd/cartesi-rollups-cli/root/refund/refund.go new file mode 100644 index 000000000..5844be787 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/refund/refund.go @@ -0,0 +1,230 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package refund submits refunds for deposits in a foreclosed application. +package refund + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "os" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +var Cmd = newCommand() + +type options struct { + inputFile string + skipConfirmation bool + asJSON bool +} + +func newCommand() *cobra.Command { + var opts options + cmd := &cobra.Command{ + Use: "refund ", + Short: "Refund an unfinalized deposit from a foreclosed application", + Args: cobra.ExactArgs(2), //nolint:mnd // Application and input index. + RunE: opts.run, + Long: `Calls IApplication.issueRefund(inputIndex, input). The guardian must have +already foreclosed the application, and consensus must not have finalized +the deposit input. Local ACCEPTED or REJECTED status does not determine +whether the input is finalized. + +Any funded signer can pay the gas. The canonical refund builder sends the +funds to the original depositor recorded by the portal. The gas payer does +not have to be the depositor or guardian and cannot choose a new recipient. +No machine, output, accounts-drive, or account proof is required. + +The application argument accepts a name (resolved through the local database) +or an Ethereum address (no database access). The input index is application-wide, +not epoch-relative, and accepts decimal or 0x-prefixed hexadecimal uint256 values. + +--input-file must contain the complete original InputAdded.input bytes as +0x-prefixed hexadecimal text, with optional surrounding whitespace. Use the +node's data.raw_data field, not decoded_data.payload, the portal payload, +deposit transaction calldata, or JSON. + +The command waits for a successful mined receipt by default. With --no-wait, +it returns after broadcast without confirmation of payment. In that case, +check the receipt, RefundIssued event, and wasRefundForInputIssued on chain. + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Required only for an application name + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum HTTP endpoint + CARTESI_AUTH_KIND Signer authentication method + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX Derived mnemonic account index`, + Example: `# Export the complete input through the node JSON-RPC API: +cartesi-rollups-cli read inputs echo-dapp 7 --jsonrpc | jq -er '.data.raw_data' > deposit-input.hex + +# Refund after the guardian has foreclosed the application: +cartesi-rollups-cli refund echo-dapp 7 --input-file deposit-input.hex --yes --json`, + } + cmd.Flags().StringVar(&opts.inputFile, "input-file", "", "File containing the complete 0x-prefixed InputAdded.input hex") + cobra.CheckErr(cmd.MarkFlagRequired("input-file")) + cmd.Flags().BoolVarP(&opts.skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Print the transaction hash, application address, and input index as JSON") + cli.AddTransactionFlags(cmd) + origHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + for _, name := range []string{"database-connection", "blockchain-http-endpoint", "gas-limit"} { + if flag := command.Flags().Lookup(name); flag != nil { + flag.Hidden = false + } + } + origHelp(command, args) + }) + return cmd +} + +func (o *options) run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + inputIndex, err := parseInputIndex(args[1]) + if err != nil { + return err + } + input, err := loadInput(o.inputFile) + if err != nil { + return err + } + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } + endpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } + client, err := ethclient.DialContext(ctx, endpoint.Raw()) + if err != nil { + return fmt.Errorf("connect to Ethereum: %w", err) + } + defer client.Close() + chainID, err := client.ChainID(ctx) + if err != nil { + return fmt.Errorf("read chain ID: %w", err) + } + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return fmt.Errorf("prepare refund signer: %w", err) + } + appContract, err := iapplication.NewIApplication(appAddr, client) + if err != nil { + return fmt.Errorf("bind application: %w", err) + } + if !o.skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to refund input %s from application %s\n"+ + " gas payer: %s\n"+ + "The canonical refund builder pays the original depositor; the gas payer cannot change the recipient.\n", + inputIndex, appAddr, txOpts.From) + if err != nil { + return err + } + confirmed, err := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.IssueRefund(opts, inputIndex, input) + }) + if err != nil { + return fmt.Errorf("refund: %w", cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + } + if receipt != nil && !hasMatchingRefundEvent(appContract, receipt, appAddr, inputIndex, input) { + return fmt.Errorf("transaction %s mined, but its receipt has no matching RefundIssued event for input %s", tx.Hash(), inputIndex) + } + if o.asJSON { + result := struct { + cli.TransactionResult + ApplicationAddr common.Address `json:"application_address"` + InputIndex string `json:"input_index"` + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr, InputIndex: inputIndex.String()} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + return fmt.Errorf("write refund result: %w", err) + } + } else { + return cli.WriteTransactionResult(cmd, tx, receipt) + } + return nil +} + +func hasMatchingRefundEvent( + application *iapplication.IApplication, + receipt *types.Receipt, + address common.Address, + inputIndex *big.Int, + input []byte, +) bool { + for _, log := range receipt.Logs { + if log == nil || log.Address != address { + continue + } + event, err := application.ParseRefundIssued(*log) + if err == nil && event.InputIndex.Cmp(inputIndex) == 0 && bytes.Equal(event.Input, input) { + return true + } + } + return false +} + +func parseInputIndex(raw string) (*big.Int, error) { + const uint256Bits = 256 + base, digits := 10, raw + if strings.HasPrefix(raw, "0x") { + base, digits = 16, raw[2:] + } + if digits == "" || strings.HasPrefix(digits, "+") || strings.HasPrefix(digits, "-") { + return nil, fmt.Errorf("invalid input index: expected a decimal or 0x-prefixed uint256") + } + index, ok := new(big.Int).SetString(digits, base) + if !ok || index.Sign() < 0 || index.BitLen() > uint256Bits { + return nil, fmt.Errorf("invalid input index: expected a decimal or 0x-prefixed uint256") + } + return index, nil +} + +func loadInput(path string) ([]byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read input file: %w", err) + } + encoded := strings.TrimSpace(string(raw)) + if !strings.HasPrefix(encoded, "0x") { + return nil, fmt.Errorf("invalid input file: expected 0x-prefixed hexadecimal InputAdded.input bytes") + } + input, err := hexutil.Decode(encoded) + if err != nil { + return nil, fmt.Errorf("invalid input file: %w", err) + } + if len(input) == 0 { + return nil, fmt.Errorf("invalid input file: input bytes cannot be empty") + } + return input, nil +} diff --git a/cmd/cartesi-rollups-cli/root/refund/refund_test.go b/cmd/cartesi-rollups-cli/root/refund/refund_test.go new file mode 100644 index 000000000..fa54691cd --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/refund/refund_test.go @@ -0,0 +1,497 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package refund + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testChainID = 31337 + testGasLimit = 90000 + inputFileFlag = "--input-file" + yesFlag = "--yes" + jsonFlag = "--json" + + maxUint256Decimal = "115792089237316195423570985008687907853269984665640564039457584007913129639935" +) + +func TestParseInputIndex(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"0", "0"}, + {"0x0", "0"}, + {"007", "7"}, + {"0xFF", "255"}, + {"18446744073709551616", "18446744073709551616"}, + {maxUint256Decimal, maxUint256Decimal}, + {"0x" + strings.Repeat("f", 64), maxUint256Decimal}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + index, err := parseInputIndex(tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, index.String()) + }) + } + for _, input := range []string{ + "", "-1", "+1", "-0", "0x", "0x-1", "0x+1", "0xgg", "0b10", "1_0", " 7", "7 ", + "0x1" + strings.Repeat("0", 64), + "115792089237316195423570985008687907853269984665640564039457584007913129639936", + } { + t.Run("reject_"+input, func(t *testing.T) { + index, err := parseInputIndex(input) + require.ErrorContains(t, err, "invalid input index") + require.Nil(t, index) + }) + } +} + +func writeInputFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "input.hex") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func originalInput(t *testing.T) []byte { + t.Helper() + inputABI, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + input, err := inputABI.Pack("EvmAdvance", big.NewInt(testChainID), common.HexToAddress(testApplication), + common.HexToAddress("0x2"), big.NewInt(10), big.NewInt(20), big.NewInt(0), big.NewInt(7), []byte{0xaa, 0xbb}) + require.NoError(t, err) + return input +} + +func TestLoadInput(t *testing.T) { + input := originalInput(t) + loaded, err := loadInput(writeInputFile(t, " \t\n"+hexutil.Encode(input)+"\r\n ")) + require.NoError(t, err) + require.Equal(t, input, loaded) + for _, contents := range []string{"", " \n", "0x", "aabb", "0Xaa", "0xabc", "0xgg", "0xaa bb", `"0xaa"`, `{}`} { + t.Run("reject_"+contents, func(t *testing.T) { + loaded, err := loadInput(writeInputFile(t, contents)) + require.ErrorContains(t, err, "invalid input file") + require.Nil(t, loaded) + }) + } + _, err = loadInput(filepath.Join(t.TempDir(), "missing.hex")) + require.ErrorContains(t, err, "read input file") + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestCommandRejectsArgumentsBeforeEthereum(t *testing.T) { + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, "invalid-endpoint") + validFile := writeInputFile(t, hexutil.Encode(originalInput(t))) + tests := []struct { + name string + args []string + want string + }{ + {"missing arguments", nil, "accepts 2 arg(s)"}, + {"missing file flag", []string{testApplication, "7"}, `required flag(s) "input-file" not set`}, + {"invalid application", []string{"0x1234", "7", inputFileFlag, validFile}, "invalid Ethereum address"}, + {"invalid index", []string{testApplication, "bad", inputFileFlag, validFile}, "invalid input index"}, + {"missing file", []string{testApplication, "7", inputFileFlag, filepath.Join(t.TempDir(), "missing")}, "read input file"}, + {"invalid file", []string{testApplication, "7", inputFileFlag, writeInputFile(t, "{}")}, "invalid input file"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(append([]string{}, tt.args...)) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), tt.want) + }) + } +} + +// refundRPC serves only the Ethereum methods used by the real generated binding. +// It never connects to a chain or sends a transaction outside this test server. +type refundRPC struct { + estimateErr error + sendErr error + sent chan *types.Transaction + estimates atomic.Int32 + receipts atomic.Int32 + failed bool + pending bool + logs []*types.Log + emptyCode bool +} + +func (*refundRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(testChainID)), nil +} + +func (*refundRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*refundRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (r *refundRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + if r.emptyCode { + return hexutil.Bytes{}, nil + } + return hexutil.Bytes{0x01}, nil +} + +func (r *refundRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, r.estimateErr +} + +func (r *refundRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + if r.pending { + return nil, nil + } + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + return &types.Receipt{ + TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: r.logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit, + }, nil +} + +func (*refundRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return 0, nil +} + +func (r *refundRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), r.sendErr +} + +type refundRevert struct { + data string +} + +func (*refundRevert) Error() string { return "execution reverted" } +func (*refundRevert) ErrorCode() int { return 3 } +func (e *refundRevert) ErrorData() any { return e.data } + +func setupRPC(t *testing.T, service *refundRPC) common.Address { + t.Helper() + if service.logs == nil { + service.logs = []*types.Log{refundEventLog(t, common.HexToAddress(testApplication), big.NewInt(7), originalInput(t))} + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + return crypto.PubkeyToAddress(key.PublicKey) +} + +func refundEventLog(t *testing.T, address common.Address, index *big.Int, input []byte) *types.Log { + t.Helper() + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["RefundIssued"] + data, err := event.Inputs.NonIndexed().Pack(input, []byte{0xab}) + require.NoError(t, err) + return &types.Log{Address: address, Topics: []common.Hash{event.ID, common.BigToHash(index)}, Data: data} +} + +func TestCommandRequiresMatchingRefundEvent(t *testing.T) { + address := common.HexToAddress(testApplication) + input := originalInput(t) + matching := refundEventLog(t, address, big.NewInt(7), input) + wrongEmitter := refundEventLog(t, common.HexToAddress("0x1234"), big.NewInt(7), input) + wrongIndex := refundEventLog(t, address, big.NewInt(8), input) + wrongInput := refundEventLog(t, address, big.NewInt(7), []byte{0x12}) + malformed := &types.Log{Address: address, Topics: matching.Topics, Data: []byte{0x01}} + wrongTopic := &types.Log{Address: address, Topics: []common.Hash{common.HexToHash("0x12")}, Data: matching.Data} + missingIndex := &types.Log{Address: address, Topics: matching.Topics[:1], Data: matching.Data} + for _, test := range []struct { + name string + logs []*types.Log + noWait bool + wantErr bool + }{ + {name: "matching", logs: []*types.Log{matching}}, + {name: "empty receipt at EOA", logs: []*types.Log{}, wantErr: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantErr: true}, + {name: "wrong index", logs: []*types.Log{wrongIndex}, wantErr: true}, + {name: "wrong input", logs: []*types.Log{wrongInput}, wantErr: true}, + {name: "wrong event", logs: []*types.Log{wrongTopic}, wantErr: true}, + {name: "malformed", logs: []*types.Log{malformed}, wantErr: true}, + {name: "missing index", logs: []*types.Log{missingIndex}, wantErr: true}, + {name: "later match", logs: []*types.Log{nil, wrongEmitter, wrongIndex, wrongInput, malformed, wrongTopic, missingIndex, matching}}, + {name: "no wait with empty receipt", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), logs: test.logs, emptyCode: len(test.logs) == 0} + setupRPC(t, service) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + t.Setenv(config.DATABASE_CONNECTION, "invalid") + cmd := newCommand() + cmd.SilenceUsage = true + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(input)), yesFlag, jsonFlag} + if test.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.EqualValues(t, 123456, tx.Gas()) + require.Zero(t, service.estimates.Load()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantErr { + require.ErrorContains(t, err, "no matching RefundIssued event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + status := "mined" + if test.noWait { + status = "broadcast" + } + require.Equal(t, status, result["status"]) + }) + } +} + +func TestCommandSignsOriginalInput(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1)} + signer := setupRPC(t, service) + input := originalInput(t) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "0x7", inputFileFlag, writeInputFile(t, hexutil.Encode(input)), yesFlag, jsonFlag}) + require.NoError(t, cmd.ExecuteContext(t.Context())) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Equal(t, common.HexToAddress(testApplication), *tx.To()) + require.Zero(t, tx.Value().Sign()) + require.Equal(t, uint64(testGasLimit), tx.Gas()) + from, err := types.Sender(types.LatestSignerForChainID(big.NewInt(testChainID)), tx) + require.NoError(t, err) + require.Equal(t, signer, from) + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + method, err := appABI.MethodById(tx.Data()[:4]) + require.NoError(t, err) + require.Equal(t, "issueRefund", method.Name) + args, err := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, err) + require.Equal(t, big.NewInt(7), args[0]) + require.Equal(t, input, args[1]) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, map[string]string{ + "transaction_hash": tx.Hash().Hex(), "application_address": testApplication, "input_index": "7", + "status": "mined", "block_number": "0xa", + }, result) + require.EqualValues(t, 1, service.estimates.Load()) + require.EqualValues(t, 1, service.receipts.Load()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) +} + +func TestCommandReceiptPolicy(t *testing.T) { + for _, tt := range []struct { + name string + manualGas bool + noWait bool + failed bool + pending bool + wantErr string + }{ + {name: "estimated mined"}, + {name: "manual gas mined", manualGas: true}, + {name: "manual gas receipt failure", manualGas: true, failed: true, wantErr: "failed in block"}, + {name: "estimated receipt failure", failed: true, wantErr: "failed in block"}, + {name: "estimated broadcast only", noWait: true, pending: true}, + {name: "manual gas broadcast only", manualGas: true, noWait: true, pending: true}, + {name: "pending timeout", pending: true, wantErr: "outcome unknown"}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), failed: tt.failed, pending: tt.pending} + setupRPC(t, service) + if tt.manualGas { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag, jsonFlag} + if tt.noWait { + args = append(args, "--no-wait") + } + if tt.pending && !tt.noWait { + args = append(args, "--wait-timeout", "20ms") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + if tt.manualGas { + require.EqualValues(t, 123456, tx.Gas()) + require.Zero(t, service.estimates.Load()) + } else { + require.EqualValues(t, testGasLimit, tx.Gas()) + require.EqualValues(t, 1, service.estimates.Load()) + } + if tt.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.Positive(t, service.receipts.Load()) + } + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + if tt.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + } + }) + } +} + +// This subprocess keeps Cobra's default writers. SetOut would hide a regression +// from OutOrStdout back to Printf, because Cobra Printf defaults to stderr. +func TestCommandDefaultOutputProcess(t *testing.T) { + if os.Getenv("CARTESI_TEST_REFUND_OUTPUT_PROCESS") != "1" { + return + } + cmd := newCommand() + for i, arg := range os.Args { + if arg == "--" { + cmd.SetArgs(os.Args[i+1:]) + break + } + } + if err := cmd.ExecuteContext(t.Context()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func TestCommandPlainResultUsesDefaultStdout(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1)} + setupRPC(t, service) + t.Setenv("CARTESI_TEST_REFUND_OUTPUT_PROCESS", "1") + //nolint:gosec // Runs this test binary with fixed arguments and a local fixture path, without a shell. + process := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestCommandDefaultOutputProcess$", "--", + testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + require.NoError(t, process.Run(), stderr.String()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Equal(t, "Transaction mined: "+tx.Hash().Hex()+"\n", stdout.String()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.NotContains(t, stderr.String(), "Transaction mined:") +} + +func TestCommandReportsRefundReverts(t *testing.T) { + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + for _, name := range []string{"NotForeclosed", "CannotRefundFinalizedInput", "RefundAlreadyIssued"} { + t.Run(name, func(t *testing.T) { + contractError := appABI.Errors[name] + data := append([]byte(nil), contractError.ID[:4]...) + if len(contractError.Inputs) > 0 { + args, err := contractError.Inputs.Pack(big.NewInt(7)) + require.NoError(t, err) + data = append(data, args...) + } + service := &refundRPC{sent: make(chan *types.Transaction, 1), estimateErr: &refundRevert{data: hexutil.Encode(data)}} + setupRPC(t, service) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "decoded revert: "+name) + require.Empty(t, service.sent) + require.NotContains(t, stdout.String(), "Refund tx-hash") + }) + } +} + +func TestCommandKeepsHashOnBroadcastError(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), sendErr: errors.New("response unavailable")} + setupRPC(t, service) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag, jsonFlag}) + err := cmd.ExecuteContext(t.Context()) + require.ErrorContains(t, err, "response unavailable") + require.Len(t, service.sent, 1) + tx := <-service.sent + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) +} diff --git a/cmd/cartesi-rollups-cli/root/root.go b/cmd/cartesi-rollups-cli/root/root.go index d266a6b1a..0258eb11b 100644 --- a/cmd/cartesi-rollups-cli/root/root.go +++ b/cmd/cartesi-rollups-cli/root/root.go @@ -14,6 +14,7 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/inspect" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/provedriveroot" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/refund" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/send" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/validate" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/withdraw" @@ -76,6 +77,7 @@ func init() { Cmd.AddCommand(validate.Cmd) Cmd.AddCommand(execute.Cmd) Cmd.AddCommand(foreclose.Cmd) + Cmd.AddCommand(refund.Cmd) Cmd.AddCommand(provedriveroot.Cmd) Cmd.AddCommand(withdraw.Cmd) Cmd.AddCommand(app.Cmd) From b2efe998cd7671cb25ab6a234cb9d984b26fc1a3 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:26:06 -0300 Subject: [PATCH 22/41] test(cli): verify transaction command behavior Exercise all ten transaction command forms through the actual root in isolated processes. Check shared help, explicit help, and failed broadcast and receipt errors. Preserve the signed hash without usage or a success result. Place these cross-command tests after all CLI implementations. --- .../root/root_transaction_test.go | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 cmd/cartesi-rollups-cli/root/root_transaction_test.go diff --git a/cmd/cartesi-rollups-cli/root/root_transaction_test.go b/cmd/cartesi-rollups-cli/root/root_transaction_test.go new file mode 100644 index 000000000..193d9dce3 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/root_transaction_test.go @@ -0,0 +1,194 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "bytes" + "context" + "errors" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +const ( + rootCommandProcess = "CARTESI_TEST_ROOT_COMMAND_PROCESS" + rootTestChainID = 31337 + rootTestGasLimit = 500000 + rootTestPrivateKey = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" +) + +// Run each invocation in a fresh process. The real command tree has global +// Cobra flags and Viper bindings. Do not reset or replace either in these tests. +func TestRootCommandProcess(t *testing.T) { + if os.Getenv(rootCommandProcess) != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + Cmd.SetArgs(os.Args[i+1:]) + if err := Cmd.ExecuteContext(t.Context()); err != nil { + os.Exit(1) + } + os.Exit(0) + } + } + t.Fatal("missing subprocess argument separator") +} + +func runRootCommandProcess(t *testing.T, extraEnv []string, args ...string) (string, string, error) { + t.Helper() + executable, err := os.Executable() + require.NoError(t, err) + const processTimeout = 20 * time.Second + ctx, cancel := context.WithTimeout(t.Context(), processTimeout) + defer cancel() + processArgs := append([]string{"-test.run=^TestRootCommandProcess$", "--"}, args...) + process := exec.CommandContext(ctx, executable, processArgs...) + // Each covered child must have its own output directory. Concurrent children + // can otherwise collide when they write coverage metadata at process exit. + process.Env = append(os.Environ(), rootCommandProcess+"=1", "GOCOVERDIR="+t.TempDir()) + process.Env = append(process.Env, extraEnv...) + var stdout, stderr bytes.Buffer + process.Stdout, process.Stderr = &stdout, &stderr + err = process.Run() + return stdout.String(), stderr.String(), err +} + +func TestRootTransactionHelp(t *testing.T) { + t.Parallel() + for _, command := range []string{ + "send", "execute", "deposit erc20", "foreclose", "prove-drive-root", "withdraw", "refund", + "deploy application", "deploy authority", "deploy quorum", + } { + t.Run(command, func(t *testing.T) { + t.Parallel() + args := append(strings.Fields(command), "--help") + stdout, stderr, err := runRootCommandProcess(t, nil, args...) + require.NoError(t, err, stderr) + require.Empty(t, stderr) + require.Contains(t, stdout, "Usage:") + help := strings.Join(strings.Fields(stdout), " ") + require.Contains(t, help, "--gas-limit") + require.Contains(t, help, "Zero estimates gas; a nonzero value skips estimation.") + require.Contains(t, help, "--no-wait") + require.Contains(t, help, "Return after broadcast without checking execution success") + require.Contains(t, help, "--wait-timeout") + require.NotContains(t, help, "--async") + }) + } +} + +func TestRootHelpRemainsAvailable(t *testing.T) { + t.Parallel() + stdout, stderr, err := runRootCommandProcess(t, nil, "--help") + require.NoError(t, err, stderr) + require.Empty(t, stderr) + require.Contains(t, stdout, "Usage:") + require.Contains(t, stdout, "Available Commands:") +} + +func TestRootTransactionErrorsKeepHashWithoutUsage(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + sendErr error + want string + }{ + {name: "broadcast", sendErr: errors.New("broadcast response lost"), want: "broadcast response lost"}, + {name: "mined failure", want: "receipt status 0"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + backend := &rootTransactionRPC{sendErr: test.sendErr, sent: make(chan *types.Transaction, 1)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + inputFile := filepath.Join(t.TempDir(), "input.hex") + require.NoError(t, os.WriteFile(inputFile, []byte("0xaabb\n"), 0o600)) + application := common.HexToAddress("0x01") + stdout, stderr, err := runRootCommandProcess(t, []string{ + config.AUTH_KIND + "=private_key", + config.AUTH_PRIVATE_KEY + "=" + rootTestPrivateKey, + }, "refund", application.Hex(), "7", "--input-file", inputFile, + "--blockchain-http-endpoint", httpServer.URL, "--gas-limit", "500000", "--yes", "--json") + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode(), stderr) + require.Empty(t, stdout, "a failed transaction must not produce a success result") + require.Len(t, backend.sent, 1, stderr) + tx := <-backend.sent + require.Equal(t, &application, tx.To()) + require.EqualValues(t, rootTestGasLimit, tx.Gas()) + _, err = types.Sender(types.LatestSignerForChainID(big.NewInt(rootTestChainID)), tx) + require.NoError(t, err, "the provider must receive a signed transaction") + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + calldata, err := appABI.Pack("issueRefund", big.NewInt(7), []byte{0xaa, 0xbb}) + require.NoError(t, err) + require.Equal(t, calldata, tx.Data()) + require.Contains(t, stderr, "Transaction hash: "+tx.Hash().Hex()) + require.Contains(t, stderr, "Error: refund:") + require.Contains(t, stderr, test.want) + require.GreaterOrEqual(t, strings.Count(stderr, tx.Hash().Hex()), 2, + "the returned error must retain the hash in addition to the pre-broadcast diagnostic") + require.NotContains(t, stderr, "Usage:", "runtime errors must not dump command help") + }) + } +} + +// This local RPC server lets the actual refund binding sign and submit its +// calldata. It reports either a lost broadcast response or a failed receipt. +// No request reaches a blockchain or a database. +type rootTransactionRPC struct { + sendErr error + sent chan *types.Transaction +} + +func (*rootTransactionRPC) ChainId(context.Context) hexutil.Uint64 { //nolint:revive // RPC method eth_chainId. + return rootTestChainID +} + +func (*rootTransactionRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(1), GasLimit: rootTestGasLimit} +} + +func (*rootTransactionRPC) GasPrice(context.Context) hexutil.Uint64 { return 1 } + +func (*rootTransactionRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (r *rootTransactionRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + select { + case r.sent <- tx: + default: + return tx.Hash(), errors.New("unexpected repeated broadcast") + } + return tx.Hash(), r.sendErr +} + +func (*rootTransactionRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(1), Logs: []*types.Log{}} +} From e5a872982a1dfd5b7cf7767e6ffb59096034a679 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:17 -0300 Subject: [PATCH 23/41] feat(auth)!: separate prt and claimer signers Configure PRT transactions through CARTESI_PRT_AUTH_* settings. Keep Claimer and CLI authentication independent. Use account 6 for the development PRT mnemonic and account 0 for claims. Allow operators to select the same address in both configurations. Tests verify separate credential selection and explicit account zero. BREAKING CHANGE: PRT submission requires its own authentication settings. --- Makefile | 3 + compose.yaml | 3 + internal/config/auth/auth.go | 58 ++++++++--- internal/config/auth/auth_test.go | 134 +++++++++++++++++++++++++- internal/config/generate/Config.toml | 67 ++++++++++++- internal/config/generated.go | 100 +++++++++++++++++++ internal/prt/service.go | 2 +- test/compose/compose.integration.yaml | 3 + test/compose/compose.test.yaml | 3 + 9 files changed, 353 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index de8f21e12..c2bb695d1 100644 --- a/Makefile +++ b/Makefile @@ -153,6 +153,9 @@ env: @echo export CARTESI_DEVNET_TEST_ERC20_ADDRESS="0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" @echo export CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS="0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" @echo export CARTESI_AUTH_MNEMONIC=\"test test test test test test test test test test test junk\" + @echo export CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX="0" + @echo export CARTESI_PRT_AUTH_MNEMONIC=\"test test test test test test test test test test test junk\" + @echo export CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX="6" @echo export CARTESI_DATABASE_CONNECTION="postgres://postgres:password@localhost:5432/rollupsdb?sslmode=disable" @echo export CARTESI_SNAPSHOTS_DIR="snapshots" @echo export CARTESI_TEST_DATABASE_CONNECTION="postgres://test_user:password@localhost:5432/test_rollupsdb?sslmode=disable" diff --git a/compose.yaml b/compose.yaml index b8d414178..8921366c9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,9 @@ x-env: &env CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" CARTESI_DATABASE_CONNECTION_FILE: /run/secrets/database_connection CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 services: ethereum_provider: diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index d06166d4a..2e6c0e850 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -21,22 +21,54 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" ) -func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.TransactOptsFactory, error) { - if chainId == nil || chainId.Sign() <= 0 { +type authGetters struct { + kind func() (AuthKind, error) + privateKey func() (RedactedString, error) + mnemonic func() (RedactedString, error) + mnemonicAccountIndex func() (RedactedUint, error) + awsKMSKeyID func() (RedactedString, error) +} + +func GetTransactOptsFactory(ctx context.Context, chainID *big.Int) (ethutil.TransactOptsFactory, error) { + return getTransactOptsFactory(ctx, chainID, authGetters{ + kind: GetAuthKind, + privateKey: GetAuthPrivateKey, + mnemonic: GetAuthMnemonic, + mnemonicAccountIndex: GetAuthMnemonicAccountIndex, + awsKMSKeyID: GetAuthAwsKmsKeyId, + }) +} + +func GetPrtTransactOptsFactory(ctx context.Context, chainID *big.Int) (ethutil.TransactOptsFactory, error) { + return getTransactOptsFactory(ctx, chainID, authGetters{ + kind: GetPrtAuthKind, + privateKey: GetPrtAuthPrivateKey, + mnemonic: GetPrtAuthMnemonic, + mnemonicAccountIndex: GetPrtAuthMnemonicAccountIndex, + awsKMSKeyID: GetPrtAuthAwsKmsKeyId, + }) +} + +func getTransactOptsFactory( + ctx context.Context, + chainID *big.Int, + getters authGetters, +) (ethutil.TransactOptsFactory, error) { + if chainID == nil || chainID.Sign() <= 0 { return nil, bind.ErrNoChainID } - authKind, err := GetAuthKind() + authKind, err := getters.kind() if err != nil { return nil, err } switch authKind { - case AuthKindMnemonicVar: - mnemonic, err := GetAuthMnemonic() + case AuthKindMnemonicVar, AuthKindMnemonicFile: + mnemonic, err := getters.mnemonic() if err != nil { return nil, err } - accountIndex, err := GetAuthMnemonicAccountIndex() + accountIndex, err := getters.mnemonicAccountIndex() if err != nil { return nil, err } @@ -44,13 +76,13 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran if err != nil { return nil, err } - txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainId) + txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainID) if err != nil { return nil, err } return ethutil.NewStaticTransactOptsFactory(txOpts), nil - case AuthKindPrivateKeyVar: - privateKey, err := GetAuthPrivateKey() + case AuthKindPrivateKeyVar, AuthKindPrivateKeyFile: + privateKey, err := getters.privateKey() if err != nil { return nil, err } @@ -58,13 +90,13 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran if err != nil { return nil, err } - txOpts, err := bind.NewKeyedTransactorWithChainID(key, chainId) + txOpts, err := bind.NewKeyedTransactorWithChainID(key, chainID) if err != nil { return nil, err } return ethutil.NewStaticTransactOptsFactory(txOpts), nil case AuthKindAWS: - keyId, err := GetAuthAwsKmsKeyId() + keyID, err := getters.awsKMSKeyID() if err != nil { return nil, err } @@ -76,8 +108,8 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran return signtx.CreateAWSTransactOptsFactory( ctx, kmsClient, - aws.String(keyId.Value), - types.LatestSignerForChainID(chainId), + aws.String(keyID.Value), + types.LatestSignerForChainID(chainID), ) default: return nil, fmt.Errorf("no valid authentication method found") diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go index 3fb54c58f..1953dc475 100644 --- a/internal/config/auth/auth_test.go +++ b/internal/config/auth/auth_test.go @@ -8,10 +8,13 @@ import ( "crypto/rand" "encoding/asn1" "encoding/base64" + "encoding/hex" "encoding/json" "math/big" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -22,8 +25,131 @@ import ( "github.com/stretchr/testify/require" . "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/ethutil" ) +func TestGetTransactOptsFactoryAcceptsVariableAndFileKinds(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + privateKeyText := hex.EncodeToString(crypto.FromECDSA(privateKey)) + privateKeyAddress := crypto.PubkeyToAddress(privateKey.PublicKey) + + mnemonicKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 4) + require.NoError(t, err) + mnemonicAddress := crypto.PubkeyToAddress(mnemonicKey.PublicKey) + + tests := []struct { + name string + kind AuthKind + expectedAddress common.Address + }{ + {name: "private key variable", kind: AuthKindPrivateKeyVar, expectedAddress: privateKeyAddress}, + {name: "private key file", kind: AuthKindPrivateKeyFile, expectedAddress: privateKeyAddress}, + {name: "mnemonic variable", kind: AuthKindMnemonicVar, expectedAddress: mnemonicAddress}, + {name: "mnemonic file", kind: AuthKindMnemonicFile, expectedAddress: mnemonicAddress}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, err := getTransactOptsFactory(t.Context(), big.NewInt(31337), authGetters{ + kind: func() (AuthKind, error) { return test.kind, nil }, + privateKey: func() (RedactedString, error) { + return RedactedString{Value: privateKeyText}, nil + }, + mnemonic: func() (RedactedString, error) { + return RedactedString{Value: ethutil.FoundryMnemonic}, nil + }, + mnemonicAccountIndex: func() (RedactedUint, error) { + return RedactedUint{Value: 4}, nil + }, + awsKMSKeyID: func() (RedactedString, error) { + t.Fatal("AWS KMS getter must not be called") + return RedactedString{}, nil + }, + }) + require.NoError(t, err) + require.Equal(t, test.expectedAddress, factory.From()) + }) + } +} + +func TestGetPrtTransactOptsFactoryUsesOnlyPrtAuth(t *testing.T) { + for _, test := range []struct { + name string + index string + want uint32 + }{ + {name: "default", want: 6}, + {name: "explicit nonzero index", index: "4", want: 4}, + {name: "explicit zero index", index: "0"}, + } { + t.Run(test.name, func(t *testing.T) { + resetAuthConfig(t) + t.Setenv(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, test.index) + viper.Set(AUTH_KIND, "mnemonic") + viper.Set(AUTH_MNEMONIC, ethutil.FoundryMnemonic) + viper.Set(AUTH_MNEMONIC_ACCOUNT_INDEX, 0) + viper.Set(PRT_AUTH_KIND, "mnemonic") + viper.Set(PRT_AUTH_MNEMONIC, ethutil.FoundryMnemonic) + + claimerFactory, err := GetTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + prtFactory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + prtKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, test.want) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(prtKey.PublicKey), prtFactory.From()) + if test.want != 0 { + require.NotEqual(t, claimerFactory.From(), prtFactory.From()) + } else { + // Operators may configure the same address for both services. + require.Equal(t, claimerFactory.From(), prtFactory.From()) + } + }) + } +} + +func TestGetPrtTransactOptsFactoryDoesNotFallbackToGenericAuth(t *testing.T) { + resetAuthConfig(t) + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + viper.Set(AUTH_KIND, "private_key") + viper.Set(AUTH_PRIVATE_KEY, hex.EncodeToString(crypto.FromECDSA(privateKey))) + viper.Set(PRT_AUTH_KIND, "private_key") + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.Nil(t, factory) + require.ErrorContains(t, err, PRT_AUTH_PRIVATE_KEY) +} + +func TestGetPrtTransactOptsFactoryRequiresPrtMnemonic(t *testing.T) { + resetAuthConfig(t) + viper.Set(AUTH_KIND, "mnemonic") + viper.Set(AUTH_MNEMONIC, ethutil.FoundryMnemonic) + viper.Set(PRT_AUTH_KIND, "mnemonic") + viper.Set(PRT_AUTH_MNEMONIC, "") + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.Nil(t, factory) + require.ErrorContains(t, err, PRT_AUTH_MNEMONIC) +} + +func TestGetPrtTransactOptsFactoryReadsFileAuth(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + privateKeyText := hex.EncodeToString(crypto.FromECDSA(privateKey)) + privateKeyFile := filepath.Join(t.TempDir(), "prt-private-key") + require.NoError(t, os.WriteFile(privateKeyFile, []byte(privateKeyText), 0o600)) + + resetAuthConfig(t) + viper.Set(PRT_AUTH_KIND, "private_key_file") + viper.Set(PRT_AUTH_PRIVATE_KEY_FILE, privateKeyFile) + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), factory.From()) +} + func TestGetTransactOptsFactoryAWSSignsDynamicFeeTransaction(t *testing.T) { server := newFakeKMSServer(t) t.Cleanup(server.Close) @@ -94,15 +220,21 @@ func TestGetTransactOptsFactoryRejectsInvalidChainID(t *testing.T) { } } -func setupAWSAuth(t *testing.T, endpoint string) { +func resetAuthConfig(t *testing.T) { t.Helper() viper.Reset() viper.AutomaticEnv() + SetDefaults() t.Cleanup(func() { viper.Reset() viper.AutomaticEnv() SetDefaults() }) +} + +func setupAWSAuth(t *testing.T, endpoint string) { + t.Helper() + resetAuthConfig(t) viper.Set(AUTH_KIND, "aws") viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key") diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index 60a05ad62..e0c9b3f96 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -348,7 +348,7 @@ One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws". The auth variable for the kind defined here is required. Eg.: CARTESI_AUTH_MNEMONIC""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] [auth.CARTESI_AUTH_PRIVATE_KEY] file = true @@ -356,7 +356,7 @@ go-type = "RedactedString" description = """ The node will use this private key to sign transactions.""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] [auth.CARTESI_AUTH_MNEMONIC] file = true @@ -364,7 +364,7 @@ go-type = "RedactedString" description = """ The node will use the private key generated from this mnemonic to sign transactions.""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] [auth.CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX] default = "0" @@ -373,7 +373,7 @@ description = """ When using mnemonics to sign transactions, the node will use this account index to generate the private key.""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] [auth.CARTESI_AUTH_AWS_KMS_KEY_ID] go-type = "RedactedString" @@ -390,7 +390,64 @@ Prefer an ARN or a bare key ID over an alias: an alias is resolved per-region, s the same alias in a different region names a different key and therefore a different signing address.""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] + +# +# PRT Auth +# + +[prt-auth.CARTESI_PRT_AUTH_KIND] +default = "mnemonic" +go-type = "AuthKind" +description = """ +One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws". + +The PRT auth variable for the kind defined here is required. Eg.: CARTESI_PRT_AUTH_MNEMONIC""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_PRIVATE_KEY] +file = true +go-type = "RedactedString" +description = """ +The PRT service will use this private key to sign transactions.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_MNEMONIC] +file = true +go-type = "RedactedString" +description = """ +The PRT service will use the private key generated from this mnemonic to sign transactions.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX] +default = "6" +go-type = "RedactedUint" +description = """ +When using a mnemonic to sign PRT transactions, +the PRT service will use this account index to generate the private key. +The resulting PRT and Claimer signer addresses must differ.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_AWS_KMS_KEY_ID] +go-type = "RedactedString" +description = """ +An AWS KMS key ID, alias, or ARN for the PRT service. + +If set, the PRT service will use the AWS KMS service with this key to sign transactions. + +Everything else about the AWS connection — region, endpoint, and credentials — is +resolved by the AWS SDK's standard chain, not by CARTESI_ variables. See the +"Externally-provided configuration" section for the variables involved. + +Prefer an ARN or a bare key ID over an alias: an alias is resolved per-region, so +the same alias in a different region names a different key and therefore a +different signing address.""" +omit = true +used-by = ["node", "prt"] # # Database diff --git a/internal/config/generated.go b/internal/config/generated.go index 450c549e0..06ba07ee0 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -68,6 +68,11 @@ const ( LOG_LEVEL_PRT = "CARTESI_LOG_LEVEL_PRT" LOG_LEVEL_VALIDATOR = "CARTESI_LOG_LEVEL_VALIDATOR" JSONRPC_MACHINE_LOG_LEVEL = "CARTESI_JSONRPC_MACHINE_LOG_LEVEL" + PRT_AUTH_AWS_KMS_KEY_ID = "CARTESI_PRT_AUTH_AWS_KMS_KEY_ID" + PRT_AUTH_KIND = "CARTESI_PRT_AUTH_KIND" + PRT_AUTH_MNEMONIC = "CARTESI_PRT_AUTH_MNEMONIC" + PRT_AUTH_MNEMONIC_ACCOUNT_INDEX = "CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX" + PRT_AUTH_PRIVATE_KEY = "CARTESI_PRT_AUTH_PRIVATE_KEY" ADVANCER_INPUT_BATCH_SIZE = "CARTESI_ADVANCER_INPUT_BATCH_SIZE" ADVANCER_POLLING_INTERVAL = "CARTESI_ADVANCER_POLLING_INTERVAL" BLOCKCHAIN_GAS_LIMIT = "CARTESI_BLOCKCHAIN_GAS_LIMIT" @@ -95,6 +100,10 @@ const ( BLOCKCHAIN_HTTP_ENDPOINT_FILE = "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE" DATABASE_CONNECTION_FILE = "CARTESI_DATABASE_CONNECTION_FILE" + + PRT_AUTH_MNEMONIC_FILE = "CARTESI_PRT_AUTH_MNEMONIC_FILE" + + PRT_AUTH_PRIVATE_KEY_FILE = "CARTESI_PRT_AUTH_PRIVATE_KEY_FILE" ) func SetDefaults() { @@ -192,6 +201,16 @@ func SetDefaults() { viper.SetDefault(JSONRPC_MACHINE_LOG_LEVEL, "info") + // no default for CARTESI_PRT_AUTH_AWS_KMS_KEY_ID + + viper.SetDefault(PRT_AUTH_KIND, "mnemonic") + + // no default for CARTESI_PRT_AUTH_MNEMONIC + + viper.SetDefault(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, "6") + + // no default for CARTESI_PRT_AUTH_PRIVATE_KEY + viper.SetDefault(ADVANCER_INPUT_BATCH_SIZE, "500") viper.SetDefault(ADVANCER_POLLING_INTERVAL, "3") @@ -2377,6 +2396,87 @@ func GetJsonrpcMachineLogLevel() (string, error) { return notDefinedstring(), fmt.Errorf("%s: %w", JSONRPC_MACHINE_LOG_LEVEL, ErrNotDefined) } +// GetPrtAuthAwsKmsKeyId returns the value for the environment variable CARTESI_PRT_AUTH_AWS_KMS_KEY_ID. +func GetPrtAuthAwsKmsKeyId() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_AWS_KMS_KEY_ID) + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_AWS_KMS_KEY_ID, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_AWS_KMS_KEY_ID, ErrNotDefined) +} + +// GetPrtAuthKind returns the value for the environment variable CARTESI_PRT_AUTH_KIND. +func GetPrtAuthKind() (AuthKind, error) { + s := viper.GetString(PRT_AUTH_KIND) + if s != "" { + v, err := toAuthKind(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_KIND, err) + } + return v, nil + } + return notDefinedAuthKind(), fmt.Errorf("%s: %w", PRT_AUTH_KIND, ErrNotDefined) +} + +// GetPrtAuthMnemonic returns the value for the environment variable CARTESI_PRT_AUTH_MNEMONIC. +func GetPrtAuthMnemonic() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_MNEMONIC) + if s == "" { + filename := viper.GetString(PRT_AUTH_MNEMONIC_FILE) + contents, err := os.ReadFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_MNEMONIC, ErrNotDefined) +} + +// GetPrtAuthMnemonicAccountIndex returns the value for the environment variable CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX. +func GetPrtAuthMnemonicAccountIndex() (RedactedUint, error) { + s := viper.GetString(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX) + if s != "" { + v, err := toRedactedUint(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, err) + } + return v, nil + } + return notDefinedRedactedUint(), fmt.Errorf("%s: %w", PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, ErrNotDefined) +} + +// GetPrtAuthPrivateKey returns the value for the environment variable CARTESI_PRT_AUTH_PRIVATE_KEY. +func GetPrtAuthPrivateKey() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_PRIVATE_KEY) + if s == "" { + filename := viper.GetString(PRT_AUTH_PRIVATE_KEY_FILE) + contents, err := os.ReadFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", PRT_AUTH_PRIVATE_KEY_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_PRIVATE_KEY, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_PRIVATE_KEY, ErrNotDefined) +} + // GetAdvancerInputBatchSize returns the value for the environment variable CARTESI_ADVANCER_INPUT_BATCH_SIZE. func GetAdvancerInputBatchSize() (uint64, error) { s := viper.GetString(ADVANCER_INPUT_BATCH_SIZE) diff --git a/internal/prt/service.go b/internal/prt/service.go index f6bd1502c..b3153e11b 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -125,7 +125,7 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro if s.submissionTimeout == 0 { return nil, fmt.Errorf("BlockchainHttpRequestTimeout must be different from zero") } - s.txOptsFactory, err = auth.GetTransactOptsFactory(ctx, chainID) + s.txOptsFactory, err = auth.GetPrtTransactOptsFactory(ctx, chainID) if err != nil { return nil, err } diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 00fc53008..a6072c892 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -14,6 +14,9 @@ x-env: &env CARTESI_DEVNET_TEST_ERC20_ADDRESS: "0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" CARTESI_DATABASE_CONNECTION: postgres://postgres:password@database:5432/rollupsdb?sslmode=disable CARTESI_AUTH_MNEMONIC: "test test test test test test test test test test test junk" + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC: "test test test test test test test test test test test junk" + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 services: ethereum_provider: diff --git a/test/compose/compose.test.yaml b/test/compose/compose.test.yaml index 719690341..dd566b048 100644 --- a/test/compose/compose.test.yaml +++ b/test/compose/compose.test.yaml @@ -13,6 +13,9 @@ x-env: &env CARTESI_DATABASE_CONNECTION: postgres://postgres:password@database:5432/rollupsdb?sslmode=disable CARTESI_TEST_DATABASE_CONNECTION: postgres://test_user:password@database:5432/test_rollupsdb?sslmode=disable CARTESI_AUTH_MNEMONIC: "test test test test test test test test test test test junk" + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC: "test test test test test test test test test test test junk" + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 services: ethereum_provider: From 2e84d268aa3e3c4b5373fe1d4626ec31e3ff5403 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:46:14 -0300 Subject: [PATCH 24/41] fix(appstatus): explain blocked foreclosure work Explain the FAILED health gate when failure or foreclosure is recorded. Keep observation and health policy unchanged. Recovery requires repair before the operator clears FAILED. --- internal/appstatus/appstatus.go | 29 ++++++++++++++++++++---- internal/appstatus/appstatus_test.go | 33 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/appstatus/appstatus.go b/internal/appstatus/appstatus.go index 470b373d6..718f3e476 100644 --- a/internal/appstatus/appstatus.go +++ b/internal/appstatus/appstatus.go @@ -23,12 +23,12 @@ type Repository interface { } // SetFailed marks an application as FAILED (recoverable). -// Use for machine runtime errors that can be resolved by operator intervention -// (e.g., OOM kill, process crash). The operator can re-enable the application -// after fixing the root cause. +// Use for runtime or internal computation errors that can be resolved by +// operator intervention (e.g., OOM kill, process crash, or a repaired software +// defect). The operator can re-enable the application after fixing the cause. // // Recovery assumptions — FAILED is safe to re-enable only when: -// - The failure was a machine runtime error (not a DB desync). +// - The failure does not prove that persisted state is inconsistent. // - The last snapshot is consistent with the database state. // - replay.Run will correctly verify inputs from the snapshot point. // @@ -234,5 +234,26 @@ func setApplicationStatus( // the in-memory Application consistent with the database. app.Status = status app.Reason = &reason + if status == ApplicationStatus_Failed { + WarnBlockedForeclosure(logger, app) + } return nil } + +// WarnBlockedForeclosure explains why FAILED prevents foreclosure work from +// finishing. Call when failure or foreclosure is recorded, not on every tick. +func WarnBlockedForeclosure(logger *slog.Logger, app *Application) { + if app.Status != ApplicationStatus_Failed || app.ForecloseBlock == 0 { + return + } + var reason string + if app.Reason != nil { + reason = *app.Reason + } + logger.Warn("Application failure blocks foreclosure drain; repair the cause before clearing FAILED", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + "reason", reason, + "recovery_command", fmt.Sprintf("cartesi-rollups-cli app status %s enabled", app.IApplicationAddress.Hex())) +} diff --git a/internal/appstatus/appstatus_test.go b/internal/appstatus/appstatus_test.go index 9ac78c9ec..073ae92c3 100644 --- a/internal/appstatus/appstatus_test.go +++ b/internal/appstatus/appstatus_test.go @@ -4,6 +4,7 @@ package appstatus import ( + "bytes" "context" "errors" "log/slog" @@ -12,9 +13,41 @@ import ( . "github.com/cartesi/rollups-node/internal/model" "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) +func TestFailedForeclosureDiagnostic(t *testing.T) { + for _, tc := range []struct { + name string + foreclosed bool + writeError error + }{ + {name: "failure after foreclosure", foreclosed: true}, + {name: "failure without foreclosure"}, + {name: "failure not recorded", foreclosed: true, writeError: errors.New("database unavailable")}, + } { + t.Run(tc.name, func(t *testing.T) { + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, nil)) + app := newTestApp() + if tc.foreclosed { + app.ForecloseBlock = 80 + } + err := SetFailed(t.Context(), logger, &mockRepo{err: tc.writeError}, app, "machine process crashed") + require.ErrorIs(t, err, tc.writeError) + if tc.foreclosed && tc.writeError == nil { + require.Contains(t, logs.String(), "failure blocks foreclosure drain") + require.Contains(t, logs.String(), "foreclose_block=80") + require.Contains(t, logs.String(), "machine process crashed") + require.Contains(t, logs.String(), "repair the cause before clearing FAILED") + } else { + require.NotContains(t, logs.String(), "failure blocks foreclosure drain") + } + }) + } +} + func TestAppStatus(t *testing.T) { suite.Run(t, new(AppStatusSuite)) } From ae78646454438f1daccd6dec886e1ad206e1f2cb Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:17 -0300 Subject: [PATCH 25/41] feat(evmreader): adopt v3 InputBox observation Resolve and scan the InputBox assigned to each application. Remove the legacy data-availability selector gate. Keep adapter resolution and distinct-InputBox tests with this change. Sealed-epoch completeness and restart behavior are handled separately in the next commit. Remove the obsolete input-reader switch. Validate saved chain and observation settings at startup, and explain blocked foreclosure work without weakening health gates. Complete each application input scan before advancing its cursor. Return operation errors to one logging boundary instead of replacing them with a generic incomplete-scan marker. Keep real failures visible during shutdown, preserve progress for healthy applications, and stop dispatching work after context cancellation. Store no-input epoch closures and their cursors atomically. Cover cancellation, compound errors, failed reads and writes, partial progress, and readiness in unit tests. --- cmd/cartesi-rollups-evm-reader/root/root.go | 3 - cmd/cartesi-rollups-node/root/root.go | 3 - internal/config/generate/Config.toml | 7 - internal/config/generated.go | 37 -- internal/errutil/cancellation.go | 35 ++ internal/errutil/cancellation_test.go | 56 +++ internal/evmreader/adapter_resolver.go | 5 +- internal/evmreader/adapter_resolver_test.go | 7 - internal/evmreader/block_scan_plan.go | 5 +- internal/evmreader/block_scan_plan_test.go | 28 +- internal/evmreader/dave_consensus_test.go | 16 +- internal/evmreader/daveconsensus_adapter.go | 33 +- internal/evmreader/edge_cases_test.go | 5 +- internal/evmreader/error_paths_test.go | 24 +- internal/evmreader/evmreader.go | 37 +- internal/evmreader/evmreader_test.go | 6 +- internal/evmreader/fixtures_test.go | 7 +- internal/evmreader/foreclosure.go | 2 + internal/evmreader/foreclosure_test.go | 30 ++ internal/evmreader/input.go | 408 ++++++------------ internal/evmreader/input_scan_test.go | 278 ++++++++++++ internal/evmreader/input_scan_units_test.go | 52 +-- internal/evmreader/input_test.go | 37 +- internal/evmreader/mocks_test.go | 75 ++-- .../evmreader/observation_shutdown_test.go | 109 +++++ internal/evmreader/output.go | 31 +- internal/evmreader/output_test.go | 48 ++- internal/evmreader/readiness_test.go | 29 +- internal/evmreader/sealedepochs.go | 10 +- internal/evmreader/sealedepochs_test.go | 23 +- internal/evmreader/service.go | 43 +- internal/evmreader/service_config_test.go | 139 +++--- internal/evmreader/util.go | 8 - pkg/ethutil/client.go | 13 + pkg/ethutil/client_cancellation_test.go | 79 ++++ pkg/service/supervisor.go | 26 +- 36 files changed, 1022 insertions(+), 732 deletions(-) create mode 100644 internal/errutil/cancellation.go create mode 100644 internal/errutil/cancellation_test.go create mode 100644 internal/evmreader/input_scan_test.go create mode 100644 internal/evmreader/observation_shutdown_test.go create mode 100644 pkg/ethutil/client_cancellation_test.go diff --git a/cmd/cartesi-rollups-evm-reader/root/root.go b/cmd/cartesi-rollups-evm-reader/root/root.go index f8384798d..b868f83b2 100644 --- a/cmd/cartesi-rollups-evm-reader/root/root.go +++ b/cmd/cartesi-rollups-evm-reader/root/root.go @@ -25,7 +25,6 @@ var ( pollInterval string databaseConnection string maxStartupTime string - enableInputReader bool telemetryAddress string cfg *config.EvmreaderConfig maxBlockRange uint64 @@ -60,8 +59,6 @@ func init() { "Poll interval") cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, "Maximum startup time in seconds") - cli.AddFlagBoolVar(flags, &enableInputReader, "input-reader", config.FEATURE_INPUT_READER_ENABLED, - "Enable or disable the input reader (for external input readers)") cli.AddFlagUint64Var(flags, &maxBlockRange, "max-block-range", config.BLOCKCHAIN_MAX_BLOCK_RANGE, "Maximum number of blocks in a single query. large queries will be split automatically. Zero for unlimited.") diff --git a/cmd/cartesi-rollups-node/root/root.go b/cmd/cartesi-rollups-node/root/root.go index eaaa53c6e..691f62b1a 100644 --- a/cmd/cartesi-rollups-node/root/root.go +++ b/cmd/cartesi-rollups-node/root/root.go @@ -41,7 +41,6 @@ var ( claimerPollInterval string prtPollInterval string maxStartupTime string - enableInputReader bool enableInspect bool enableJsonrpc bool enableSubmission bool @@ -107,8 +106,6 @@ func init() { "PRT poll interval") cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, "Maximum startup time in seconds") - cli.AddFlagBoolVar(flags, &enableInputReader, "input-reader", config.FEATURE_INPUT_READER_ENABLED, - "Enable or disable the input reader (for external input readers)") cli.AddFlagBoolVar(flags, &enableInspect, "inspect-enabled", config.FEATURE_INSPECT_ENABLED, "Enable or disable the inspect service") cli.AddFlagBoolVar(flags, &enableJsonrpc, "jsonrpc-enabled", config.FEATURE_JSONRPC_API_ENABLED, diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index e0c9b3f96..9ca91a783 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -69,13 +69,6 @@ used-by = ["validator", "node"] # Features # -[features.CARTESI_FEATURE_INPUT_READER_ENABLED] -default = "true" -go-type = "bool" -description = """ -If set to false, the node will not read inputs from the blockchain.""" -used-by = ["evmreader", "node"] - [features.CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED] default = "true" go-type = "bool" diff --git a/internal/config/generated.go b/internal/config/generated.go index 06ba07ee0..af3ab8829 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -40,7 +40,6 @@ const ( CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS = "CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS" DATABASE_CONNECTION = "CARTESI_DATABASE_CONNECTION" FEATURE_CLAIM_SUBMISSION_ENABLED = "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED" - FEATURE_INPUT_READER_ENABLED = "CARTESI_FEATURE_INPUT_READER_ENABLED" FEATURE_INSPECT_ENABLED = "CARTESI_FEATURE_INSPECT_ENABLED" FEATURE_JSONRPC_API_ENABLED = "CARTESI_FEATURE_JSONRPC_API_ENABLED" FEATURE_MACHINE_HASH_CHECK_ENABLED = "CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED" @@ -145,8 +144,6 @@ func SetDefaults() { viper.SetDefault(FEATURE_CLAIM_SUBMISSION_ENABLED, "true") - viper.SetDefault(FEATURE_INPUT_READER_ENABLED, "true") - viper.SetDefault(FEATURE_INSPECT_ENABLED, "true") viper.SetDefault(FEATURE_JSONRPC_API_ENABLED, "true") @@ -661,9 +658,6 @@ type EvmreaderConfig struct { // for more information. DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` - // If set to false, the node will not read inputs from the blockchain. - FeatureInputReaderEnabled bool `mapstructure:"CARTESI_FEATURE_INPUT_READER_ENABLED"` - // HTTP address for EVM Reader's telemetry service. EvmReaderTelemetryAddress string `mapstructure:"CARTESI_EVM_READER_TELEMETRY_ADDRESS"` @@ -744,13 +738,6 @@ func LoadEvmreaderConfig() (*EvmreaderConfig, error) { return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the evmreader service: %w", err) } - cfg.FeatureInputReaderEnabled, err = GetFeatureInputReaderEnabled() - if err != nil && err != ErrNotDefined { - return nil, fmt.Errorf("failed to get CARTESI_FEATURE_INPUT_READER_ENABLED: %w", err) - } else if err == ErrNotDefined { - return nil, fmt.Errorf("CARTESI_FEATURE_INPUT_READER_ENABLED is required for the evmreader service: %w", err) - } - cfg.EvmReaderTelemetryAddress, err = GetEvmReaderTelemetryAddress() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_EVM_READER_TELEMETRY_ADDRESS: %w", err) @@ -981,9 +968,6 @@ type NodeConfig struct { // stops startup. Mode changes on an existing database are not supported. FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` - // If set to false, the node will not read inputs from the blockchain. - FeatureInputReaderEnabled bool `mapstructure:"CARTESI_FEATURE_INPUT_READER_ENABLED"` - // If set to false, the node will not start the inspect service. FeatureInspectEnabled bool `mapstructure:"CARTESI_FEATURE_INSPECT_ENABLED"` @@ -1149,13 +1133,6 @@ func LoadNodeConfig() (*NodeConfig, error) { return nil, fmt.Errorf("CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED is required for the node service: %w", err) } - cfg.FeatureInputReaderEnabled, err = GetFeatureInputReaderEnabled() - if err != nil && err != ErrNotDefined { - return nil, fmt.Errorf("failed to get CARTESI_FEATURE_INPUT_READER_ENABLED: %w", err) - } else if err == ErrNotDefined { - return nil, fmt.Errorf("CARTESI_FEATURE_INPUT_READER_ENABLED is required for the node service: %w", err) - } - cfg.FeatureInspectEnabled, err = GetFeatureInspectEnabled() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_FEATURE_INSPECT_ENABLED: %w", err) @@ -1698,7 +1675,6 @@ func (c *NodeConfig) ToEvmreaderConfig() *EvmreaderConfig { BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, BlockchainId: c.BlockchainId, DatabaseConnection: c.DatabaseConnection, - FeatureInputReaderEnabled: c.FeatureInputReaderEnabled, LogColor: c.LogColor, LogLevel: c.LogLevel, BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, @@ -2032,19 +2008,6 @@ func GetFeatureClaimSubmissionEnabled() (bool, error) { return notDefinedbool(), fmt.Errorf("%s: %w", FEATURE_CLAIM_SUBMISSION_ENABLED, ErrNotDefined) } -// GetFeatureInputReaderEnabled returns the value for the environment variable CARTESI_FEATURE_INPUT_READER_ENABLED. -func GetFeatureInputReaderEnabled() (bool, error) { - s := viper.GetString(FEATURE_INPUT_READER_ENABLED) - if s != "" { - v, err := toBool(s) - if err != nil { - return v, fmt.Errorf("failed to parse %s: %w", FEATURE_INPUT_READER_ENABLED, err) - } - return v, nil - } - return notDefinedbool(), fmt.Errorf("%s: %w", FEATURE_INPUT_READER_ENABLED, ErrNotDefined) -} - // GetFeatureInspectEnabled returns the value for the environment variable CARTESI_FEATURE_INSPECT_ENABLED. func GetFeatureInspectEnabled() (bool, error) { s := viper.GetString(FEATURE_INSPECT_ENABLED) diff --git a/internal/errutil/cancellation.go b/internal/errutil/cancellation.go new file mode 100644 index 000000000..f1e90416b --- /dev/null +++ b/internal/errutil/cancellation.go @@ -0,0 +1,35 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package errutil provides error classification shared by node components. +package errutil + +import ( + "context" + "errors" +) + +// IsOnlyCancellation reports whether every cause of err is context.Canceled. +// Unlike errors.Is, it does not hide other causes in a joined error. Nil is not +// a cancellation. Errors without children are checked with errors.Is. Callers +// must separately establish that the service is shutting down. +func IsOnlyCancellation(err error) bool { + switch cause := err.(type) { + case interface{ Unwrap() []error }: + children := cause.Unwrap() + if len(children) == 0 { + return errors.Is(err, context.Canceled) + } + for _, child := range children { + if !IsOnlyCancellation(child) { + return false + } + } + return true + case interface{ Unwrap() error }: + if child := cause.Unwrap(); child != nil { + return IsOnlyCancellation(child) + } + } + return errors.Is(err, context.Canceled) +} diff --git a/internal/errutil/cancellation_test.go b/internal/errutil/cancellation_test.go new file mode 100644 index 000000000..93be1aa10 --- /dev/null +++ b/internal/errutil/cancellation_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package errutil + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsOnlyCancellation(t *testing.T) { + other := errors.New("database unavailable") + for _, test := range []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"canceled", context.Canceled, true}, + {"wrapped", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined", errors.Join(context.Canceled, fmt.Errorf("write: %w", context.Canceled)), true}, + {"multiple wrapped", fmt.Errorf("queries: %w; %w", context.Canceled, context.Canceled), true}, + {"nested", fmt.Errorf("query: %w", errors.Join(context.Canceled, context.Canceled)), true}, + {"deadline", context.DeadlineExceeded, false}, + {"other", other, false}, + {"mixed", errors.Join(context.Canceled, other), false}, + {"mixed reversed", errors.Join(other, context.Canceled), false}, + {"mixed deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"nested mixed", fmt.Errorf("query: %w", errors.Join(context.Canceled, other)), false}, + {"empty causes", &cancellationTestError{}, false}, + {"nil cause", &cancellationTestError{causes: []error{nil}}, false}, + {"nil wrapped cause", fmt.Errorf("query: %w", nil), false}, + {"custom cancellation leaf", &cancellationTestError{canceled: true}, true}, + {"custom cancellation with failure", &cancellationTestError{canceled: true, causes: []error{other}}, false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, IsOnlyCancellation(test.err)) + }) + } +} + +type cancellationTestError struct { + causes []error + canceled bool +} + +func (*cancellationTestError) Error() string { return "test dependency error" } +func (e *cancellationTestError) Unwrap() []error { return e.causes } +func (e *cancellationTestError) Is(target error) bool { + return e.canceled && target == context.Canceled +} diff --git a/internal/evmreader/adapter_resolver.go b/internal/evmreader/adapter_resolver.go index c806d20f0..b70f8d57c 100644 --- a/internal/evmreader/adapter_resolver.go +++ b/internal/evmreader/adapter_resolver.go @@ -20,7 +20,6 @@ type cachedAdapters struct { consensusAddr common.Address inputBoxAddr common.Address isDaveConsensus bool - hasInputBoxDA bool } type applicationAdapterResolver struct { @@ -101,7 +100,6 @@ func (r *applicationAdapterResolver) getOrCreateAdapters(app *Application) (cach consensusAddr: app.IConsensusAddress, inputBoxAddr: app.IInputBoxAddress, isDaveConsensus: app.IsDaveConsensus(), - hasInputBoxDA: app.HasDataAvailabilitySelector(DataAvailability_InputBox), } r.cache[addr] = cached return cached, true @@ -110,6 +108,5 @@ func (r *applicationAdapterResolver) getOrCreateAdapters(app *Application) (cach func adaptersAreStale(cached cachedAdapters, app *Application) bool { return cached.consensusAddr != app.IConsensusAddress || cached.inputBoxAddr != app.IInputBoxAddress || - cached.isDaveConsensus != app.IsDaveConsensus() || - cached.hasInputBoxDA != app.HasDataAvailabilitySelector(DataAvailability_InputBox) + cached.isDaveConsensus != app.IsDaveConsensus() } diff --git a/internal/evmreader/adapter_resolver_test.go b/internal/evmreader/adapter_resolver_test.go index fc8741a30..47ac2f55d 100644 --- a/internal/evmreader/adapter_resolver_test.go +++ b/internal/evmreader/adapter_resolver_test.go @@ -56,12 +56,6 @@ func TestApplicationAdapterResolver_InvalidatesStaleAdapters(t *testing.T) { app.ConsensusType = Consensus_PRT }, }, - { - name: "InputBox data availability changed", - change: func(app *Application) { - app.DataAvailability = []byte{0xff} - }, - }, } for _, tt := range tests { @@ -155,7 +149,6 @@ func resolverApp(id int64) *Application { IApplicationAddress: common.BigToAddress(big.NewInt(id)), IConsensusAddress: common.HexToAddress("0x00000000000000000000000000000000000000c1"), IInputBoxAddress: common.HexToAddress("0x00000000000000000000000000000000000000b1"), - DataAvailability: DataAvailability_InputBox[:], ConsensusType: Consensus_Authority, Enabled: true, Status: ApplicationStatus_OK, diff --git a/internal/evmreader/block_scan_plan.go b/internal/evmreader/block_scan_plan.go index d88f8162f..8afec5390 100644 --- a/internal/evmreader/block_scan_plan.go +++ b/internal/evmreader/block_scan_plan.go @@ -3,8 +3,6 @@ package evmreader -import . "github.com/cartesi/rollups-node/internal/model" - type blockScanPlan struct { iConsensusInputTargets []appContracts daveEpochTargets []appContracts @@ -32,8 +30,7 @@ func buildBlockScanPlan(apps []appContracts) blockScanPlan { continue } - if application.LastInputCheckBlock < application.ForecloseBlock && - application.HasDataAvailabilitySelector(DataAvailability_InputBox) { + if application.LastInputCheckBlock < application.ForecloseBlock { plan.iConsensusInputTargets = append(plan.iConsensusInputTargets, app) } continue diff --git a/internal/evmreader/block_scan_plan_test.go b/internal/evmreader/block_scan_plan_test.go index a46c30156..9e468b2e3 100644 --- a/internal/evmreader/block_scan_plan_test.go +++ b/internal/evmreader/block_scan_plan_test.go @@ -5,9 +5,11 @@ package evmreader import ( "fmt" + "math/big" "testing" . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" ) @@ -26,14 +28,6 @@ func TestBuildBlockScanPlan_RoutesScannerTargets(t *testing.T) { wantIConsensusInput: []int64{1}, wantOutput: []int64{1}, }, - { - name: "OK IConsensus app without InputBox data availability remains an input target", - apps: []appContracts{planApp(2, planAppConfig{ - withoutInputBoxDA: true, - })}, - wantIConsensusInput: []int64{2}, - wantOutput: []int64{2}, - }, { name: "OK DaveConsensus app is executable", apps: []appContracts{planApp(3, planAppConfig{ @@ -66,16 +60,6 @@ func TestBuildBlockScanPlan_RoutesScannerTargets(t *testing.T) { wantOutput: []int64{5}, wantPostForeclosure: []int64{5}, }, - { - name: "foreclosed IConsensus app without InputBox data availability skips input catch-up", - apps: []appContracts{planApp(6, planAppConfig{ - withoutInputBoxDA: true, - forecloseBlock: 100, - lastInputCheckBlock: 99, - })}, - wantOutput: []int64{6}, - wantPostForeclosure: []int64{6}, - }, { name: "foreclosed DaveConsensus app with epoch cursor behind gets sealed-epoch catch-up", apps: []appContracts{planApp(7, planAppConfig{ @@ -167,7 +151,6 @@ func requireNoDuplicatePlanTargets(plan blockScanPlan) error { type planAppConfig struct { status ApplicationStatus consensus Consensus - withoutInputBoxDA bool forecloseBlock uint64 lastInputCheckBlock uint64 lastEpochCheckBlock uint64 @@ -182,17 +165,12 @@ func planApp(id int64, cfg planAppConfig) appContracts { if consensus == "" { consensus = Consensus_Authority } - dataAvailability := DataAvailability_InputBox[:] - if cfg.withoutInputBoxDA { - dataAvailability = []byte{0xff} - } - return appContracts{application: &Application{ ID: id, + IInputBoxAddress: common.BigToAddress(big.NewInt(id)), Enabled: true, Status: status, ConsensusType: consensus, - DataAvailability: dataAvailability, ForecloseBlock: cfg.forecloseBlock, LastInputCheckBlock: cfg.lastInputCheckBlock, LastEpochCheckBlock: cfg.lastEpochCheckBlock, diff --git a/internal/evmreader/dave_consensus_test.go b/internal/evmreader/dave_consensus_test.go index e15de64b6..d1a2947bb 100644 --- a/internal/evmreader/dave_consensus_test.go +++ b/internal/evmreader/dave_consensus_test.go @@ -15,23 +15,13 @@ import ( "github.com/stretchr/testify/mock" ) -// makeSealedEpochResult constructs the anonymous struct returned by GetCurrentSealedEpoch. +// makeSealedEpochResult constructs the node-owned GetCurrentSealedEpoch result. func makeSealedEpochResult( epochNum int64, lowerBound, upperBound uint64, tournament common.Address, -) struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address -} { - return struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address - }{ +) DaveCurrentSealedEpoch { + return DaveCurrentSealedEpoch{ EpochNumber: big.NewInt(epochNum), InputIndexLowerBound: new(big.Int).SetUint64(lowerBound), InputIndexUpperBound: new(big.Int).SetUint64(upperBound), diff --git a/internal/evmreader/daveconsensus_adapter.go b/internal/evmreader/daveconsensus_adapter.go index c013b85b6..22628a0f0 100644 --- a/internal/evmreader/daveconsensus_adapter.go +++ b/internal/evmreader/daveconsensus_adapter.go @@ -20,18 +20,21 @@ import ( // Interface for DaveConsensus reading type DaveConsensusAdapter interface { GetInputBox(opts *bind.CallOpts) (common.Address, error) - GetCurrentSealedEpoch(opts *bind.CallOpts) (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address - }, error) + GetCurrentSealedEpoch(opts *bind.CallOpts) (DaveCurrentSealedEpoch, error) GetApplicationContract(opts *bind.CallOpts) (common.Address, error) GetTournamentFactory(opts *bind.CallOpts) (common.Address, error) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) RetrieveSealedEpochs(opts *bind.FilterOpts) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) } +// DaveCurrentSealedEpoch contains the fields used to observe sealed epochs. +type DaveCurrentSealedEpoch struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address +} + // DaveConsensus Wrapper type DaveConsensusAdapterImpl struct { daveConsensus *idaveconsensus.IDaveConsensus @@ -88,13 +91,17 @@ func (d *DaveConsensusAdapterImpl) GetInputBox(opts *bind.CallOpts) (common.Addr return d.daveConsensus.GetInputBox(opts) } -func (d *DaveConsensusAdapterImpl) GetCurrentSealedEpoch(opts *bind.CallOpts) (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address -}, error) { - return d.daveConsensus.GetCurrentSealedEpoch(opts) +func (d *DaveConsensusAdapterImpl) GetCurrentSealedEpoch(opts *bind.CallOpts) (DaveCurrentSealedEpoch, error) { + result, err := d.daveConsensus.GetCurrentSealedEpoch(opts) + if err != nil { + return DaveCurrentSealedEpoch{}, err + } + return DaveCurrentSealedEpoch{ + EpochNumber: result.EpochNumber, + InputIndexLowerBound: result.InputIndexLowerBound, + InputIndexUpperBound: result.InputIndexUpperBound, + Tournament: result.Tournament, + }, nil } func (d *DaveConsensusAdapterImpl) GetApplicationContract(opts *bind.CallOpts) (common.Address, error) { diff --git a/internal/evmreader/edge_cases_test.go b/internal/evmreader/edge_cases_test.go index 0f604f374..b8dbfdaa2 100644 --- a/internal/evmreader/edge_cases_test.go +++ b/internal/evmreader/edge_cases_test.go @@ -122,7 +122,6 @@ func (s *EvmReaderSuite) sameTransactionScanApp() (appContracts, common.Hash) { Name: "test-app", IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 10, @@ -461,7 +460,6 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { s.client.EnqueueNewHead(101).Once() called := newCallNotification(s.client.EnqueueNewHead(102)) - s.evmReader.inputReaderEnabled = false s.evmReader.defaultBlock = DefaultBlock_Latest addr := common.HexToAddress("0x4444444444444444444444444444444444444444") @@ -477,6 +475,7 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { IConsensusAddress: consensusAddr1, IInputBoxAddress: inputBoxAddr, LastOutputCheckBlock: 999, // > header block → skip output check + LastInputCheckBlock: 999, // input scan is already current for this adapter test LastForecloseCheckBlock: 999, }}, uint64(1), nil).Once() // Header 2: consensus address changed → cache invalidation @@ -487,6 +486,7 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { IConsensusAddress: consensusAddr2, IInputBoxAddress: inputBoxAddr, LastOutputCheckBlock: 999, + LastInputCheckBlock: 999, LastForecloseCheckBlock: 999, }}, uint64(1), nil).Once() // Header 3: same config as header 2 → cache hit @@ -497,6 +497,7 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { IConsensusAddress: consensusAddr2, IInputBoxAddress: inputBoxAddr, LastOutputCheckBlock: 999, + LastInputCheckBlock: 999, LastForecloseCheckBlock: 999, }}, uint64(1), nil).Once() // Catch-all for sentinel header diff --git a/internal/evmreader/error_paths_test.go b/internal/evmreader/error_paths_test.go index 291239548..18168e2cb 100644 --- a/internal/evmreader/error_paths_test.go +++ b/internal/evmreader/error_paths_test.go @@ -62,8 +62,8 @@ func (s *EvmReaderSuite) TestCreateEpochsAndInputsErrorDoesNotAdvanceCheckpoint( s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) - require.ErrorIs(err, errScanIncomplete) // per-app failure is reported after scanning + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) + require.ErrorContains(err, "store inputs and epochs: database connection lost") // CreateEpochsAndInputs was attempted repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) @@ -311,7 +311,6 @@ func (s *EvmReaderSuite) TestBlockRegressionDoesNotWriteToDb() { Name: "test-app", IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, LastInputCheckBlock: 100, } @@ -424,7 +423,6 @@ func (s *EvmReaderSuite) TestIConsensusInputCountMismatchSkipsApp() { Name: "test-app", IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, LastInputCheckBlock: 100, } @@ -452,8 +450,8 @@ func (s *EvmReaderSuite) TestIConsensusInputCountMismatchSkipsApp() { Return(uint64(0), nil) s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) - s.Require().ErrorIs(err, errScanIncomplete) // per-app failure is reported after scanning + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) + s.Require().ErrorContains(err, "input count mismatch") // App was skipped: counter says 2 new, but only 1 fetched → no DB writes repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) @@ -474,7 +472,6 @@ func (s *EvmReaderSuite) TestIConsensusInputCountValidationUsesObservedEndCount( Name: "test-app", IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, LastInputCheckBlock: 100, } @@ -513,10 +510,10 @@ func (s *EvmReaderSuite) TestIConsensusInputCountValidationUsesObservedEndCount( }).Return(nil).Once() s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 105, []appContracts{{ + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 105, appContracts{ application: app, inputSource: inputSrc, - }}) + }) s.Require().NoError(err) repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) @@ -542,7 +539,6 @@ func (s *EvmReaderSuite) TestEpochLengthZeroSetsAppCorrupted() { Name: "test-app", IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 0, // will trigger corrupted LastInputCheckBlock: 100, }, @@ -550,23 +546,19 @@ func (s *EvmReaderSuite) TestEpochLengthZeroSetsAppCorrupted() { }} repo := newMockRepository() - repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). - Return(uint64(0), nil) repo.On("UpdateApplicationStatus", mock.Anything, int64(1), ApplicationStatus_Corrupted, mock.Anything, ).Return(nil) - repo.On("UpdateEventLastCheckBlock", - mock.Anything, mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil) s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) s.Require().NoError(err) // App must be set inoperable repo.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 1) // No epochs or inputs should be stored repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) } // --- Sealed epoch CreateEpochsAndInputs failure prevents checkpoint advance --- diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index 8eaf35863..bb1725416 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log/slog" "math/big" "time" @@ -16,6 +17,7 @@ import ( "github.com/ethereum/go-ethereum/rpc" "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/errutil" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -52,6 +54,7 @@ type EvmReaderRepository interface { UpdateEventLastCheckBlock(ctx context.Context, appIDs []int64, event MonitoredEvent, blockNumber uint64) error GetEventLastCheckBlock(ctx context.Context, appID int64, event MonitoredEvent) (uint64, error) + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) @@ -142,8 +145,6 @@ func (r *Service) Ready() bool { // A few failed cycles tolerate transient errors without hiding persistent stalls. const maxConsecutiveScanFailures = 3 -var errScanIncomplete = errors.New("one or more applications failed to scan or persist progress") - func (r *Service) processBlockHead( ctx context.Context, blockNumber uint64, @@ -152,7 +153,11 @@ func (r *Service) processBlockHead( r.Logger.Debug("Retrieving enabled applications") observableApps, _, err := listEnabledApplications(ctx, r.repository) if err != nil { - r.Logger.Error("Error retrieving L1-observable applications", "error", err) + level := slog.LevelError + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Error retrieving L1-observable applications", "error", err) return false } @@ -181,20 +186,37 @@ func (r *Service) processBlockHead( // Known input corruption with a persisted integrity status is application-local // degradation; observation still runs on later ticks. An idle scan is healthy. // Always run remaining scanners so one application's stall cannot block others. +// Stop dispatching new work when the service context ends; the active scanner +// has already handled its operation's error. func (r *Service) runBlockScanners( ctx context.Context, apps []appContracts, blockNumber uint64, ) bool { + if ctx.Err() != nil { + return false + } // Detect foreclosure first so later scanners use the marker observed in this same tick. success := r.checkForForeclosure(ctx, apps, blockNumber) + if ctx.Err() != nil { + return false + } plan := buildBlockScanPlan(apps) success = r.scanDaveConsensusEpochsAndInputs(ctx, plan.daveEpochTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } success = r.scanIConsensusInputs(ctx, plan.iConsensusInputTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } success = r.checkForOutputExecution(ctx, plan.outputTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } // Post-foreclosure observation dispatches to drive-prove discovery or withdrawal indexing. success = r.checkPostForeclosure(ctx, plan.postForeclosureTargets, blockNumber) && success @@ -258,12 +280,9 @@ func (f *DefaultAdapterFactory) CreateAdapters(app *Application) (ApplicationCon return nil, nil, nil, fmt.Errorf("error building application contract: %w", err) } - var inputSource InputSourceAdapter - if app.HasDataAvailabilitySelector(DataAvailability_InputBox) { - inputSource, err = NewInputSourceAdapter(app.IInputBoxAddress, f.Client, f.Filter) - if err != nil { - return nil, nil, nil, fmt.Errorf("error building inputbox contract: %w", err) - } + inputSource, err := NewInputSourceAdapter(app.IInputBoxAddress, f.Client, f.Filter) + if err != nil { + return nil, nil, nil, fmt.Errorf("error building inputbox contract: %w", err) } var daveConsensus DaveConsensusAdapter diff --git a/internal/evmreader/evmreader_test.go b/internal/evmreader/evmreader_test.go index d4cc5f359..9b45193b5 100644 --- a/internal/evmreader/evmreader_test.go +++ b/internal/evmreader/evmreader_test.go @@ -63,7 +63,7 @@ func (s *EvmReaderSuite) SetupTest() { LogLevel: logLevel, EvmReaderReadyMaxStaleness: 200 * time.Millisecond, BlockchainDefaultBlock: DefaultBlock_Latest, - FeatureInputReaderEnabled: true, + BlockchainId: 1, EvmReaderPollingInterval: 100 * time.Millisecond, }, Repository: s.repository, @@ -257,7 +257,7 @@ func (s *EvmReaderSuite) TestTickScansWithServiceContext() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1).Run(assertValidContext) + ).Return(nil).Times(3).Run(assertValidContext) s.repository.On( "UpdateEventLastCheckBlock", mock.Anything, @@ -272,7 +272,7 @@ func (s *EvmReaderSuite) TestTickScansWithServiceContext() { s.Require().NoError(err) s.client.AssertCalled(s.T(), "HeaderByNumber", mock.Anything, mock.Anything) - s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 5) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 7) } func (s *EvmReaderSuite) TestFetchMostRecentHeaderReturnsErrorWhenHeaderNumberIsNil() { diff --git a/internal/evmreader/fixtures_test.go b/internal/evmreader/fixtures_test.go index 81a2bae02..74f633d7f 100644 --- a/internal/evmreader/fixtures_test.go +++ b/internal/evmreader/fixtures_test.go @@ -36,14 +36,13 @@ var ( inputAddedEvent3 = makeInputEvent(app1Addr, 3, 0x13) ) -// applications defines the two-app setup used by most tests. -// app1: InputBox DA (inputs are read), app2: non-InputBox DA (inputs filtered out). +// applications defines two applications that share one InputBox. var applications = []*Application{{ + ID: 1, Name: "my-app-1", IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 0x01, @@ -51,11 +50,11 @@ var applications = []*Application{{ LastInputCheckBlock: 0x00, LastOutputCheckBlock: 0x00, }, { + ID: 2, Name: "my-app-2", IApplicationAddress: app2Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: []byte{0x11, 0x32, 0x45, 0x56}, Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 0x01, diff --git a/internal/evmreader/foreclosure.go b/internal/evmreader/foreclosure.go index 3d12acd82..ffa56325a 100644 --- a/internal/evmreader/foreclosure.go +++ b/internal/evmreader/foreclosure.go @@ -9,6 +9,7 @@ import ( "fmt" "math/big" + "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) @@ -199,6 +200,7 @@ func (r *Service) checkForForeclosure( "address", app.application.IApplicationAddress, "foreclose_block", block, "foreclose_transaction", txHash) + appstatus.WarnBlockedForeclosure(r.Logger, app.application) } return success } diff --git a/internal/evmreader/foreclosure_test.go b/internal/evmreader/foreclosure_test.go index c60d754d0..16b4f2a94 100644 --- a/internal/evmreader/foreclosure_test.go +++ b/internal/evmreader/foreclosure_test.go @@ -4,11 +4,13 @@ package evmreader import ( + "bytes" "context" "errors" "log/slog" "math/big" "os" + "strings" "testing" . "github.com/cartesi/rollups-node/internal/model" @@ -23,6 +25,34 @@ import ( "github.com/stretchr/testify/require" ) +func TestCheckForForeclosureWarnsForFailedApplicationOnce(t *testing.T) { + s, contract, repo := newForeclosureServiceFixture(t) + defer contract.AssertExpectations(t) + defer repo.AssertExpectations(t) + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app := foreclosureTestApp(1) + app.Status = ApplicationStatus_Failed + app.Reason = new("machine process crashed") + app.LastForecloseCheckBlock = 10 + const head, foreclosure = uint64(100), uint64(80) + txHash := common.HexToHash("0xfeed") + contract.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + contract.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(foreclosure, txHash)}, nil).Once() + repo.On("UpdateApplicationForeclosure", mock.Anything, app.ID, foreclosure, txHash, head).Return(nil).Once() + + apps := []appContracts{foreclosureAppContracts(app, contract)} + require.True(t, s.checkForForeclosure(t.Context(), apps, head)) + require.True(t, s.checkForForeclosure(t.Context(), apps, head+1)) + + require.Equal(t, 1, strings.Count(logs.String(), "failure blocks foreclosure drain")) + require.Contains(t, logs.String(), "foreclose_block=80") + require.Contains(t, logs.String(), "machine process crashed") + require.Contains(t, logs.String(), "repair the cause before clearing FAILED") + require.Equal(t, ApplicationStatus_Failed, app.Status, "the diagnostic must not clear the health gate") +} + // newForeclosureServiceFixture builds the smallest Service surface that // checkForForeclosure / foreclosureSearchStartBlock reach for, plus the // mocks bound to it. This avoids the full EvmReaderSuite bootstrap which diff --git a/internal/evmreader/input.go b/internal/evmreader/input.go index c9846eb97..c7cca3c99 100644 --- a/internal/evmreader/input.go +++ b/internal/evmreader/input.go @@ -7,9 +7,11 @@ import ( "context" "errors" "fmt" + "log/slog" "math" "math/big" + "github.com/cartesi/rollups-node/internal/errutil" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -42,23 +44,13 @@ func (r *Service) initializeNewApplicationInputSync( "current_block", mostRecentBlockNumber, ) if app.application.IInputBoxBlock == 0 { - r.Logger.Error("Application has no InputBox block number defined", - "application", app.application.Name, - "inputbox", app.application.IInputBoxAddress, - "iinputbox_block", app.application.IInputBoxBlock, - ) return 0, errors.New("application has no InputBox block number defined") } lastInputCheckBlock := app.application.IInputBoxBlock - 1 err := r.repository.UpdateEventLastCheckBlock(ctx, []int64{app.application.ID}, MonitoredEvent_InputAdded, lastInputCheckBlock) if err != nil { - r.Logger.Error("Failed to update application LastInputCheckBlock", - "application", app.application.Name, - "last_input_check_block", lastInputCheckBlock, - "error", err, - ) - return 0, err + return 0, fmt.Errorf("initialize input cursor at block %d: %w", lastInputCheckBlock, err) } r.Logger.Debug("Application input sync initialized", "application", app.application.Name, @@ -77,15 +69,13 @@ func (r *Service) scanIConsensusInputs( applications []appContracts, mostRecentBlockNumber uint64, ) bool { - success := true - if !r.inputReaderEnabled { - return success - } - r.Logger.Debug("Checking for new inputs") units, success := r.buildIConsensusInputScanUnits(ctx, applications, mostRecentBlockNumber) for _, unit := range units { + if ctx.Err() != nil { + return false + } success = r.scanIConsensusInputUnit(ctx, unit) && success } return success @@ -99,9 +89,6 @@ func (r *Service) buildIConsensusInputScanUnits( success := true appsByInputBox := map[common.Address][]appContracts{} for _, app := range applications { - if !app.application.HasDataAvailabilitySelector(DataAvailability_InputBox) { - continue - } key := app.application.IInputBoxAddress appsByInputBox[key] = append(appsByInputBox[key], app) } @@ -115,6 +102,9 @@ func (r *Service) buildIConsensusInputScanUnits( appsByLastInputCheckBlock := make(map[iConsensusInputScanRange][]appContracts) for _, app := range inputBoxApps { + if ctx.Err() != nil { + return nil, false + } lastInputCheckBlock := app.application.LastInputCheckBlock if lastInputCheckBlock == 0 { // New application. Find a safe start block to scan for inputs var err error @@ -125,17 +115,19 @@ func (r *Service) buildIConsensusInputScanUnits( ) if err != nil { success = false - r.Logger.Error("Failed to initialize application input sync", - "application", app.application.Name, - "most_recent_block", endBlock, - "error", err, - ) + r.reportInputScanError(ctx, app, 0, endBlock, err) continue } } scanEndBlock := foreclosureBoundedEndBlock(app.application, endBlock) if lastInputCheckBlock > scanEndBlock { - r.Logger.Warn( + level := slog.LevelWarn + if app.application.IInputBoxBlock != 0 && lastInputCheckBlock == app.application.IInputBoxBlock-1 { + // Registration can initialize the cursor before deployment is + // visible at the configured observation block. + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Input search skipped: most recent block is lower than the last processed one", "application", app.application.Name, "last_processed_block", lastInputCheckBlock, @@ -192,19 +184,15 @@ func (r *Service) scanIConsensusInputUnit( "most_recent_block", unit.endBlock, ) - err := r.readAndStoreInputs(ctx, - unit.lastInputCheckBlock, - unit.endBlock, - unit.apps, - ) - if err != nil { - success = false - r.Logger.Error("Error reading inputs", - "apps", appAddresses, - "last_processed_block", unit.lastInputCheckBlock, - "most_recent_block", unit.endBlock, - "error", err, - ) + for _, app := range unit.apps { + if ctx.Err() != nil { + return false + } + err := r.readAndStoreApplicationInputs(ctx, unit.lastInputCheckBlock, unit.endBlock, app) + if err != nil { + success = false + r.reportInputScanError(ctx, app, unit.lastInputCheckBlock, unit.endBlock, err) + } } return success } @@ -227,6 +215,25 @@ func (r *Service) scanIConsensusInputUnit( return success } +// The application scan owns operational error logging. Helpers return the cause; +// status transitions retain their existing, separately reported diagnostics. +func (r *Service) reportInputScanError(ctx context.Context, app appContracts, from, to uint64, err error) { + if errors.Is(err, errApplicationStatusReported) { + return + } + level := slog.LevelError + if ctx.Err() == context.Canceled && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Application input scan interrupted", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "last_processed_block", from, + "most_recent_block", to, + "error", err, + ) +} + // ErrInputForNonOpenEpoch indicates that an input was received for an epoch // that is not open — a state that should never occur during normal operation. var ErrInputForNonOpenEpoch = errors.New("received input for non-open epoch") @@ -300,285 +307,128 @@ func indexInputsIntoEpochs( return epochInputMap, nil } -// recordInputCorruption separates a known application-local integrity failure -// from failure to persist its status. Status helpers update app.Status only after -// a successful write and preserve previously persisted integrity terminals. -// Observation must continue on later ticks, including for terminal applications. +// recordInputCorruption keeps a recorded integrity fault local to the application. +// A failed status write still fails the scan. Status helpers update app.Status +// only after a successful write and preserve existing integrity terminals. func (r *Service) recordInputCorruption( ctx context.Context, app *Application, reasonFmt string, args ...any, -) bool { +) error { reason := fmt.Sprintf(reasonFmt, args...) - // setApplicationCorrupted always returns non-nil (the reason text itself). - // The DB error case is already logged inside setApplicationStatus. - // TODO: consider returning only DB errors instead of always returning an error. - _ = r.setApplicationCorrupted(ctx, app, "%s", reason) + err := r.setApplicationCorrupted(ctx, app, "%s", reason) if app.Status == ApplicationStatus_Corrupted || app.Status == ApplicationStatus_Diverged { r.Logger.Warn("Input observation degraded for application", "application", app.Name, "reason", reason) - return true + return nil } - return false + return err } -// readAndStoreInputs reads, inputs from the InputSource given specific filter options, indexes -// them into epochs and store the indexed inputs and epochs -func (r *Service) readAndStoreInputs( +// readAndStoreApplicationInputs completes one application's scan before its +// cursor can advance. A failed fetch, epoch lookup, or write leaves it retryable. +func (r *Service) readAndStoreApplicationInputs( ctx context.Context, lastProcessedBlock uint64, mostRecentBlockNumber uint64, - apps []appContracts, + app appContracts, ) error { - if len(apps) == 0 { - r.Logger.Warn("No valid running applications") - return nil + address := app.application.IApplicationAddress + epochLength := app.application.EpochLength + if epochLength == 0 { + return r.recordInputCorruption(ctx, app.application, "Application has epoch length of zero") } - // Retrieve Inputs from blockchain - nextSearchBlock := lastProcessedBlock + 1 - appInputsMap, err := r.readInputsFromBlockchain(ctx, apps, nextSearchBlock, mostRecentBlockNumber) + inputs, err := r.readApplicationInputs(ctx, app, lastProcessedBlock+1, mostRecentBlockNumber) if err != nil { - return fmt.Errorf("failed to read inputs from block %v to block %v. %w", - nextSearchBlock, - mostRecentBlockNumber, - err) + return err } - - var scanIncomplete bool - - if len(appInputsMap) != len(apps) { - scanIncomplete = true + currentEpoch, err := r.repository.GetEpoch(ctx, address.String(), calculateEpochIndex(epochLength, lastProcessedBlock)) + if err != nil { + return fmt.Errorf("retrieve current epoch: %w", err) } - addrToApp := mapAddressToApp(apps) - - // Index Inputs into epochs and handle epoch finalization - for address, inputs := range appInputsMap { - - app, exists := addrToApp[address] - if !exists { - r.Logger.Error("Application address on input not found", - "address", address) - scanIncomplete = true - continue - } - - epochLength := app.application.EpochLength - if epochLength == 0 { - ok := r.recordInputCorruption(ctx, app.application, - "Application has epoch length of zero") - scanIncomplete = scanIncomplete || !ok - continue - } - - // Retrieves last open epoch from DB - currentEpoch, err := r.repository.GetEpoch(ctx, address.String(), calculateEpochIndex(epochLength, lastProcessedBlock)) - if err != nil { - // Shutdown cancels the ctx mid-query; downgrade to Debug - // for the graceful-stop case. DeadlineExceeded would still - // flow through the Error branch. - if errors.Is(err, context.Canceled) { - r.Logger.Debug("GetEpoch canceled during shutdown", - "application", app.application.Name, - "address", address, - "error", err, - ) - } else { - r.Logger.Error("Error retrieving existing current epoch", - "application", app.application.Name, - "address", address, - "error", err, - ) - } - scanIncomplete = true - continue + epochInputMap, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, ErrInputForNonOpenEpoch) { + return r.recordInputCorruption(ctx, app.application, "Should never happen. %v", err) } + return fmt.Errorf("index inputs: %w", err) + } - // Index inputs into epochs using pure function - epochInputMap, err := indexInputsIntoEpochs( - epochLength, currentEpoch, inputs, mostRecentBlockNumber) + if len(epochInputMap) == 0 { + // No epoch needs to be stored or closed. Only this application's + // successfully processed range is eligible for the cursor-only update. + err = r.repository.UpdateEventLastCheckBlock( + ctx, []int64{app.application.ID}, MonitoredEvent_InputAdded, mostRecentBlockNumber) if err != nil { - if errors.Is(err, ErrInputForNonOpenEpoch) { - ok := r.recordInputCorruption(ctx, app.application, - "Should never happen. %v", err) - scanIncomplete = scanIncomplete || !ok - continue - } - return fmt.Errorf("error indexing inputs: %w", err) + return fmt.Errorf("update input cursor: %w", err) } + return nil + } - for epoch, epochInputs := range epochInputMap { - if epoch.Status == EpochStatus_Closed { - r.Logger.Info("Closing epoch", - "application", app.application.Name, - "address", address, - "epoch_index", epoch.Index, - "start", epoch.FirstBlock, - "end", epoch.LastBlock) - } - for _, input := range epochInputs { - r.Logger.Info("Found new Input", - "application", app.application.Name, - "address", address, - "index", input.Index, - "block", input.BlockNumber, - "epoch_index", epoch.Index) - } + // This transaction stores inputs, closes epochs, and advances the cursor. + // An epoch closure with no new inputs must use the same atomic write. + err = r.repository.CreateEpochsAndInputs(ctx, address.String(), epochInputMap, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.recordInputCorruption(ctx, app.application, + "stored input L1 log identity conflicts with rescanned chain data"+ + " (possible reorg past the input cursor); operator reset required. %v", err) } + return fmt.Errorf("store inputs and epochs: %w", err) + } - // Store everything - if len(epochInputMap) > 0 { - err = r.repository.CreateEpochsAndInputs( - ctx, - address.String(), - epochInputMap, - mostRecentBlockNumber, - ) - if err != nil { - if errors.Is(err, repository.ErrInputLogIdentityConflict) { - ok := r.recordInputCorruption(ctx, app.application, - "stored input L1 log identity conflicts with rescanned chain data"+ - " (possible reorg past the input cursor); operator reset required. %v", err) - scanIncomplete = scanIncomplete || !ok - continue - } - r.Logger.Error("Error storing inputs and epochs", - "application", app.application.Name, - "address", address, - "error", err, - ) - scanIncomplete = true - continue - } - r.Logger.Debug("Inputs and epochs stored successfully", + for epoch, epochInputs := range epochInputMap { + if epoch.Status == EpochStatus_Closed { + r.Logger.Info("Closing epoch", "application", app.application.Name, "address", address, - "start_block", nextSearchBlock, - "end_block", mostRecentBlockNumber, - "epoch_count", len(epochInputMap), - "input_count", len(inputs), - ) - } else { - r.Logger.Debug("No inputs or epochs to store") - } - - } - - // Update LastInputCheckBlock for applications that were successfully scanned - // but didn't have any inputs. - // For apps WITH inputs, LastInputCheckBlock is already updated atomically inside - // CreateEpochsAndInputs (same DB transaction as the epoch/input inserts). - // This separate call for no-input apps is NOT in the same transaction. If the process - // crashes between the two, no-input apps will re-scan the block range on restart. - // This is benign: the re-scan finds no inputs and updates the checkpoint idempotently. - // Only apps present in appInputsMap were successfully scanned. Apps that failed - // to fetch are absent from the map and their checkpoint must NOT advance, - // otherwise inputs in the failed block range would be permanently skipped. - appsToUpdate := []int64{} - for _, app := range apps { - appAddress := app.application.IApplicationAddress - if inputs, exists := appInputsMap[appAddress]; exists && len(inputs) == 0 { - appsToUpdate = append(appsToUpdate, app.application.ID) + "epoch_index", epoch.Index, + "start", epoch.FirstBlock, + "end", epoch.LastBlock) } - } - // Update LastInputCheckBlock for applications without inputs - if len(appsToUpdate) > 0 { - err := r.repository.UpdateEventLastCheckBlock(ctx, appsToUpdate, MonitoredEvent_InputAdded, mostRecentBlockNumber) - if err != nil { - // Shutdown cancels the ctx mid-update; downgrade to Debug - // for the graceful-stop case. DeadlineExceeded would still - // flow through the Error branch. - if errors.Is(err, context.Canceled) { - r.Logger.Debug("UpdateEventLastCheckBlock canceled during shutdown", - "app_ids", appsToUpdate, - "block_number", mostRecentBlockNumber, - "error", err, - ) - } else { - r.Logger.Error("Failed to update LastInputCheckBlock for applications without inputs", - "app_ids", appsToUpdate, - "block_number", mostRecentBlockNumber, - "error", err, - ) - } - scanIncomplete = true - } else { - r.Logger.Debug("Updated LastInputCheckBlock for applications without inputs", - "app_ids", appsToUpdate, - "block_number", mostRecentBlockNumber, - ) + for _, input := range epochInputs { + r.Logger.Info("Found new Input", + "application", app.application.Name, + "address", address, + "index", input.Index, + "block", input.BlockNumber, + "epoch_index", epoch.Index) } } - - if scanIncomplete { - return errScanIncomplete - } + r.Logger.Debug("Inputs and epochs stored successfully", + "application", app.application.Name, + "address", address, + "start_block", lastProcessedBlock+1, + "end_block", mostRecentBlockNumber, + "epoch_count", len(epochInputMap), + "input_count", len(inputs), + ) return nil } -// readInputsFromBlockchain fetches inputs for each application independently. -// On per-app failure, the failing app is omitted from the returned map (not -// present as a key) and processing continues for the remaining apps. The error -// return is reserved for fatal failures that prevent any work. -// Callers must use map-key presence to distinguish success (key exists, possibly -// with an empty slice) from failure (key absent) — apps absent from the map must -// NOT have their checkpoint advanced. -func (r *Service) readInputsFromBlockchain( +// readApplicationInputs preserves the failure cause for the scan caller. It +// validates the fetched logs against the counter observed in the same walk. +func (r *Service) readApplicationInputs( ctx context.Context, - apps []appContracts, + app appContracts, startBlock, endBlock uint64, -) (map[common.Address][]*Input, error) { - - // Initialize app input map - var appInputsMap = make(map[common.Address][]*Input) - - for _, app := range apps { - inputCount, err := r.repository.GetNumberOfInputs( - ctx, app.application.IApplicationAddress.String()) - if err != nil { - r.Logger.Error("Error getting input count for application", - "application", app.application.Name, - "error", err.Error(), - ) - continue - } - prevValue := new(big.Int).SetUint64(inputCount) - inputs, endCount, err := r.fetchInputs( - ctx, app, startBlock, endBlock, - prevValue, 0, math.MaxUint64) - if err != nil { - r.Logger.Error("Error fetching inputs for application", - "application", app.application.Name, - "start_block", startBlock, - "end_block", endBlock, - "error", err.Error(), - ) - continue - } - - // Validate input count: the on-chain counter delta observed during the - // transition walk should match the number of inputs fetched. This mirrors - // the DaveConsensus sealed epoch validation at sealedepochs.go and guards - // against silent input loss from FindTransitions missing a transition. - expectedNew := endCount - inputCount - if uint64(len(inputs)) != expectedNew { - r.Logger.Error( - "Input count mismatch: on-chain delta does not match fetched inputs", - "application", app.application.Name, - "db_count", inputCount, - "on_chain_end_count", endCount, - "expected_new", expectedNew, - "got", len(inputs), - "start_block", startBlock, - "end_block", endBlock, - ) - continue - } - - appInputsMap[app.application.IApplicationAddress] = inputs +) ([]*Input, error) { + inputCount, err := r.repository.GetNumberOfInputs(ctx, app.application.IApplicationAddress.String()) + if err != nil { + return nil, fmt.Errorf("get stored input count: %w", err) } - - return appInputsMap, nil + inputs, endCount, err := r.fetchInputs( + ctx, app, startBlock, endBlock, new(big.Int).SetUint64(inputCount), 0, math.MaxUint64) + if err != nil { + return nil, fmt.Errorf("fetch inputs: %w", err) + } + expectedNew := endCount - inputCount + if uint64(len(inputs)) != expectedNew { + return nil, fmt.Errorf("input count mismatch: stored %d, on-chain %d, expected %d new inputs, got %d", + inputCount, endCount, expectedNew, len(inputs)) + } + return inputs, nil } // fetchInputs locates blocks where new inputs were added via FindTransitions diff --git a/internal/evmreader/input_scan_test.go b/internal/evmreader/input_scan_test.go new file mode 100644 index 000000000..fed23db24 --- /dev/null +++ b/internal/evmreader/input_scan_test.go @@ -0,0 +1,278 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + scanInitializeCursor = "initialize cursor" + scanStoredCount = "stored count" + scanChainCounter = "chain counter" + scanInputLogs = "input logs" + scanEpochLookup = "epoch lookup" + scanEpochWrite = "epoch write" + scanCursorWrite = "cursor write" +) + +// Exercise the scan caller, not just its error classifier. Cancellation occurs +// inside the failing operation, as it does when shutdown interrupts a DB query. +func TestInputScanErrorReporting(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, operation := range []string{ + scanInitializeCursor, scanStoredCount, scanChainCounter, scanInputLogs, scanEpochLookup, scanEpochWrite, scanCursorWrite, + } { + t.Run(operation, func(t *testing.T) { + for _, test := range []struct { + name string + err error + cancel bool + quiet bool + }{ + {name: "shutdown", err: fmt.Errorf("query: %w", context.Canceled), cancel: true, quiet: true}, + {name: "joined shutdown", err: errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), + cancel: true, quiet: true}, + {name: "active context cancellation", err: context.Canceled}, + {name: "database failure", err: dbErr}, + {name: "database failure during shutdown", err: dbErr, cancel: true}, + {name: "timeout", err: context.DeadlineExceeded}, + {name: "timeout during shutdown", err: context.DeadlineExceeded, cancel: true}, + {name: "mixed failure", err: errors.Join(context.Canceled, dbErr), cancel: true}, + {name: "mixed timeout", err: errors.Join(context.Canceled, context.DeadlineExceeded), cancel: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, output := inputScanFixture(t, operation, test.err, func(mock.Arguments) { + if test.cancel { + cancel() + } + }) + require.False(t, reader.scanIConsensusInputs(ctx, []appContracts{app}, 110)) + logs := output.String() + require.Equal(t, 1, strings.Count(logs, `"msg":"Application input scan interrupted"`), logs) + if test.quiet { + require.NotContains(t, logs, `"level":"ERROR"`) + } else { + require.Equal(t, 1, strings.Count(logs, `"level":"ERROR"`), logs) + } + require.Contains(t, logs, `"application":"input-scan-app"`) + require.Contains(t, logs, `"address":"`+strings.ToLower(app1Addr.Hex())+`"`) + require.Contains(t, logs, `"most_recent_block":110`) + if errors.Is(test.err, dbErr) { + require.Contains(t, logs, dbErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs, context.DeadlineExceeded.Error()) + } + if operation != scanInitializeCursor && operation != scanCursorWrite { + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + } + if operation != scanEpochWrite { + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 0) + } + }) + } + }) + } +} + +// Set up exactly one failing operation. Unreached operations have no expectation: +// an attempt to write after a failed read fails the test immediately. +func inputScanFixture(t *testing.T, operation string, failure error, during func(mock.Arguments)) ( + *Service, appContracts, *MockRepository, *bytes.Buffer, +) { + t.Helper() + app := appContracts{application: &model.Application{ + ID: 1, Name: "input-scan-app", IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 90, EpochLength: 10, LastInputCheckBlock: 100, ConsensusType: model.Consensus_Authority, + Enabled: true, Status: model.ApplicationStatus_OK, + }} + repo := newMockRepository() + input := newMockInputBox() + app.inputSource = input + output := new(bytes.Buffer) + reader := &Service{repository: repo, defaultBlock: model.DefaultBlock_Latest} + reader.Logger = slog.New(slog.NewJSONHandler(output, &slog.HandlerOptions{Level: slog.LevelDebug})) + t.Cleanup(func() { + repo.AssertExpectations(t) + input.AssertExpectations(t) + }) + if operation == scanInitializeCursor { + app.application.LastInputCheckBlock = 0 + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{1}, model.MonitoredEvent_InputAdded, uint64(89)). + Run(during).Return(failure).Once() + return reader, app, repo, output + } + count := repo.On("GetNumberOfInputs", mock.Anything, app1Addr.Hex()).Return(uint64(0), nil).Once() + if operation == scanStoredCount { + count.Run(during).Return(uint64(0), failure) + return reader, app, repo, output + } + chainCount := input.On("GetNumberOfInputs", mock.Anything, app1Addr).Return(big.NewInt(0), nil) + if operation == scanChainCounter { + chainCount.Run(during).Return((*big.Int)(nil), failure).Once() + return reader, app, repo, output + } + if operation == scanInputLogs { + chainCount.Return(big.NewInt(1), nil) + input.On("RetrieveInputs", mock.Anything, mock.Anything, mock.Anything). + Run(during).Return([]iinputbox.IInputBoxInputAdded(nil), failure).Once() + return reader, app, repo, output + } + epoch := repo.On("GetEpoch", mock.Anything, app1Addr.Hex(), uint64(10)).Return((*model.Epoch)(nil), nil).Once() + switch operation { + case scanEpochLookup: + epoch.Run(during).Return((*model.Epoch)(nil), failure) + case scanEpochWrite: + // Closing an epoch with no new inputs still requires an atomic epoch+cursor write. + epoch.Return(&model.Epoch{Index: 10, FirstBlock: 100, LastBlock: 109, Status: model.EpochStatus_Open}, nil) + repo.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), mock.Anything, uint64(110)). + Run(during).Return(failure).Once() + case scanCursorWrite: + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{1}, model.MonitoredEvent_InputAdded, uint64(110)). + Run(during).Return(failure).Once() + default: + t.Fatalf("unknown input-scan operation %q", operation) + } + return reader, app, repo, output +} + +func TestInputScanHelperPreservesError(t *testing.T) { + for _, operation := range []string{scanStoredCount, scanChainCounter, scanInputLogs, scanEpochLookup, scanEpochWrite, scanCursorWrite} { + t.Run(operation, func(t *testing.T) { + cause := errors.New("operation failed") + reader, app, _, logs := inputScanFixture(t, operation, cause, func(mock.Arguments) {}) + err := reader.readAndStoreApplicationInputs(t.Context(), 100, 110, app) + require.ErrorIs(t, err, cause) + require.NotEqual(t, cause.Error(), err.Error(), "the operation must add context") + require.NotContains(t, logs.String(), `"level":"ERROR"`, "the caller owns operational error logging") + require.NotContains(t, logs.String(), "Application input scan interrupted") + }) + } +} + +func TestInputScanClosesEmptyEpochAtomically(t *testing.T) { + reader, app, repo, _ := inputScanFixture(t, scanEpochWrite, nil, func(args mock.Arguments) { + epochs := args.Get(2).(map[*model.Epoch][]*model.Input) + require.Len(t, epochs, 1) + for epoch, inputs := range epochs { + require.Equal(t, model.EpochStatus_Closed, epoch.Status) + require.EqualValues(t, 10, epoch.Index) + require.Empty(t, inputs) + } + }) + require.True(t, reader.scanIConsensusInputs(t.Context(), []appContracts{app}, 110)) + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 1) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) +} + +func TestInputScanStatusFailureReportedOnce(t *testing.T) { + repo := newMockRepository() + repo.On("UpdateApplicationStatus", mock.Anything, int64(1), model.ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + app := appContracts{application: &model.Application{ + ID: 1, Name: "invalid-epoch-length", IApplicationAddress: app1Addr, + Status: model.ApplicationStatus_OK, LastInputCheckBlock: 100, + }} + var logs bytes.Buffer + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + require.True(t, reader.scanIConsensusInputs(t.Context(), []appContracts{app}, 110), + "the recorded integrity fault is local to this application") + require.Contains(t, logs.String(), "Application has epoch length of zero") + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + require.NotContains(t, logs.String(), "Application input scan interrupted") + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + repo.AssertExpectations(t) +} + +func TestInputScanContinuesAfterApplicationFailure(t *testing.T) { + reader, failed, repo, logs := inputScanFixture(t, scanEpochLookup, errors.New("epoch lookup failed"), func(mock.Arguments) {}) + healthy := appContracts{application: &model.Application{ + ID: 2, Name: "healthy", IApplicationAddress: app2Addr, EpochLength: 10, LastInputCheckBlock: 100, + }} + input := newMockInputBox() + input.On("GetNumberOfInputs", mock.Anything, app2Addr).Return(big.NewInt(0), nil) + healthy.inputSource = input + repo.On("GetNumberOfInputs", mock.Anything, app2Addr.Hex()).Return(uint64(0), nil).Once() + repo.On("GetEpoch", mock.Anything, app2Addr.Hex(), uint64(10)).Return((*model.Epoch)(nil), nil).Once() + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{2}, model.MonitoredEvent_InputAdded, uint64(110)). + Return(nil).Once() + require.False(t, reader.scanIConsensusInputUnit(t.Context(), iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: []appContracts{failed, healthy}, + })) + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 1) + input.AssertExpectations(t) +} + +func TestInputScanShutdownStopsLaterWork(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, logs := inputScanFixture(t, scanEpochLookup, context.Canceled, func(mock.Arguments) { cancel() }) + app.application.LastForecloseCheckBlock = 110 + app.application.LastOutputCheckBlock = 100 + + // Output observation would query the repository if dispatch continued after shutdown. + require.False(t, reader.runBlockScanners(ctx, []appContracts{app}, 110)) + repo.AssertNumberOfCalls(t, "GetNumberOfPendingExecutableOutputs", 0) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + require.NotContains(t, logs.String(), `"level":"ERROR"`) +} + +func TestInputScanFailureThenShutdownKeepsEarlierError(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, failed, repo, logs := inputScanFixture(t, scanStoredCount, errors.New("database unavailable"), func(mock.Arguments) {}) + canceled := appContracts{application: &model.Application{ + ID: 2, Name: "canceled", IApplicationAddress: app2Addr, EpochLength: 10, + }} + repo.On("GetNumberOfInputs", mock.Anything, app2Addr.Hex()). + Run(func(mock.Arguments) { cancel() }).Return(uint64(0), context.Canceled).Once() + // The third scan has no expectations. It must not start after cancellation. + require.False(t, reader.scanIConsensusInputUnit(ctx, iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: []appContracts{failed, canceled, failed}, + })) + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + require.Contains(t, logs.String(), "database unavailable") +} + +func TestInputScanCancellationDoesNotRefreshReadiness(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, logs := inputScanFixture(t, scanEpochLookup, context.Canceled, func(mock.Arguments) { cancel() }) + app.application.LastForecloseCheckBlock = 110 + app.application.LastOutputCheckBlock = 100 + client := newMockEthClient() + client.On("HeaderByNumber", mock.Anything, mock.Anything).Return(&types.Header{Number: big.NewInt(110)}, nil).Once() + reader.client = client + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app.application}, uint64(1), nil).Once() + reader.resolver = newApplicationAdapterResolver(reader.Logger, + newMockAdapterFactory().SetupDefaultBehaviorSingleApp(newMockApplicationContract(), app.inputSource.(*MockInputBox))) + previous := time.Now().Add(-time.Minute) + reader.lastSuccessfulPoll.Store(&previous) + reader.consecutiveScanFailures.Store(2) + _, err := reader.Tick(ctx) + require.NoError(t, err) + require.Equal(t, previous, *reader.lastSuccessfulPoll.Load()) + require.EqualValues(t, 2, reader.consecutiveScanFailures.Load()) + require.NotContains(t, logs.String(), `"level":"ERROR"`) +} diff --git a/internal/evmreader/input_scan_units_test.go b/internal/evmreader/input_scan_units_test.go index aae2fce02..d5bc2cb03 100644 --- a/internal/evmreader/input_scan_units_test.go +++ b/internal/evmreader/input_scan_units_test.go @@ -33,8 +33,8 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { { name: "same input box and cursor share one unit", apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitApp(2, inputBoxA, 10, true), + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxA, 10), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 20}: {1, 2}}, @@ -43,8 +43,8 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { { name: "same input box and different cursor produce separate units", apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitApp(2, inputBoxA, 11, true), + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxA, 11), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 20}: {1}, {11, 20}: {2}}, @@ -53,28 +53,18 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { { name: "different input boxes produce separate units", apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitApp(2, inputBoxB, 10, true), + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxB, 10), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 20}: {1}}, inputBoxB: {{10, 20}: {2}}, }, }, - { - name: "non-InputBox data availability apps are excluded", - apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitApp(2, inputBoxA, 10, false), - }, - units: map[common.Address]map[iConsensusInputScanRange][]int64{ - inputBoxA: {{10, 20}: {1}}, - }, - }, { name: "foreclosed app scans only through the foreclose block", apps: []appContracts{ - inputUnitAppWithForeclose(1, inputBoxA, 10, true, 15), + inputUnitAppWithForeclose(1, inputBoxA, 10, 15), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 15}: {1}}, @@ -83,15 +73,15 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { { name: "foreclosed app already checked through foreclosure is excluded", apps: []appContracts{ - inputUnitAppWithForeclose(1, inputBoxA, 15, true, 15), + inputUnitAppWithForeclose(1, inputBoxA, 15, 15), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{}, }, { name: "same input box and cursor split when foreclosure changes end block", apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitAppWithForeclose(2, inputBoxA, 10, true, 15), + inputUnitApp(1, inputBoxA, 10), + inputUnitAppWithForeclose(2, inputBoxA, 10, 15), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 20}: {1}, {10, 15}: {2}}, @@ -101,7 +91,8 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - units, _ := reader.buildIConsensusInputScanUnits(ctx, tt.apps, 20) + units, success := reader.buildIConsensusInputScanUnits(ctx, tt.apps, 20) + require.True(t, success) require.Equal(t, tt.units, inputScanUnitIDs(units)) }) } @@ -119,11 +110,12 @@ func TestBuildIConsensusInputScanUnits_InitializesBeforeGrouping(t *testing.T) { repository: repo, } inputBox := common.HexToAddress("0x00000000000000000000000000000000000000a1") - app := inputUnitApp(1, inputBox, 0, true) + app := inputUnitApp(1, inputBox, 0) app.application.IInputBoxBlock = 7 - units, _ := reader.buildIConsensusInputScanUnits(ctx, []appContracts{app}, 20) + units, success := reader.buildIConsensusInputScanUnits(ctx, []appContracts{app}, 20) + require.True(t, success) require.Equal(t, map[common.Address]map[iConsensusInputScanRange][]int64{ inputBox: {{6, 20}: {1}}, }, inputScanUnitIDs(units)) @@ -139,9 +131,9 @@ func TestBuildIConsensusInputScanUnits_FailedInitializationExcludesOnlyThatApp(t }, } inputBox := common.HexToAddress("0x00000000000000000000000000000000000000a1") - broken := inputUnitApp(1, inputBox, 0, true) + broken := inputUnitApp(1, inputBox, 0) broken.application.IInputBoxBlock = 0 - good := inputUnitApp(2, inputBox, 10, true) + good := inputUnitApp(2, inputBox, 10) units, success := reader.buildIConsensusInputScanUnits(ctx, []appContracts{broken, good}, 20) require.False(t, success) @@ -151,28 +143,22 @@ func TestBuildIConsensusInputScanUnits_FailedInitializationExcludesOnlyThatApp(t }, inputScanUnitIDs(units)) } -func inputUnitApp(id int64, inputBox common.Address, cursor uint64, hasInputBoxDA bool) appContracts { - return inputUnitAppWithForeclose(id, inputBox, cursor, hasInputBoxDA, 0) +func inputUnitApp(id int64, inputBox common.Address, cursor uint64) appContracts { + return inputUnitAppWithForeclose(id, inputBox, cursor, 0) } func inputUnitAppWithForeclose( id int64, inputBox common.Address, cursor uint64, - hasInputBoxDA bool, forecloseBlock uint64, ) appContracts { - dataAvailability := []byte{0xff} - if hasInputBoxDA { - dataAvailability = DataAvailability_InputBox[:] - } return appContracts{application: &Application{ ID: id, Name: "app", IApplicationAddress: common.BigToAddress(big.NewInt(id)), IInputBoxAddress: inputBox, IInputBoxBlock: 1, - DataAvailability: dataAvailability, Enabled: true, Status: ApplicationStatus_OK, LastInputCheckBlock: cursor, diff --git a/internal/evmreader/input_test.go b/internal/evmreader/input_test.go index 5fbf8184f..48073b816 100644 --- a/internal/evmreader/input_test.go +++ b/internal/evmreader/input_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func (s *EvmReaderSuite) TestItReadsInputsFromNewBlocksFilteredByDA() { +func (s *EvmReaderSuite) TestItReadsInputsFromDirectInputBox() { s.client.EnqueueNewHead(0x11).Once() s.client.EnqueueNewHead(0x12).Once() s.client.EnqueueNewHead(0x13).Once() @@ -26,7 +26,13 @@ func (s *EvmReaderSuite) TestItReadsInputsFromNewBlocksFilteredByDA() { s.Require().True(waitNotification(called), "evmreader did not read new header") s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 3) - s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 9) + s.repository.AssertCalled(s.T(), "CreateEpochsAndInputs", + mock.Anything, applications[0].IApplicationAddress.String(), mock.Anything, uint64(0x13)) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 13) + s.repository.AssertCalled(s.T(), "UpdateEventLastCheckBlock", + mock.Anything, []int64{applications[0].ID}, MonitoredEvent_InputAdded, uint64(0)) + s.repository.AssertCalled(s.T(), "UpdateEventLastCheckBlock", + mock.Anything, []int64{applications[1].ID}, MonitoredEvent_InputAdded, uint64(0x13)) s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) s.repository.AssertExpectations(s.T()) @@ -47,31 +53,19 @@ func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(2) + ).Return(nil).Times(8) // Two initial cursors, then one update per app per block. s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, MonitoredEvent_OutputExecuted, mock.Anything, ).Return(nil).Times(4) - s.repository.On("UpdateEventLastCheckBlock", - mock.Anything, - mock.Anything, - MonitoredEvent_InputAdded, - mock.Anything, - ).Once().Return(nil) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, MonitoredEvent_OutputExecuted, mock.Anything, ).Return(nil).Times(2) - s.repository.On("UpdateEventLastCheckBlock", - mock.Anything, - mock.Anything, - MonitoredEvent_InputAdded, - mock.Anything, - ).Once().Return(nil) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -83,7 +77,7 @@ func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything, - ).Return(uint64(0), nil).Times(3) + ).Return(uint64(0), nil).Times(6) s.repository.Unset("CreateEpochsAndInputs") // Prepare sequence of inputs @@ -156,7 +150,6 @@ func (s *EvmReaderSuite) TestItReadsMultipleInputsFromSingleNewBlock() { IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 0x10, @@ -247,7 +240,6 @@ func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock( IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 0x10, @@ -302,7 +294,6 @@ func (s *EvmReaderSuite) TestCatchUpForeclosedInputsScansThroughForecloseBlock() IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 1, @@ -360,7 +351,6 @@ func (s *EvmReaderSuite) TestCatchUpForeclosedInputsStoresSameBlockInput() { IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 1, @@ -437,7 +427,7 @@ func (s *EvmReaderSuite) TestCatchUpForeclosedInputsStoresSameBlockInput() { } // TestCheckpointNotAdvancedOnFetchFailure is a regression test for a bug where -// readInputsFromBlockchain swallowed per-app fetch errors, and the caller then +// the input fetch swallowed per-app errors, and the caller then // advanced LastInputCheckBlock for failed apps — permanently skipping their inputs. func (s *EvmReaderSuite) TestCheckpointNotAdvancedOnFetchFailure() { require := require.New(s.T()) @@ -509,8 +499,9 @@ func (s *EvmReaderSuite) TestCheckpointNotAdvancedOnFetchFailure() { s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) - require.ErrorIs(err, errScanIncomplete) + require.False(s.evmReader.scanIConsensusInputUnit(s.ctx, iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: apps, + })) repo.AssertExpectations(s.T()) } diff --git a/internal/evmreader/mocks_test.go b/internal/evmreader/mocks_test.go index a0ad09e04..76dfe61f2 100644 --- a/internal/evmreader/mocks_test.go +++ b/internal/evmreader/mocks_test.go @@ -76,7 +76,7 @@ func newMockEthClient() *MockEthClient { } func (m *MockEthClient) SetupDefaultBehavior() *MockEthClient { - m.On("ChainID", mock.Anything).Return(big.NewInt(0), nil) + m.On("ChainID", mock.Anything).Return(big.NewInt(1), nil) return m } @@ -123,32 +123,34 @@ func (m *MockInputBox) SetupDefaultBehavior() *MockInputBox { // RetrieveInputs: matched by Start block, not call order. m.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), - mock.Anything, + []common.Address{app1Addr}, mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) m.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x12 }), - mock.Anything, + []common.Address{app1Addr}, mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent1}, nil) m.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), - mock.Anything, + []common.Address{app1Addr}, mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent2, inputAddedEvent3}, nil) // GetNumberOfInputs: block-based matching models the on-chain state. // Each range returns the input count at that point in the blockchain. - m.On("GetNumberOfInputs", blockRange(0, 0x11), mock.Anything). + m.On("GetNumberOfInputs", blockRange(0, 0x11), app1Addr). Return(new(big.Int).SetUint64(0), nil) - m.On("GetNumberOfInputs", blockRange(0x11, 0x12), mock.Anything). + m.On("GetNumberOfInputs", blockRange(0x11, 0x12), app1Addr). Return(new(big.Int).SetUint64(1), nil) - m.On("GetNumberOfInputs", blockRange(0x12, 0x13), mock.Anything). + m.On("GetNumberOfInputs", blockRange(0x12, 0x13), app1Addr). Return(new(big.Int).SetUint64(2), nil) - m.On("GetNumberOfInputs", blockFrom(0x13), mock.Anything). + m.On("GetNumberOfInputs", blockFrom(0x13), app1Addr). Return(new(big.Int).SetUint64(4), nil) + m.On("GetNumberOfInputs", mock.Anything, app2Addr). + Return(new(big.Int).SetUint64(0), nil) return m } @@ -191,9 +193,7 @@ func newMockRepository() *MockRepository { func (m *MockRepository) SetupDefaultBehavior() *MockRepository { m.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). - Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound) - m.On("SaveNodeConfigRaw", mock.Anything, EvmReaderConfigKey, mock.Anything). - Return(nil) + Return([]byte(`{"DefaultBlock":"LATEST","ChainID":1}`), time.Time{}, time.Time{}, nil) apps := copyApplications(applications) m.On("ListApplications", @@ -205,6 +205,7 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 apps[0].LastOutputCheckBlock = 0x11 apps[1].LastOutputCheckBlock = 0x11 m.On("ListApplications", @@ -216,6 +217,7 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 apps[0].LastOutputCheckBlock = 0x12 apps[1].LastOutputCheckBlock = 0x12 m.On("ListApplications", @@ -230,7 +232,7 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) m.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -243,18 +245,10 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { mock.Anything, ).Return(nil).Maybe() - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(0), nil) - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(1), nil) - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(2), nil) + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(0), nil) + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(1), nil) + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(2), nil) + m.On("GetNumberOfInputs", mock.Anything, app2Addr.String()).Return(uint64(0), nil).Times(3) m.On("GetNumberOfExecutedOutputs", mock.Anything, @@ -266,14 +260,8 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { mock.Anything, mock.Anything).Return(nil) - m.On("GetEpoch", - mock.Anything, - mock.Anything, - uint64(0)).Return(nil, nil).Once() - m.On("GetEpoch", - mock.Anything, - mock.Anything, - uint64(1)).Return( + m.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(0)).Return(nil, nil).Once() + m.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(1)).Return( &Epoch{ Index: 1, FirstBlock: 11, @@ -282,6 +270,8 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { TxBufferDataBlock: nil, ClaimTransactionHash: nil, }, nil).Twice() + m.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(0)).Return(nil, nil).Once() + m.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(1)).Return(nil, nil).Twice() // Catch-all: returns empty list for extra headers. m.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). @@ -304,6 +294,11 @@ func (m *MockRepository) ListApplications( return args.Get(0).([]*Application), args.Get(1).(uint64), args.Error(2) } +func (m *MockRepository) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + func (m *MockRepository) SaveNodeConfigRaw( ctx context.Context, key string, rawJSON []byte, ) error { @@ -589,19 +584,9 @@ func (m *MockDaveConsensus) GetInputBox( func (m *MockDaveConsensus) GetCurrentSealedEpoch( opts *bind.CallOpts, -) (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address -}, error) { +) (DaveCurrentSealedEpoch, error) { args := m.Called(opts) - return args.Get(0).(struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address - }), args.Error(1) + return args.Get(0).(DaveCurrentSealedEpoch), args.Error(1) } func (m *MockDaveConsensus) GetApplicationContract( @@ -687,7 +672,7 @@ func (m *MockAdapterFactory) SetupDefaultBehavior( mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[1].IApplicationAddress }), - ).Return(appContract2, nil, nil, nil) + ).Return(appContract2, inputBox1, nil, nil) return m } diff --git a/internal/evmreader/observation_shutdown_test.go b/internal/evmreader/observation_shutdown_test.go new file mode 100644 index 000000000..d19262ca2 --- /dev/null +++ b/internal/evmreader/observation_shutdown_test.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestObservationDatabaseErrorReporting(t *testing.T) { + const ( + applicationList = "application list" + pendingOutputs = "pending outputs" + ) + dbErr := errors.New("database unavailable") + for _, operation := range []string{applicationList, pendingOutputs} { + t.Run(operation, func(t *testing.T) { + for _, test := range []struct { + name string + err error + cancel bool + quiet bool + }{ + {name: "shutdown", err: fmt.Errorf("query: %w", context.Canceled), cancel: true, quiet: true}, + {name: "joined shutdown", err: errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), + cancel: true, quiet: true}, + {name: "active context cancellation", err: context.Canceled}, + {name: "database failure", err: dbErr}, + {name: "database failure during shutdown", err: dbErr, cancel: true}, + {name: "timeout", err: context.DeadlineExceeded}, + {name: "timeout during shutdown", err: context.DeadlineExceeded, cancel: true}, + {name: "mixed failure", err: errors.Join(context.Canceled, dbErr), cancel: true}, + {name: "mixed timeout", err: errors.Join(context.Canceled, context.DeadlineExceeded), cancel: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var logs bytes.Buffer + repo := newMockRepository() + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + fail := func(mock.Arguments) { + if test.cancel { + cancel() + } + } + switch operation { + case applicationList: + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Run(fail).Return([]*model.Application(nil), uint64(0), test.err).Once() + require.False(t, reader.processBlockHead(ctx, 110, nil)) + case pendingOutputs: + app := appContracts{application: &model.Application{ + ID: 1, Name: "output-scan-app", IApplicationAddress: app1Addr, LastOutputCheckBlock: 100, + }} + repo.Unset("GetNumberOfPendingExecutableOutputs") + repo.On("GetNumberOfPendingExecutableOutputs", mock.Anything, app1Addr.Hex()). + Run(fail).Return(uint64(0), test.err).Once() + require.False(t, reader.checkForOutputExecution(ctx, []appContracts{app}, 110)) + require.Contains(t, logs.String(), `"application":"output-scan-app"`) + } + require.Equal(t, 1, strings.Count(logs.String(), `"error":`), logs.String()) + if test.quiet { + require.NotContains(t, logs.String(), `"level":"ERROR"`) + require.Contains(t, logs.String(), `"level":"DEBUG"`) + } else { + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`), logs.String()) + } + if errors.Is(test.err, dbErr) { + require.Contains(t, logs.String(), dbErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs.String(), context.DeadlineExceeded.Error()) + } + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + repo.AssertNumberOfCalls(t, "UpdateOutputsExecution", 0) + repo.AssertExpectations(t) + }) + } + }) + } +} + +func TestOutputScanStopsAfterShutdown(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := newMockRepository() + repo.Unset("GetNumberOfPendingExecutableOutputs") + repo.On("GetNumberOfPendingExecutableOutputs", mock.Anything, app1Addr.Hex()). + Run(func(mock.Arguments) { cancel() }).Return(uint64(0), context.Canceled).Once() + reader := &Service{repository: repo} + reader.Logger = testLogger(t) + apps := []appContracts{ + {application: &model.Application{ID: 1, IApplicationAddress: app1Addr, LastOutputCheckBlock: 100}}, + {application: &model.Application{ID: 2, IApplicationAddress: app2Addr, LastOutputCheckBlock: 100}}, + } + require.False(t, reader.checkForOutputExecution(ctx, apps, 110)) + repo.AssertExpectations(t) +} diff --git a/internal/evmreader/output.go b/internal/evmreader/output.go index ef10604aa..04e9b2c0e 100644 --- a/internal/evmreader/output.go +++ b/internal/evmreader/output.go @@ -9,8 +9,10 @@ import ( "encoding/hex" "errors" "fmt" + "log/slog" "math/big" + "github.com/cartesi/rollups-node/internal/errutil" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -95,6 +97,9 @@ func (r *Service) checkForOutputExecution( r.Logger.Debug("Checking for new Output Executed Events", "apps", appAddresses) for _, app := range apps { + if ctx.Err() != nil { + return false + } lastOutputCheck := app.application.LastOutputCheckBlock if lastOutputCheck == 0 { // New application. Find a safe start block to scan for outputs var err error @@ -114,12 +119,21 @@ func (r *Service) checkForOutputExecution( } if mostRecentBlockNumber > lastOutputCheck { - pending, err := r.hasPendingExecutableOutputs(ctx, app) + pending, err := r.repository.GetNumberOfPendingExecutableOutputs(ctx, app.application.IApplicationAddress.Hex()) if err != nil { + level := slog.LevelError + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Error counting pending executable outputs", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err, + ) success = false continue } - if !pending { + if pending == 0 { r.Logger.Debug("Not reading output execution: no pending executable outputs", "application", app.application.Name, "address", app.application.IApplicationAddress, "last_output_check_block", lastOutputCheck, @@ -154,19 +168,6 @@ func (r *Service) checkForOutputExecution( return success } -func (r *Service) hasPendingExecutableOutputs(ctx context.Context, app appContracts) (bool, error) { - pending, err := r.repository.GetNumberOfPendingExecutableOutputs(ctx, app.application.IApplicationAddress.String()) - if err != nil { - r.Logger.Error("Error counting pending executable outputs", - "application", app.application.Name, - "address", app.application.IApplicationAddress, - "error", err, - ) - return false, err - } - return pending > 0, nil -} - func (r *Service) readAndUpdateOutputs( ctx context.Context, app appContracts, lastOutputCheck, mostRecentBlockNumber uint64) bool { diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go index 502f5cde1..b200c6159 100644 --- a/internal/evmreader/output_test.go +++ b/internal/evmreader/output_test.go @@ -66,7 +66,7 @@ func (s *EvmReaderSuite) setupOutputExecution() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -550,6 +550,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 apps[0].LastOutputCheckBlock = 0x0F apps[1].LastOutputCheckBlock = 0x11 s.repository.On("ListApplications", @@ -561,6 +562,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 apps[0].LastOutputCheckBlock = 0x0F apps[1].LastOutputCheckBlock = 0x12 s.repository.On("ListApplications", @@ -588,7 +590,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -654,6 +656,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 apps[0].LastOutputCheckBlock = 0x0F apps[1].LastOutputCheckBlock = 0x11 s.repository.On("ListApplications", @@ -665,6 +668,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 apps[0].LastOutputCheckBlock = 0x0F apps[1].LastOutputCheckBlock = 0x12 s.repository.On("ListApplications", @@ -691,7 +695,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -729,12 +733,11 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { s.contractFactory = newMockAdapterFactory() s.evmReader = &Service{ - client: s.client, - repository: s.repository, - defaultBlock: DefaultBlock_Latest, - adapterFactory: s.contractFactory, - hasEnabledApps: true, - inputReaderEnabled: true, + client: s.client, + repository: s.repository, + defaultBlock: DefaultBlock_Latest, + adapterFactory: s.contractFactory, + hasEnabledApps: true, } logLevel, err := config.GetLogLevel() @@ -764,6 +767,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { ).Return(apps, uint64(2), nil).Once() apps = copyApplications(applications[1:2]) + apps[0].LastInputCheckBlock = 0x11 apps[0].LastOutputCheckBlock = 0x11 apps[0].LastForecloseCheckBlock = 0x100 s.repository.On("ListApplications", @@ -774,6 +778,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { ).Return(apps, uint64(1), nil).Once() apps = copyApplications(applications[1:2]) + apps[0].LastInputCheckBlock = 0x12 apps[0].LastOutputCheckBlock = 0x12 apps[0].LastForecloseCheckBlock = 0x100 s.repository.On("ListApplications", @@ -791,7 +796,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -804,10 +809,8 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, ).Return(nil).Maybe() - s.repository.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(0), nil) + s.repository.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(0), nil) + s.repository.On("GetNumberOfInputs", mock.Anything, app2Addr.String()).Return(uint64(0), nil).Times(3) s.repository.On("GetNumberOfExecutedOutputs", mock.Anything, @@ -820,10 +823,9 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, mock.Anything).Return(nil).Once() - s.repository.On("GetEpoch", - mock.Anything, - mock.Anything, - uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(1)).Return(nil, nil).Twice() output := &Output{ Index: 1, @@ -864,15 +866,17 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { s.inputBox.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), - mock.Anything, + []common.Address{app1Addr}, mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) // On-chain: 0 inputs before block 0x11, 1 from block 0x11 - s.inputBox.On("GetNumberOfInputs", blockRange(0, 0x11), mock.Anything). + s.inputBox.On("GetNumberOfInputs", blockRange(0, 0x11), app1Addr). Return(new(big.Int).SetUint64(0), nil) - s.inputBox.On("GetNumberOfInputs", blockFrom(0x11), mock.Anything). + s.inputBox.On("GetNumberOfInputs", blockFrom(0x11), app1Addr). Return(new(big.Int).SetUint64(1), nil) + s.inputBox.On("GetNumberOfInputs", mock.Anything, app2Addr). + Return(new(big.Int).SetUint64(0), nil) s.contractFactory.On("CreateAdapters", mock.MatchedBy(func(app *Application) bool { @@ -883,7 +887,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[1].IApplicationAddress }), - ).Return(s.applicationContract2, nil, nil, nil) + ).Return(s.applicationContract2, s.inputBox, nil, nil) } func (s *EvmReaderSuite) TestCheckOutputFailsWhenOutputMismatches() { diff --git a/internal/evmreader/readiness_test.go b/internal/evmreader/readiness_test.go index 2bff483ba..6854ba0a3 100644 --- a/internal/evmreader/readiness_test.go +++ b/internal/evmreader/readiness_test.go @@ -7,13 +7,13 @@ import ( "context" "errors" "fmt" - "github.com/cartesi/rollups-node/internal/repository" "log/slog" "math/big" "testing" "time" . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -21,12 +21,24 @@ import ( "github.com/stretchr/testify/require" ) +func TestDaveScanReportsMissingInputBox(t *testing.T) { + reader := &Service{} + reader.Logger = slog.Default() + apps := []appContracts{{application: &Application{ + ID: 1, Name: "missing-input-box", IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + ConsensusType: Consensus_PRT, + }}} + + require.False(t, reader.scanDaveConsensusEpochsAndInputs(t.Context(), apps, 110), + "a missing InputBox adapter must count as a failed observation cycle") +} + func TestReadinessTracksScanFailures(t *testing.T) { for _, failure := range []string{"application list", "input counter", "input logs", "input cursor", "input store", "output query", "foreclosure query", "drive cursor", "withdrawal store"} { t.Run(failure, func(t *testing.T) { app := &Application{ ID: 1, Name: "app", Enabled: true, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, + ConsensusType: Consensus_Authority, EpochLength: 10, Status: ApplicationStatus_OK, LastInputCheckBlock: 100, LastOutputCheckBlock: 110, LastForecloseCheckBlock: 110, } @@ -89,7 +101,7 @@ func TestReadinessTracksScanFailures(t *testing.T) { recovered = []any{false, nil} repo.On("UpdateApplicationLastForecloseCheckBlock", mock.Anything, mock.Anything, mock.Anything).Return(nil) } - r := &Service{client: client, repository: repo, inputReaderEnabled: true, + r := &Service{client: client, repository: repo, defaultBlock: DefaultBlock_Latest, readyMaxStaleness: time.Hour} r.Logger = slog.Default() r.resolver = newApplicationAdapterResolver(r.Logger, newMockAdapterFactory().SetupDefaultBehaviorSingleApp(contract, input)) @@ -119,16 +131,19 @@ func TestReadinessTracksScanFailures(t *testing.T) { } func TestReadinessSeparatesInputCorruptionFromSharedFailures(t *testing.T) { - for _, failure := range []string{"recorded conflict", "status write", "RPC", "store", "epoch query", "zero epoch", "zero epoch status write", "non-open epoch", "non-open epoch status write"} { + for _, failure := range []string{ + "recorded conflict", "status write", "RPC", "store", "epoch query", + "zero epoch", "zero epoch status write", "non-open epoch", "non-open epoch status write", + } { t.Run(failure, func(t *testing.T) { bad := &Application{ID: 1, Name: "degraded", Enabled: true, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, + ConsensusType: Consensus_Authority, EpochLength: 10, Status: ApplicationStatus_OK, LastInputCheckBlock: 100, LastOutputCheckBlock: 110, LastForecloseCheckBlock: 110} good := *bad good.ID, good.Name, good.IApplicationAddress = 2, "healthy", common.HexToAddress("0x2222") - // Terminal execution status must not prevent healthy observation. + // An integrity-terminal status must not prevent healthy observation. good.Status = ApplicationStatus_Corrupted repo := newMockRepository() list := repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). @@ -169,7 +184,7 @@ func TestReadinessSeparatesInputCorruptionFromSharedFailures(t *testing.T) { } client := newMockEthClient() client.On("HeaderByNumber", mock.Anything, mock.Anything).Return(&types.Header{Number: big.NewInt(110)}, nil) - r := &Service{client: client, repository: repo, inputReaderEnabled: true, + r := &Service{client: client, repository: repo, defaultBlock: DefaultBlock_Latest, readyMaxStaleness: time.Hour} r.Logger = slog.Default() r.resolver = newApplicationAdapterResolver(r.Logger, diff --git a/internal/evmreader/sealedepochs.go b/internal/evmreader/sealedepochs.go index 0948e0768..ca2650a48 100644 --- a/internal/evmreader/sealedepochs.go +++ b/internal/evmreader/sealedepochs.go @@ -88,24 +88,16 @@ func (r *Service) scanDaveConsensusEpochsAndInputs( mostRecentBlockNumber uint64, ) bool { success := true - if !r.inputReaderEnabled { - return success - } - r.Logger.Debug("Checking for new epochs and inputs", "apps", applications) // Process each application individually since each has its own DaveConsensus contract for _, app := range applications { if app.inputSource == nil { - // Alpha.6 permits data-availability encodings that this node does not - // support. A missing adapter is therefore a capability mismatch, not - // evidence that the application's persisted state is corrupted. success = false - r.Logger.Error("Cannot scan DaveConsensus epochs: configured input source is unsupported", + r.Logger.Error("Cannot scan DaveConsensus epochs: InputBox adapter is missing", "application", app.application.Name, "address", app.application.IApplicationAddress, "input_box", app.application.IInputBoxAddress, - "data_availability", app.application.DataAvailability, ) continue } diff --git a/internal/evmreader/sealedepochs_test.go b/internal/evmreader/sealedepochs_test.go index f2358aa3e..3e6d962b8 100644 --- a/internal/evmreader/sealedepochs_test.go +++ b/internal/evmreader/sealedepochs_test.go @@ -83,7 +83,6 @@ func (s *SealedEpochsSuite) TestProcessSealedEpochFindsInputAtOverlapBlock() { IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, IInputBoxBlock: 10, - DataAvailability: DataAvailability_InputBox[:], }, inputSource: s.inputBox, daveConsensus: s.dave, @@ -186,7 +185,6 @@ func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { lastEpochCheckBlock uint64 = 50 forecloseBlock uint64 = 70 ) - s.evmReader.inputReaderEnabled = true app := appContracts{ application: &Application{ @@ -199,7 +197,6 @@ func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { LastEpochCheckBlock: lastEpochCheckBlock, LastInputCheckBlock: forecloseBlock, LastOutputCheckBlock: lastEpochCheckBlock, - DataAvailability: DataAvailability_InputBox[:], }, daveConsensus: s.dave, inputSource: s.inputBox, @@ -211,12 +208,7 @@ func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { LastBlock: lastEpochCheckBlock, }, nil).Once() - currentSealedEpoch := struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address - }{ + currentSealedEpoch := DaveCurrentSealedEpoch{ EpochNumber: big.NewInt(2), InputIndexLowerBound: big.NewInt(0), InputIndexUpperBound: big.NewInt(0), @@ -240,7 +232,6 @@ func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { func (s *SealedEpochsSuite) TestTerminalDaveConsensusAppProcessesOpenEpochToForeclosure() { const forecloseBlock uint64 = 70 - s.evmReader.inputReaderEnabled = true app := appContracts{ application: &Application{ @@ -253,7 +244,6 @@ func (s *SealedEpochsSuite) TestTerminalDaveConsensusAppProcessesOpenEpochToFore ForecloseBlock: forecloseBlock, LastEpochCheckBlock: forecloseBlock, LastInputCheckBlock: forecloseBlock - 1, - DataAvailability: DataAvailability_InputBox[:], }, daveConsensus: s.dave, inputSource: s.inputBox, @@ -302,20 +292,17 @@ func (s *SealedEpochsSuite) TestTerminalDaveConsensusAppProcessesOpenEpochToFore s.dave.AssertNumberOfCalls(s.T(), "GetCurrentSealedEpoch", 0) } -func (s *SealedEpochsSuite) TestDaveConsensusWithUnsupportedInputSourceDoesNotPanic() { - s.evmReader.inputReaderEnabled = true +func (s *SealedEpochsSuite) TestDaveConsensusWithMissingInputBoxAdapterDoesNotPanic() { var logs bytes.Buffer s.evmReader.Logger = slog.New(slog.NewTextHandler(&logs, nil)) app := appContracts{ application: &Application{ ID: 1, - Name: "unsupported-input-source-prt-app", + Name: "missing-input-box-adapter-prt-app", IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, ConsensusType: Consensus_PRT, Status: ApplicationStatus_OK, - // Alpha.6's abandoned InputBoxAndEspresso experiment. The node - // intentionally supports only the official InputBox source. - DataAvailability: []byte{0x85, 0x79, 0xfd, 0x0c}, }, daveConsensus: s.dave, } @@ -324,7 +311,7 @@ func (s *SealedEpochsSuite) TestDaveConsensusWithUnsupportedInputSourceDoesNotPa s.evmReader.scanDaveConsensusEpochsAndInputs( s.ctx, []appContracts{app}, 100) }) - s.Contains(logs.String(), "configured input source is unsupported") + s.Contains(logs.String(), "InputBox adapter is missing") s.Equal(ApplicationStatus_OK, app.application.Status) s.dave.AssertNotCalled(s.T(), "GetCurrentSealedEpoch") s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) diff --git a/internal/evmreader/service.go b/internal/evmreader/service.go index 56d62edb6..287f85001 100644 --- a/internal/evmreader/service.go +++ b/internal/evmreader/service.go @@ -38,7 +38,6 @@ type Service struct { chainID uint64 defaultBlock DefaultBlock hasEnabledApps bool - inputReaderEnabled bool lastBlockNumber atomic.Uint64 lastSuccessfulPoll atomic.Pointer[time.Time] consecutiveScanFailures atomic.Uint32 @@ -47,11 +46,7 @@ type Service struct { const EvmReaderConfigKey = "evm-reader" -type PersistentConfig struct { - DefaultBlock DefaultBlock - InputReaderEnabled bool - ChainID uint64 -} +type PersistentConfig = config.PersistentChainConfig func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { err := ctx.Err() @@ -98,13 +93,12 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro } } - chainId, err := ethClient.ChainID(ctx) + chainID, err := ethClient.ChainID(ctx) if err != nil { return nil, err } - if chainId.Uint64() != c.Config.BlockchainId { - return nil, fmt.Errorf("EthClient chainId mismatch: network %d != provided %d", - chainId.Uint64(), c.Config.BlockchainId) + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err } s.repository = c.Repository @@ -116,15 +110,10 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro if err != nil { return nil, err } - if chainId.Uint64() != nodeConfig.ChainID { - return nil, fmt.Errorf("NodeConfig chainId mismatch: network %d != config %d", - chainId.Uint64(), nodeConfig.ChainID) - } s.client = ethClient s.chainID = nodeConfig.ChainID s.defaultBlock = nodeConfig.DefaultBlock - s.inputReaderEnabled = nodeConfig.InputReaderEnabled s.hasEnabledApps = true if c.AdapterFactory != nil { @@ -157,24 +146,24 @@ func (s *Service) setupPersistentConfig( ctx context.Context, c *config.EvmreaderConfig, ) (*PersistentConfig, error) { + requested := PersistentConfig{DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId} + if err := requested.Validate(); err != nil { + return nil, fmt.Errorf("invalid evm-reader config: %w", err) + } config, err := repository.LoadNodeConfig[PersistentConfig](ctx, s.repository, EvmReaderConfigKey) if config == nil && errors.Is(err, repository.ErrNotFound) { nc := NodeConfig[PersistentConfig]{ - Key: EvmReaderConfigKey, - Value: PersistentConfig{ - DefaultBlock: c.BlockchainDefaultBlock, - InputReaderEnabled: c.FeatureInputReaderEnabled, - ChainID: c.BlockchainId, - }, + Key: EvmReaderConfigKey, + Value: requested, } s.Logger.Info("Initializing evm-reader persistent config", "config", nc.Value) - err = repository.SaveNodeConfig(ctx, s.repository, &nc) - if err != nil { - return nil, err + config, err = repository.InitializeNodeConfig(ctx, s.repository, &nc) + } + if err == nil { + if err := config.Value.CheckRequested(requested); err != nil { + return nil, fmt.Errorf("evm-reader persistent config: %w", err) } - return &nc.Value, nil - } else if err == nil { - s.Logger.Info("Evm-reader was already configured. Using previous persistent config", "config", config.Value) + s.Logger.Info("Evm-reader persistent config matches requested config", "config", config.Value) return &config.Value, nil } diff --git a/internal/evmreader/service_config_test.go b/internal/evmreader/service_config_test.go index b5982c75b..89a9a811d 100644 --- a/internal/evmreader/service_config_test.go +++ b/internal/evmreader/service_config_test.go @@ -51,9 +51,8 @@ func TestCreateAcceptsRequestTimeoutBelowPollingInterval(t *testing.T) { defer client.Close() rawConfig, err := json.Marshal(PersistentConfig{ - DefaultBlock: DefaultBlock_Finalized, - InputReaderEnabled: true, - ChainID: chainID, + DefaultBlock: DefaultBlock_Finalized, + ChainID: chainID, }) require.NoError(t, err) @@ -68,7 +67,6 @@ func TestCreateAcceptsRequestTimeoutBelowPollingInterval(t *testing.T) { BlockchainHttpRequestTimeout: requestTimeout, BlockchainId: chainID, EvmReaderPollingInterval: pollInterval, - FeatureInputReaderEnabled: true, }, EthClient: client, Repository: repo, @@ -115,83 +113,83 @@ func (s *EvmReaderSuite) TestFetchMostRecentHeaderSuccess() { s.Require().Equal(expected.Number.Uint64(), header) } -// --- inputReaderEnabled feature flag tests --- - -func (s *EvmReaderSuite) TestInputReaderDisabledSkipsInputChecks() { - s.evmReader.inputReaderEnabled = false - - app := &Application{ - Name: "test-app", - IApplicationAddress: app1Addr, - IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], - EpochLength: 10, - LastInputCheckBlock: 100, - } - apps := []appContracts{{application: app}} - - repo := newMockRepository() - s.evmReader.repository = repo - - s.evmReader.scanIConsensusInputs(s.ctx, apps, 200) - - repo.AssertNumberOfCalls(s.T(), "GetNumberOfInputs", 0) - repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) - repo.AssertNumberOfCalls(s.T(), "GetEpoch", 0) -} - -func (s *EvmReaderSuite) TestInputReaderDisabledSkipsEpochChecks() { - s.evmReader.inputReaderEnabled = false - - apps := []appContracts{{ - application: &Application{ - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - }, - }} - - repo := newMockRepository() - s.evmReader.repository = repo - - s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, apps, 200) - - repo.AssertNumberOfCalls(s.T(), "GetLastNonOpenEpoch", 0) - repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) -} - // --- setupPersistentConfig tests --- func (s *EvmReaderSuite) TestSetupPersistentConfigFirstRun() { + initial := PersistentConfig{DefaultBlock: DefaultBlock_Finalized, ChainID: 42} + raw, err := json.Marshal(initial) + s.Require().NoError(err) repo := newMockRepository() repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). - Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound) - repo.On("SaveNodeConfigRaw", mock.Anything, EvmReaderConfigKey, mock.Anything). - Return(nil) + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound).Once() + repo.On("InitializeNodeConfigRaw", mock.Anything, EvmReaderConfigKey, raw).Return(nil).Once() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return(raw, time.Time{}, time.Time{}, nil).Once() s.evmReader.repository = repo cfg := &config.EvmreaderConfig{ - BlockchainDefaultBlock: DefaultBlock_Finalized, - FeatureInputReaderEnabled: true, - BlockchainId: 42, + BlockchainDefaultBlock: DefaultBlock_Finalized, + BlockchainId: 42, } result, err := s.evmReader.setupPersistentConfig(s.ctx, cfg) s.Require().NoError(err) s.Require().NotNil(result) s.Require().Equal(DefaultBlock_Finalized, result.DefaultBlock) - s.Require().True(result.InputReaderEnabled) s.Require().Equal(uint64(42), result.ChainID) - repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 1) + repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) + repo.AssertExpectations(s.T()) } -func (s *EvmReaderSuite) TestSetupPersistentConfigExistingConfigWins() { +func (s *EvmReaderSuite) TestSetupPersistentConfigRejectsConcurrentInitializer() { + repo := newMockRepository() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound).Once() + repo.On("InitializeNodeConfigRaw", mock.Anything, EvmReaderConfigKey, mock.Anything).Return(nil).Once() + // A different initializer wins after our first read. Never use our unsaved request. + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return([]byte(`{"DefaultBlock":"LATEST","ChainID":42}`), time.Time{}, time.Time{}, nil).Once() + s.evmReader.repository = repo + result, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) + s.Require().Nil(result) + s.Require().ErrorContains(err, "observation policy mismatch: database=LATEST, configured=FINALIZED") + repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) + repo.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigValidatesSavedFields() { + for _, test := range []struct{ raw, want string }{ + {`{}`, "non-null DefaultBlock and ChainID"}, + {`null`, "non-null DefaultBlock and ChainID"}, + {`{"DefaultBlock":"FINALIZED","ChainID":0}`, "ChainID must be greater than zero"}, + {`{"DefaultBlock":"INVALID","ChainID":42}`, "invalid DefaultBlock"}, + {`{"DefaultBlock":"LATEST","ChainID":42}`, "database=LATEST, configured=FINALIZED"}, + } { + s.Run(test.raw, func() { + repo := newMockRepository() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return([]byte(test.raw), time.Time{}, time.Time{}, nil).Once() + s.evmReader.repository = repo + result, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) + s.Require().Nil(result) + s.Require().ErrorContains(err, test.want) + repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) + repo.AssertNumberOfCalls(s.T(), "InitializeNodeConfigRaw", 0) + repo.AssertExpectations(s.T()) + }) + } +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigRejectsDifferentConfig() { existingJSON, err := json.Marshal(PersistentConfig{ - DefaultBlock: DefaultBlock_Safe, - InputReaderEnabled: false, - ChainID: 99, + DefaultBlock: DefaultBlock_Safe, + ChainID: 99, }) s.Require().NoError(err) @@ -201,20 +199,15 @@ func (s *EvmReaderSuite) TestSetupPersistentConfigExistingConfigWins() { s.evmReader.repository = repo - // Env config has DIFFERENT values — should be ignored + // A restart must not silently use values different from the requested ones. cfg := &config.EvmreaderConfig{ - BlockchainDefaultBlock: DefaultBlock_Latest, - FeatureInputReaderEnabled: true, - BlockchainId: 1, + BlockchainDefaultBlock: DefaultBlock_Latest, + BlockchainId: 1, } result, err := s.evmReader.setupPersistentConfig(s.ctx, cfg) - s.Require().NoError(err) - - // Existing config wins - s.Require().Equal(DefaultBlock_Safe, result.DefaultBlock) - s.Require().False(result.InputReaderEnabled) - s.Require().Equal(uint64(99), result.ChainID) + s.Require().ErrorContains(err, "chain ID mismatch: database=99, configured=1") + s.Require().Nil(result) // SaveNodeConfigRaw must NOT be called repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) @@ -227,7 +220,9 @@ func (s *EvmReaderSuite) TestSetupPersistentConfigDBError() { s.evmReader.repository = repo - _, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{}) + _, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) s.Require().Error(err) s.Require().ErrorContains(err, "database unreachable") } diff --git a/internal/evmreader/util.go b/internal/evmreader/util.go index f8be142a8..aafb6d5dd 100644 --- a/internal/evmreader/util.go +++ b/internal/evmreader/util.go @@ -26,14 +26,6 @@ func appsToAddresses(apps []appContracts) []common.Address { return addresses } -func mapAddressToApp(apps []appContracts) map[common.Address]appContracts { - result := make(map[common.Address]appContracts) - for _, app := range apps { - result[app.application.IApplicationAddress] = app - } - return result -} - // sortByInputIndex is a compare function that orders Inputs // by index field. It is intended to be used with `insertSorted`, see insertSorted() func sortByInputIndex(a, b *Input) int { diff --git a/pkg/ethutil/client.go b/pkg/ethutil/client.go index 62d45db5c..89bda63e9 100644 --- a/pkg/ethutil/client.go +++ b/pkg/ethutil/client.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" "github.com/hashicorp/go-retryablehttp" @@ -147,6 +148,18 @@ func newRedactedLogger(logger *slog.Logger, endpoint string) *redactedLeveledLog } func (l *redactedLeveledLogger) Error(msg string, keysAndValues ...any) { + // retryablehttp logs canceled requests before returning their error to the + // caller. Cancellation is not a transport failure. Keep the diagnostic at + // Debug; the caller still receives the error and applies its own policy. + // Classify before redaction, which can turn an error into a plain string. + for i := 0; i+1 < len(keysAndValues); i += 2 { + if keysAndValues[i] == "error" { + if err, ok := keysAndValues[i+1].(error); ok && errutil.IsOnlyCancellation(err) { + l.Debug(msg, keysAndValues...) + return + } + } + } l.logger.Error(l.redactString(msg), l.redactValues(keysAndValues)...) } diff --git a/pkg/ethutil/client_cancellation_test.go b/pkg/ethutil/client_cancellation_test.go new file mode 100644 index 000000000..7ff3195b9 --- /dev/null +++ b/pkg/ethutil/client_cancellation_test.go @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestHTTPFailureLogLevel(t *testing.T) { + transportErr := errors.New("connection reset") + for _, test := range []struct { + name string + err error + level slog.Level + }{ + {name: "cancellation", err: context.Canceled, level: slog.LevelDebug}, + {name: "wrapped cancellation", err: fmt.Errorf("request: %w", context.Canceled), level: slog.LevelDebug}, + {name: "joined cancellations", err: errors.Join(context.Canceled, context.Canceled), level: slog.LevelDebug}, + {name: "deadline", err: context.DeadlineExceeded, level: slog.LevelError}, + {name: "transport failure", err: transportErr, level: slog.LevelError}, + {name: "cancellation text is not a cause", err: errors.New("context canceled"), level: slog.LevelError}, + {name: "mixed failure", err: errors.Join(context.Canceled, transportErr), level: slog.LevelError}, + {name: "mixed deadline", err: errors.Join(context.Canceled, context.DeadlineExceeded), level: slog.LevelError}, + } { + t.Run(test.name, func(t *testing.T) { + const endpoint = "https://rpc.example.test/secret-key" + var logs bytes.Buffer + logger := newRedactedLogger(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})), endpoint) + logger.Error("request failed", "url", endpoint, "error", &url.Error{Op: "Post", URL: endpoint, Err: test.err}) + require.Contains(t, logs.String(), `"level":"`+test.level.String()+`"`) + require.Equal(t, 1, strings.Count(logs.String(), `"msg":"request failed"`)) + require.Contains(t, logs.String(), "https://rpc.example.test") + require.NotContains(t, logs.String(), "secret-key") + if errors.Is(test.err, transportErr) { + require.Contains(t, logs.String(), transportErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs.String(), context.DeadlineExceeded.Error()) + } + }) + } +} + +func TestNewEthClientCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + cancel() + <-r.Context().Done() + })) + defer server.Close() + + var logs bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + // The request context, not the context used to construct the client, is canceled. + client, err := NewEthClient(t.Context(), server.URL, logger, RetryConfig{RequestTimeout: time.Second}) + require.NoError(t, err) + defer client.Close() + _, err = client.ChainID(ctx) + require.ErrorIs(t, err, context.Canceled, "cancellation must still reach the caller") + require.NotContains(t, logs.String(), `"level":"ERROR"`) + require.Contains(t, logs.String(), `"msg":"request failed"`) + require.Contains(t, logs.String(), context.Canceled.Error()) +} diff --git a/pkg/service/supervisor.go b/pkg/service/supervisor.go index 221bf811e..bf5d5af1d 100644 --- a/pkg/service/supervisor.go +++ b/pkg/service/supervisor.go @@ -15,6 +15,7 @@ import ( "sync/atomic" "syscall" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/version" "golang.org/x/sync/errgroup" ) @@ -199,7 +200,7 @@ func (s *supervisorImpl) Serve() (err error) { if svcErr == nil { svcErr = ErrServiceStopped } - case svcErr == nil || isCancellationOnly(svcErr): + case svcErr == nil || errutil.IsOnlyCancellation(svcErr): s.logger.Info("Subservice stopped", "subservice", svc, ) @@ -228,29 +229,6 @@ func (s *supervisorImpl) Serve() (err error) { return errors.Join(errs...) } -// isCancellationOnly checks every leaf before suppressing a shutdown error. -// Matching the whole tree with errors.Is would hide failures joined with cancellation. -func isCancellationOnly(err error) bool { - switch wrapped := err.(type) { - case interface{ Unwrap() []error }: - children := wrapped.Unwrap() - if len(children) == 0 { - return errors.Is(err, context.Canceled) - } - for _, child := range children { - if !isCancellationOnly(child) { - return false - } - } - return true - case interface{ Unwrap() error }: - if child := wrapped.Unwrap(); child != nil { - return isCancellationOnly(child) - } - } - return errors.Is(err, context.Canceled) -} - // Fatal publishes the failure before cancellation so Serve observes it even if // all services return context.Canceled as a result of shutdown. func (s *supervisorImpl) Fatal(err error) { From 1f7b999f5c4bff50fa6d1fe97202d654e0880514 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:18 -0300 Subject: [PATCH 26/41] fix(evmreader): verify sealed epoch windows Wait until Dave deployment is visible at the configured observation block before scanning its sealed epochs and inputs. Do not persist a bootstrap cursor that claims an unscanned range is complete. Validate each sealed-epoch window against the on-chain count and epoch identity. Retry interrupted scans from the completed cursor, preserve already-computed epoch data, and checkpoint only complete input blocks. Report conflicting input identities instead of retrying the same failed insert forever. Include deployment, replay, checkpoint, identity, and logging regression tests with the final scanner implementation. --- internal/evmreader/dave_consensus_test.go | 146 ++++------- internal/evmreader/dave_readiness_test.go | 107 ++++++++ internal/evmreader/error_paths_test.go | 81 ++---- internal/evmreader/evmreader.go | 15 +- internal/evmreader/fixtures_test.go | 1 - internal/evmreader/input_deployment_test.go | 111 +++++++++ .../evmreader/openepoch_integrity_test.go | 123 +++++++++ internal/evmreader/sealedepochs.go | 235 ++++++++++-------- .../evmreader/sealedepochs_checkpoint_test.go | 168 +++++++++++++ .../evmreader/sealedepochs_cursor_test.go | 92 +++++++ .../evmreader/sealedepochs_deployment_test.go | 190 ++++++++++++++ .../evmreader/sealedepochs_identity_test.go | 209 ++++++++++++++++ .../evmreader/sealedepochs_logging_test.go | 104 ++++++++ .../evmreader/sealedepochs_replay_test.go | 223 +++++++++++++++++ internal/evmreader/sealedepochs_test.go | 93 ++++--- 15 files changed, 1584 insertions(+), 314 deletions(-) create mode 100644 internal/evmreader/dave_readiness_test.go create mode 100644 internal/evmreader/input_deployment_test.go create mode 100644 internal/evmreader/openepoch_integrity_test.go create mode 100644 internal/evmreader/sealedepochs_checkpoint_test.go create mode 100644 internal/evmreader/sealedepochs_cursor_test.go create mode 100644 internal/evmreader/sealedepochs_deployment_test.go create mode 100644 internal/evmreader/sealedepochs_identity_test.go create mode 100644 internal/evmreader/sealedepochs_logging_test.go create mode 100644 internal/evmreader/sealedepochs_replay_test.go diff --git a/internal/evmreader/dave_consensus_test.go b/internal/evmreader/dave_consensus_test.go index d1a2947bb..91b05b93d 100644 --- a/internal/evmreader/dave_consensus_test.go +++ b/internal/evmreader/dave_consensus_test.go @@ -15,6 +15,19 @@ import ( "github.com/stretchr/testify/mock" ) +const daveTestApplicationName = "test-app" + +// Keep scan boundaries, InputBox identity, and application status in each test. +func newDaveAppContracts(inputSource InputSourceAdapter, daveConsensus DaveConsensusAdapter) appContracts { + return appContracts{ + application: &Application{ + ID: 1, Name: daveTestApplicationName, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + }, + inputSource: inputSource, daveConsensus: daveConsensus, + } +} + // makeSealedEpochResult constructs the node-owned GetCurrentSealedEpoch result. func makeSealedEpochResult( epochNum int64, @@ -62,19 +75,10 @@ func (s *SealedEpochsSuite) TestSealedEpochsEndToEndTwoTransitions() { tournamentAddr0 := common.HexToAddress("0xA000") tournamentAddr1 := common.HexToAddress("0xA001") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: inputBoxBlock, - LastEpochCheckBlock: searchStart, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = inputBoxBlock + app.application.LastEpochCheckBlock = searchStart // Oracle: GetCurrentSealedEpoch returns epoch number based on block s.dave.On("GetCurrentSealedEpoch", @@ -109,10 +113,6 @@ func (s *SealedEpochsSuite) TestSealedEpochsEndToEndTwoTransitions() { makeSealedEpochEvent(1, 0, 0, sealBlock1, tournamentAddr1), }, nil) - // No previous sealed epochs in DB - s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). - Return(nil, nil) - // Epoch 0: first lookup → nil (new epoch) s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). Return(nil, nil).Once() @@ -180,18 +180,9 @@ func (s *SealedEpochsSuite) TestOpenEpochHappyPathCreatesNewEpoch() { mostRecentBlock uint64 = 200 ) - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 // Last sealed epoch s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). @@ -267,18 +258,9 @@ func (s *SealedEpochsSuite) TestOpenEpochHappyPathCreatesNewEpoch() { func (s *SealedEpochsSuite) TestOpenEpochExistingEpochAccumulatesInputs() { const mostRecentBlock uint64 = 300 - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 // Last sealed epoch s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). @@ -357,19 +339,10 @@ func (s *SealedEpochsSuite) TestOpenEpochScansBoundaryBlockAfterIntraTickCursorA mostRecentBlock uint64 = 200 ) - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - LastInputCheckBlock: previousInputCursor, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastInputCheckBlock = previousInputCursor s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). Return(&Epoch{ @@ -385,7 +358,7 @@ func (s *SealedEpochsSuite) TestOpenEpochScansBoundaryBlockAfterIntraTickCursorA s.repository.On("GetEventLastCheckBlock", mock.Anything, int64(1), MonitoredEvent_InputAdded, - ).Return(mostRecentBlock, nil) + ).Return(mostRecentBlock-1, nil) s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything). Return(uint64(4), nil) @@ -439,19 +412,10 @@ func (s *SealedEpochsSuite) TestMultipleSealedEpochsInOneBlock() { tournamentAddr0 := common.HexToAddress("0xA000") tournamentAddr1 := common.HexToAddress("0xA001") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - LastEpochCheckBlock: 50, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastEpochCheckBlock = 50 // Oracle: before sealBlock → -1, from sealBlock → 1 (both sealed at same block) s.dave.On("GetCurrentSealedEpoch", @@ -474,10 +438,6 @@ func (s *SealedEpochsSuite) TestMultipleSealedEpochsInOneBlock() { makeSealedEpochEvent(1, 0, 0, sealBlock, tournamentAddr1), }, nil) - // No previous sealed epochs - s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). - Return(nil, nil) - // Epoch 0: self-lookup → nil (new) s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). Return(nil, nil).Once() @@ -528,46 +488,23 @@ func (s *SealedEpochsSuite) TestMultipleSealedEpochsInOneBlock() { s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 2) } -// --- Test 5: initializeNewApplicationSealedEpochSync sets checkpoint --- -// On first run, the deployment block is fetched from DaveConsensus and the -// LastEpochCheckBlock is set to deploymentBlock - 1. -func (s *SealedEpochsSuite) TestInitializeSealedEpochSyncSetsCheckpoint() { - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - }, - daveConsensus: s.dave, - } +// The deployment floor does not certify a successful scan or change its cursor. +func (s *SealedEpochsSuite) TestInitialSealedEpochSearchBlockDoesNotSetCheckpoint() { + app := newDaveAppContracts(nil, s.dave) // DaveConsensus deployed at block 50 s.dave.On("GetDeploymentBlockNumber", mock.Anything). Return(big.NewInt(50), nil) - s.repository.On("UpdateEventLastCheckBlock", - mock.Anything, []int64{int64(1)}, MonitoredEvent_EpochSealed, uint64(49), - ).Return(nil) - - err := s.evmReader.initializeNewApplicationSealedEpochSync(s.ctx, &app, 200) + block, err := s.evmReader.initialSealedEpochSearchBlock(s.ctx, app, 200) s.Require().NoError(err) - - // LastEpochCheckBlock set to deploymentBlock - 1 - s.Require().Equal(uint64(49), app.application.LastEpochCheckBlock) - s.repository.AssertExpectations(s.T()) + s.Equal(uint64(50), block) + s.Zero(app.application.LastEpochCheckBlock) + s.Empty(s.repository.Calls) } func (s *SealedEpochsSuite) TestInitializeSealedEpochSyncSkipsBeforeConsensusDeployment() { - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - }, - daveConsensus: s.dave, - } + app := newDaveAppContracts(nil, s.dave) s.dave.On("GetDeploymentBlockNumber", mock.MatchedBy(func(opts *bind.CallOpts) bool { @@ -576,7 +513,8 @@ func (s *SealedEpochsSuite) TestInitializeSealedEpochSyncSkipsBeforeConsensusDep ).Return(new(big.Int), bind.ErrNoCode).Once() err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, 90) - s.Require().NoError(err) + s.Require().ErrorIs(err, errContractNotDeployedAtBlock) + s.Require().ErrorIs(err, bind.ErrNoCode) s.Require().Zero(app.application.LastEpochCheckBlock) s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) } diff --git a/internal/evmreader/dave_readiness_test.go b/internal/evmreader/dave_readiness_test.go new file mode 100644 index 000000000..08aae4a85 --- /dev/null +++ b/internal/evmreader/dave_readiness_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveReadinessDistinguishesDeploymentWaitFromScanFailure(t *testing.T) { + const head uint64 = 100 + for _, test := range []struct { + name string + deploymentWait bool + initialStatus model.ApplicationStatus + statusWriteErr error + }{ + {name: "deployment not yet visible", deploymentWait: true}, + {name: "missing sealed epoch"}, + {name: "existing integrity failure", initialStatus: model.ApplicationStatus_Diverged}, + {name: "corruption status write fails", statusWriteErr: errors.New("database unavailable")}, + {name: "execution terminal escalation fails", initialStatus: model.ApplicationStatus_MachineHalted, + statusWriteErr: errors.New("database unavailable")}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + client := newMockEthClient() + app := &model.Application{ + ID: 1, Name: "dave-readiness", Enabled: true, Status: model.ApplicationStatus_OK, + ConsensusType: model.Consensus_PRT, IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, + LastEpochCheckBlock: head, LastInputCheckBlock: head - 1, + LastOutputCheckBlock: head, LastForecloseCheckBlock: head, + } + if test.initialStatus != "" { + app.Status = test.initialStatus + } + client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil) + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + adapters := newMockAdapterFactory() + adapters.On("CreateAdapters", app). + Return(newMockApplicationContract(), newMockInputBox(), dave, nil).Once() + if test.deploymentWait { + app.LastEpochCheckBlock = 0 + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int), bind.ErrNoCode) + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil) + if app.Status != model.ApplicationStatus_Diverged { + statusWrite := repo.On("UpdateApplicationStatus", t.Context(), app.ID, + model.ApplicationStatus_Corrupted, mock.Anything).Return(test.statusWriteErr) + if test.statusWriteErr == nil { + statusWrite.Once() + } + } + } + reader := &Service{client: client, repository: repo, + defaultBlock: model.DefaultBlock_Finalized, readyMaxStaleness: time.Hour} + reader.Logger = testLogger(t) + reader.resolver = newApplicationAdapterResolver(reader.Logger, adapters) + + for cycle := 1; cycle <= maxConsecutiveScanFailures+1; cycle++ { + reschedule, err := reader.Tick(t.Context()) + require.NoError(t, err) + require.False(t, reschedule) + require.Equal(t, test.statusWriteErr == nil || cycle < maxConsecutiveScanFailures, reader.Ready()) + } + if test.deploymentWait { + require.Zero(t, reader.consecutiveScanFailures.Load()) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "GetLastNonOpenEpoch", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + if test.statusWriteErr == nil { + require.Zero(t, reader.consecutiveScanFailures.Load()) + if test.initialStatus == model.ApplicationStatus_Diverged { + require.Equal(t, test.initialStatus, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) + } + } else { + require.EqualValues(t, maxConsecutiveScanFailures, reader.consecutiveScanFailures.Load()) + if test.initialStatus != "" { + require.Equal(t, test.initialStatus, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + } + } + repo.AssertExpectations(t) + dave.AssertExpectations(t) + client.AssertExpectations(t) + adapters.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/error_paths_test.go b/internal/evmreader/error_paths_test.go index 18168e2cb..d2ffd85bf 100644 --- a/internal/evmreader/error_paths_test.go +++ b/internal/evmreader/error_paths_test.go @@ -29,7 +29,7 @@ func (s *EvmReaderSuite) TestCreateEpochsAndInputsErrorDoesNotAdvanceCheckpoint( app := &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, EpochLength: 10, @@ -78,18 +78,9 @@ func (s *SealedEpochsSuite) TestSealedEpochInputCountMismatchReturnsError() { const sealBlock uint64 = 200 tournamentAddr := common.HexToAddress("0xAAAA") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 // Sealed event expects 2 inputs (indices 3 to 5), but only 1 exists on-chain. event := &idaveconsensus.IDaveConsensusEpochSealed{ @@ -151,17 +142,8 @@ func (s *SealedEpochsSuite) TestSealedEpochInputCountMismatchReturnsError() { func (s *SealedEpochsSuite) TestSealedEpochFirstBlockMismatchReturnsError() { tournamentAddr := common.HexToAddress("0xBBBB") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr // Epoch 1 sealed. Previous epoch (0) has LastBlock=100 → firstBlock should be 100. event := &idaveconsensus.IDaveConsensusEpochSealed{ @@ -203,7 +185,7 @@ func (s *SealedEpochsSuite) TestOpenEpochWithNoNonOpenEpochSetsCorrupted() { app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, }, inputSource: s.inputBox, @@ -232,7 +214,7 @@ func (s *SealedEpochsSuite) TestSealedEpoch0WithNoInputBoxBlockReturnsError() { app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, IInputBoxBlock: 0, // misconfigured @@ -278,7 +260,7 @@ func (s *EvmReaderSuite) TestUpdateOutputsExecutionErrorDoesNotAdvanceCheckpoint app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, }, applicationContract: appContract, @@ -308,7 +290,7 @@ func (s *EvmReaderSuite) TestUpdateOutputsExecutionErrorDoesNotAdvanceCheckpoint // misconfiguration), scanIConsensusInputs must not write to the database. func (s *EvmReaderSuite) TestBlockRegressionDoesNotWriteToDb() { app := &Application{ - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, EpochLength: 10, @@ -336,16 +318,8 @@ func (s *EvmReaderSuite) TestBlockRegressionDoesNotWriteToDb() { // When mostRecentBlockNumber < LastEpochCheckBlock, processApplicationSealedEpochs // must skip processing and not write to the database. func (s *SealedEpochsSuite) TestSealedEpochBlockRegressionDoesNotWriteToDb() { - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - LastEpochCheckBlock: 200, - }, - daveConsensus: s.dave, - } + app := newDaveAppContracts(nil, s.dave) + app.application.LastEpochCheckBlock = 200 // mostRecentBlockNumber (150) < LastEpochCheckBlock (200) → regression err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, 150) @@ -364,7 +338,7 @@ func (s *EvmReaderSuite) TestOutputBlockRegressionDoesNotWriteToDb() { app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, LastOutputCheckBlock: 100, }, @@ -394,7 +368,7 @@ func (s *EvmReaderSuite) TestOutputExecutionSyncSkipsBeforeApplicationDeployment app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, }, applicationContract: appContract, @@ -420,7 +394,7 @@ func (s *EvmReaderSuite) TestIConsensusInputCountMismatchSkipsApp() { inputSrc := &MockInputBox{} app := &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, EpochLength: 10, @@ -469,7 +443,7 @@ func (s *EvmReaderSuite) TestIConsensusInputCountValidationUsesObservedEndCount( inputSrc := &MockInputBox{} app := &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, EpochLength: 10, @@ -536,7 +510,7 @@ func (s *EvmReaderSuite) TestEpochLengthZeroSetsAppCorrupted() { apps := []appContracts{{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, EpochLength: 0, // will trigger corrupted @@ -572,19 +546,10 @@ func (s *SealedEpochsSuite) TestSealedEpochDBFailurePreventsCheckpointAdvance() ) tournamentAddr := common.HexToAddress("0xEEEE") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - LastEpochCheckBlock: 50, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastEpochCheckBlock = 50 // Oracle: before sealBlock → -1, from sealBlock → 0 s.dave.On("GetCurrentSealedEpoch", @@ -605,10 +570,6 @@ func (s *SealedEpochsSuite) TestSealedEpochDBFailurePreventsCheckpointAdvance() makeSealedEpochEvent(0, 0, 0, sealBlock, tournamentAddr), }, nil) - // No previous sealed epochs - s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). - Return(nil, nil) - // Epoch 0 doesn't exist s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). Return(nil, nil) diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index bb1725416..b15888398 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -49,7 +49,9 @@ type EvmReaderRepository interface { blockNumber uint64, ) error GetNumberOfWithdrawals(ctx context.Context, appID int64) (uint64, error) - ListApplications(ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool) ([]*Application, uint64, error) + ListApplications( + ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool, + ) ([]*Application, uint64, error) UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error UpdateEventLastCheckBlock(ctx context.Context, appIDs []int64, event MonitoredEvent, blockNumber uint64) error GetEventLastCheckBlock(ctx context.Context, appID int64, event MonitoredEvent) (uint64, error) @@ -100,8 +102,13 @@ func (r *Service) setApplicationDiverged(ctx context.Context, app *Application, return appstatus.SetDivergedf(ctx, r.Logger, r.repository, app, reasonFmt, args...) } +// The status helper logs its reason and any failed status write. Outer scan +// loops can consume this marker without repeating those logs or continuing work. +var errApplicationStatusReported = errors.New("application status already reported") + func (r *Service) setApplicationCorrupted(ctx context.Context, app *Application, reasonFmt string, args ...any) error { - return appstatus.SetCorruptedf(ctx, r.Logger, r.repository, app, reasonFmt, args...) + err := appstatus.SetCorruptedf(ctx, r.Logger, r.repository, app, reasonFmt, args...) + return fmt.Errorf("%w: %w", errApplicationStatusReported, err) } func (r *Service) Tick(ctx context.Context) (bool, error) { @@ -270,7 +277,9 @@ type DefaultAdapterFactory struct { Filter ethutil.Filter } -func (f *DefaultAdapterFactory) CreateAdapters(app *Application) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { +func (f *DefaultAdapterFactory) CreateAdapters( + app *Application, +) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { if app == nil { return nil, nil, nil, fmt.Errorf("application reference is nil, should never happen") } diff --git a/internal/evmreader/fixtures_test.go b/internal/evmreader/fixtures_test.go index 74f633d7f..3f428542e 100644 --- a/internal/evmreader/fixtures_test.go +++ b/internal/evmreader/fixtures_test.go @@ -25,7 +25,6 @@ var ( header0 = makeHeader(0x11) header1 = makeHeader(0x12) header2 = makeHeader(0x13) - header3 = makeHeader(0x33) ) // Test input events — all target app1. diff --git a/internal/evmreader/input_deployment_test.go b/internal/evmreader/input_deployment_test.go new file mode 100644 index 000000000..3afbe0bf4 --- /dev/null +++ b/internal/evmreader/input_deployment_test.go @@ -0,0 +1,111 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestInputScanLowerHeadLogsOnlyDeploymentWaitAtDebug(t *testing.T) { + const head uint64 = 90 + for _, test := range []struct { + name string + inputBoxBlock uint64 + cursor uint64 + wantLevel slog.Level + }{ + {name: "new deployment", inputBoxBlock: 100, wantLevel: slog.LevelDebug}, + {name: "deployment floor on later tick", inputBoxBlock: 100, cursor: 99, wantLevel: slog.LevelDebug}, + {name: "previous scan above deployment", inputBoxBlock: 100, cursor: 101, wantLevel: slog.LevelWarn}, + {name: "head regressed after deployment", inputBoxBlock: 10, cursor: 100, wantLevel: slog.LevelWarn}, + {name: "no deployment evidence", cursor: 100, wantLevel: slog.LevelWarn}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + app := inputUnitApp(1, inputBoxAddr, test.cursor) + app.application.IInputBoxBlock = test.inputBoxBlock + if test.cursor == 0 { + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, test.inputBoxBlock-1).Return(nil).Once() + } + + units, success := r.buildIConsensusInputScanUnits(t.Context(), []appContracts{app}, head) + require.True(t, success) + require.Empty(t, units) + require.Contains(t, output.String(), "level="+test.wantLevel.String()+` msg="Input search skipped: most recent block`) + if test.wantLevel == slog.LevelDebug { + require.NotContains(t, output.String(), "level=WARN") + } + repo.AssertExpectations(t) + }) + } +} + +func TestIConsensusPredeploymentObservationDoesNotCorrupt(t *testing.T) { + const ( + head uint64 = 90 + epochLength uint64 = 10 + ) + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + for _, test := range []struct { + name string + inputBoxBlock uint64 + }{ + {name: "InputBox not yet visible", inputBoxBlock: 100}, + {name: "existing InputBox with no inputs", inputBoxBlock: 10}, + } { + t.Run(consensus.String()+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + applicationContract := newMockApplicationContract() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "new-app", Enabled: true, Status: model.ApplicationStatus_OK, ConsensusType: consensus, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, IInputBoxBlock: test.inputBoxBlock, EpochLength: epochLength, + }, + applicationContract: applicationContract, inputSource: inputBox, + } + // Registration saw the application at latest, but neither foreclosure + // nor output observation can see its deployment at this configured head. + applicationContract.On("GetDeploymentBlockNumber", blockRange(head, head+1)). + Return(new(big.Int), bind.ErrNoCode).Twice() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, test.inputBoxBlock-1).Return(nil).Once() + if test.inputBoxBlock <= head { + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(test.inputBoxBlock, head+1), app1Addr). + Return(new(big.Int), nil).Twice() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), (test.inputBoxBlock-1)/epochLength).Return(nil, nil).Once() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, head).Return(nil).Once() + } + + require.True(t, r.runBlockScanners(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Zero(t, app.application.LastOutputCheckBlock) + require.Zero(t, app.application.LastForecloseCheckBlock) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + inputBox.AssertNotCalled(t, "RetrieveInputs", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + applicationContract.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/openepoch_integrity_test.go b/internal/evmreader/openepoch_integrity_test.go new file mode 100644 index 000000000..d2fdf6120 --- /dev/null +++ b/internal/evmreader/openepoch_integrity_test.go @@ -0,0 +1,123 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveOpenEpochChecksInputCountBeforePublication(t *testing.T) { + const head uint64 = 100 + for _, foreclosed := range []bool{false, true} { + name := "active" + if foreclosed { + name = "foreclosed" + } + for _, test := range []struct { + name string + storedCount uint64 + lowerBound uint64 + endCount uint64 + logIndices []uint64 + wantIndices []uint64 + mismatch bool + }{ + {name: "missing first log", endCount: 2, logIndices: []uint64{1}, mismatch: true}, + {name: "missing all logs", endCount: 1, mismatch: true}, + {name: "existing open epoch misses a log", storedCount: 4, lowerBound: 2, endCount: 6, + logIndices: []uint64{5}, mismatch: true}, + {name: "overlap block includes sealed inputs", storedCount: 2, lowerBound: 2, endCount: 3, + logIndices: []uint64{0, 1, 2}, wantIndices: []uint64{2}}, + {name: "no new inputs", storedCount: 2, lowerBound: 2, endCount: 2}, + {name: "existing open inputs", storedCount: 4, lowerBound: 2, endCount: 5, + logIndices: []uint64{4}, wantIndices: []uint64{4}}, + } { + t.Run(name+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(inputBox, nil) + app.application.Status = model.ApplicationStatus_OK + app.application.LastEpochCheckBlock = head + app.application.LastInputCheckBlock = head - 1 + observedHead := head + if foreclosed { + app.application.ForecloseBlock = head + observedHead += 10 + } + sealed := &model.Epoch{Index: 0, LastBlock: head, + InputIndexUpperBound: test.lowerBound, Status: model.EpochStatus_Closed} + var open *model.Epoch + if test.storedCount > test.lowerBound { + sealed.LastBlock = head - 2 + open = &model.Epoch{Index: 1, FirstBlock: sealed.LastBlock, LastBlock: head - 1, + InputIndexLowerBound: test.lowerBound, InputIndexUpperBound: test.storedCount, + Status: model.EpochStatus_Open} + } + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(sealed, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(open, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(test.storedCount, nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), app1Addr). + Return(new(big.Int).SetUint64(test.endCount), nil).Once() + if test.endCount > test.storedCount { + events := make([]iinputbox.IInputBoxInputAdded, 0, len(test.logIndices)) + for _, index := range test.logIndices { + events = append(events, makeInputEvent(app1Addr, index, head)) + } + inputBox.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + }), []common.Address{app1Addr}, mock.Anything).Return(events, nil).Once() + } + if !test.mismatch { + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, head). + Run(func(args mock.Arguments) { + epochs := args.Get(2).(map[*model.Epoch][]*model.Input) + require.Len(t, epochs, 1) + for epoch, inputs := range epochs { + require.Equal(t, uint64(1), epoch.Index) + require.Equal(t, sealed.LastBlock, epoch.FirstBlock) + require.Equal(t, head, epoch.LastBlock) + require.Equal(t, test.lowerBound, epoch.InputIndexLowerBound) + require.Equal(t, test.endCount, epoch.InputIndexUpperBound) + require.Equal(t, model.EpochStatus_Open, epoch.Status) + require.Len(t, inputs, len(test.wantIndices)) + for i, index := range test.wantIndices { + require.Equal(t, index, inputs[i].Index) + } + } + app.application.LastInputCheckBlock = head + }).Return(nil).Once() + } + + require.Equal(t, !test.mismatch, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, observedHead)) + if test.mismatch { + require.Equal(t, head-1, app.application.LastInputCheckBlock) + if open != nil { + require.Equal(t, test.storedCount, open.InputIndexUpperBound) + require.Equal(t, head-1, open.LastBlock) + } + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + require.Equal(t, head, app.application.LastInputCheckBlock) + } + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/sealedepochs.go b/internal/evmreader/sealedepochs.go index ca2650a48..3a411f26d 100644 --- a/internal/evmreader/sealedepochs.go +++ b/internal/evmreader/sealedepochs.go @@ -11,17 +11,18 @@ import ( "math/big" . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) -func (r *Service) initializeNewApplicationSealedEpochSync( +func (r *Service) initialSealedEpochSearchBlock( ctx context.Context, - app *appContracts, + app appContracts, mostRecentBlockNumber uint64, -) error { - r.Logger.Info("Initializing application sealed epoch sync", +) (uint64, error) { + r.Logger.Debug("Initializing application sealed epoch sync", "application", app.application.Name, "current_block", mostRecentBlockNumber, ) @@ -38,48 +39,22 @@ func (r *Service) initializeNewApplicationSealedEpochSync( "consensus_address", app.application.IConsensusAddress, "block", mostRecentBlockNumber, ) - return fmt.Errorf("%w: consensus %s at block %d: %w", + return 0, fmt.Errorf("%w: consensus %s at block %d: %w", errContractNotDeployedAtBlock, app.application.IConsensusAddress, mostRecentBlockNumber, err) } - r.Logger.Error("Error retrieving dave consensus deployment block number", - "application", app.application.Name, - "address", app.application.IApplicationAddress, - "consensus_address", app.application.IConsensusAddress, - "error", err, - ) - return fmt.Errorf("failed to retrieve DaveConsensus deployment block: %w", err) + return 0, fmt.Errorf("failed to retrieve DaveConsensus deployment block: %w", err) } - if deploymentBlock.Sign() <= 0 { - r.Logger.Error("Invalid dave consensus deployment block number retrieved", - "application", app.application.Name, - "address", app.application.IApplicationAddress, - "consensus_address", app.application.IConsensusAddress, - "block_number", deploymentBlock.Uint64(), - ) - return errors.New("invalid dave consensus deployment block number retrieved") + if deploymentBlock == nil || !deploymentBlock.IsUint64() || deploymentBlock.Sign() == 0 || + deploymentBlock.Uint64() > mostRecentBlockNumber { + return 0, fmt.Errorf("invalid DaveConsensus deployment block %v at head %d", deploymentBlock, mostRecentBlockNumber) } - lastEpochCheckBlock := deploymentBlock.Uint64() - 1 - err = r.repository.UpdateEventLastCheckBlock(ctx, []int64{app.application.ID}, MonitoredEvent_EpochSealed, lastEpochCheckBlock) - if err != nil { - r.Logger.Error("Failed to update application LastEpochCheckBlock", - "application", app.application.Name, - "last_epoch_check_block", lastEpochCheckBlock, - "error", err, - ) - return err - } - r.Logger.Debug("Application sealed epoch sync initialized", - "application", app.application.Name, - "deployment_block", deploymentBlock.Uint64(), - "next_search_block", lastEpochCheckBlock+1, - "current_block", mostRecentBlockNumber, - ) - app.application.LastEpochCheckBlock = lastEpochCheckBlock - return nil + // This is a local search floor, not evidence of a completed scan. Persisting + // it before the constructor seal is stored could leave a cursor with no epoch. + return deploymentBlock.Uint64(), nil } func (r *Service) scanDaveConsensusEpochsAndInputs( @@ -111,6 +86,19 @@ func (r *Service) scanDaveConsensusEpochsAndInputs( if errors.Is(err, context.Canceled) { return false // shutting down } + if errors.Is(err, errContractNotDeployedAtBlock) { + // Registration can precede deployment visibility at the configured + // block. Neither sealed nor open epochs can be observed yet. + continue + } + if errors.Is(err, errApplicationStatusReported) { + // Only a recorded integrity status makes this fault app-local. + // A failed escalation from an execution terminal still fails the scan. + if app.application.Status != ApplicationStatus_Corrupted && app.application.Status != ApplicationStatus_Diverged { + success = false + } + continue + } success = false r.Logger.Error("Error processing application sealed epochs", "application", app.application.Name, @@ -127,6 +115,12 @@ func (r *Service) scanDaveConsensusEpochsAndInputs( if errors.Is(err, context.Canceled) { return false // shutting down } + if errors.Is(err, errApplicationStatusReported) { + if app.application.Status != ApplicationStatus_Corrupted && app.application.Status != ApplicationStatus_Diverged { + success = false + } + continue + } success = false r.Logger.Error("Error processing application open epoch", "application", app.application.Name, @@ -143,23 +137,23 @@ func (r *Service) processApplicationSealedEpochs( app appContracts, mostRecentBlockNumber uint64, ) error { - // Find the starting block for epoch search - if app.application.LastEpochCheckBlock == 0 { - err := r.initializeNewApplicationSealedEpochSync(ctx, &app, mostRecentBlockNumber) + lastEpochCheckBlock := app.application.LastEpochCheckBlock + initializing := lastEpochCheckBlock == 0 + if initializing { + deploymentBlock, err := r.initialSealedEpochSearchBlock(ctx, app, mostRecentBlockNumber) if err != nil { - if errors.Is(err, errContractNotDeployedAtBlock) { - return nil - } - r.Logger.Error("Failed to initialize application sealed epoch sync", - "application", app.application.Name, - "most_recent_block", mostRecentBlockNumber, - "error", err, - ) return fmt.Errorf("failed to determine start block for epoch search: %w", err) } + lastEpochCheckBlock = deploymentBlock - 1 } - if mostRecentBlockNumber < app.application.LastEpochCheckBlock { + if mostRecentBlockNumber < lastEpochCheckBlock { + if app.application.ForecloseBlock != 0 && app.application.ForecloseBlock == mostRecentBlockNumber { + r.Logger.Debug("Sealed epoch scan already covers foreclosure block", + "application", app.application.Name, "foreclose_block", mostRecentBlockNumber, + "last_epoch_check_block", lastEpochCheckBlock) + return nil // The independent input cursor can still require a drain. + } r.Logger.Warn( "Not reading sealed epochs: most recent block is lower than the last processed one", "application", app.application.Name, "address", app.application.IApplicationAddress, @@ -167,7 +161,7 @@ func (r *Service) processApplicationSealedEpochs( "most_recent_block", mostRecentBlockNumber, ) return nil - } else if mostRecentBlockNumber == app.application.LastEpochCheckBlock { + } else if mostRecentBlockNumber == lastEpochCheckBlock { r.Logger.Debug("Not reading sealed epochs: already checked the most recent blocks", "application", app.application.Name, "address", app.application.IApplicationAddress, "last_epoch_check_block", app.application.LastEpochCheckBlock, @@ -176,10 +170,10 @@ func (r *Service) processApplicationSealedEpochs( return nil } - nextSearchBlock := app.application.LastEpochCheckBlock + 1 + nextSearchBlock := lastEpochCheckBlock + 1 r.Logger.Debug("Checking sealed epochs for application", "application", app.application.Name, - "last_epoch_check_block", app.application.LastEpochCheckBlock, + "last_epoch_check_block", lastEpochCheckBlock, "next_search_block", nextSearchBlock, "most_recent_block", mostRecentBlockNumber, ) @@ -200,31 +194,27 @@ func (r *Service) processApplicationSealedEpochs( return sealedEpoch.EpochNumber, nil } - // Create onHit function that processes epoch transitions - onHit := func(block uint64) error { - r.Logger.Debug("Epoch transition found", "application", app.application.Name, "block", block) - return r.processEpochTransition(ctx, app, block) - } - + // A failed window may already have stored a prefix of its epochs. Seed from + // the completed scan boundary, not the newest row, so that prefix is replayed. prevValue := big.NewInt(-1) - lastEpoch, err := r.repository.GetLastNonOpenEpoch(ctx, app.application.IApplicationAddress.String()) - if err != nil { - return fmt.Errorf("failed to get last non open epoch: %w", err) + if !initializing { + var err error + prevValue, err = oracle(ctx, lastEpochCheckBlock) + if err != nil { + return err + } } - if lastEpoch != nil { - prevValue = new(big.Int).SetUint64(lastEpoch.Index) - // assert that the last epoch's last block is less than the next search block - if lastEpoch.LastBlock > nextSearchBlock { - return r.setApplicationCorrupted(ctx, app.application, - "application last non open epoch last block %d is greater than next search block %d", - lastEpoch.LastBlock, - nextSearchBlock, - ) + previousTransitionValue := prevValue + onHit := func(block uint64) error { + value, err := r.processEpochTransition(ctx, app, block, previousTransitionValue) + if err == nil { + previousTransitionValue = value } + return err } // Use FindTransitions to find epoch transitions - _, err = ethutil.FindTransitions(ctx, nextSearchBlock, mostRecentBlockNumber, prevValue, oracle, onHit) + _, err := ethutil.FindTransitions(ctx, nextSearchBlock, mostRecentBlockNumber, prevValue, oracle, onHit) if err != nil { return fmt.Errorf("failed to walk epoch transitions: %w", err) } @@ -234,6 +224,12 @@ func (r *Service) processApplicationSealedEpochs( if err != nil { return fmt.Errorf("failed to update last epoch check block: %w", err) } + if initializing { + r.Logger.Info("Application sealed epoch sync initialized", + "application", app.application.Name, "consensus_address", app.application.IConsensusAddress, + "deployment_block", nextSearchBlock, + "last_epoch_check_block", mostRecentBlockNumber) + } r.Logger.Debug("Sealed epoch search completed", "application", app.application.Name, "most_recent_block", mostRecentBlockNumber) @@ -244,7 +240,8 @@ func (r *Service) processEpochTransition( ctx context.Context, app appContracts, transitionBlock uint64, -) error { + previousEpochNumber *big.Int, +) (*big.Int, error) { r.Logger.Debug("Processing epoch transition", "application", app.application.Name, "block", transitionBlock) // Get the sealed epoch information at this block @@ -255,17 +252,9 @@ func (r *Service) processEpochTransition( sealedEpoch, err := app.daveConsensus.GetCurrentSealedEpoch(callOpts) if err != nil { - return fmt.Errorf("failed to get sealed epoch at transition block %d: %w", transitionBlock, err) + return nil, fmt.Errorf("failed to get sealed epoch at transition block %d: %w", transitionBlock, err) } - r.Logger.Info("Found sealed epoch event", - "application", app.application.Name, - "block", transitionBlock, - "epoch_number", sealedEpoch.EpochNumber, - "input_lower_bound", sealedEpoch.InputIndexLowerBound, - "input_upper_bound", sealedEpoch.InputIndexUpperBound, - "tournament", sealedEpoch.Tournament) - // Retrieve the actual EpochSealed events for this transition filterOpts := &bind.FilterOpts{ Context: ctx, @@ -275,22 +264,45 @@ func (r *Service) processEpochTransition( sealedEvents, err := app.daveConsensus.RetrieveSealedEpochs(filterOpts) if err != nil { - return fmt.Errorf("failed to retrieve sealed epoch events at block %d: %w", transitionBlock, err) + return nil, fmt.Errorf("failed to retrieve sealed epoch events at block %d: %w", transitionBlock, err) } + // Validate the whole block before storing any event. Several epochs can seal + // in one block, so a nonempty page can still omit part of the transition. + if sealedEpoch.EpochNumber == nil || !sealedEpoch.EpochNumber.IsUint64() { + return nil, fmt.Errorf("invalid sealed epoch number at block %d: %v", transitionBlock, sealedEpoch.EpochNumber) + } + expectedCount := new(big.Int).Sub(sealedEpoch.EpochNumber, previousEpochNumber) + if expectedCount.Sign() <= 0 || !expectedCount.IsUint64() || expectedCount.Uint64() != uint64(len(sealedEvents)) { + return nil, fmt.Errorf("sealed epoch event count mismatch at block %d: expected %s, got %d", + transitionBlock, expectedCount, len(sealedEvents)) + } + expectedEpoch := new(big.Int).Set(previousEpochNumber) + for _, event := range sealedEvents { + expectedEpoch.Add(expectedEpoch, big.NewInt(1)) + if event == nil || event.EpochNumber == nil || event.EpochNumber.Cmp(expectedEpoch) != 0 || + event.Raw.BlockNumber != transitionBlock { + return nil, fmt.Errorf("invalid sealed epoch event sequence at block %d: expected epoch %s", transitionBlock, expectedEpoch) + } + } + lastEvent := sealedEvents[len(sealedEvents)-1] + if lastEvent.InputIndexLowerBound.Cmp(sealedEpoch.InputIndexLowerBound) != 0 || + lastEvent.InputIndexUpperBound.Cmp(sealedEpoch.InputIndexUpperBound) != 0 || lastEvent.Tournament != sealedEpoch.Tournament { + return nil, fmt.Errorf("last sealed epoch event does not match current sealed epoch at block %d", transitionBlock) + } + r.Logger.Info("Found sealed epoch events", + "application", app.application.Name, "block", transitionBlock, + "epoch_number", sealedEpoch.EpochNumber, "count", len(sealedEvents)) + // Process each sealed epoch event for _, event := range sealedEvents { err := r.processSealedEpochEvent(ctx, app, event) if err != nil { - r.Logger.Error("Error processing sealed epoch event", - "epoch_number", event.EpochNumber, - "block", transitionBlock, - "error", err) - return fmt.Errorf("failed to process sealed epoch event at block %d: %w", transitionBlock, err) + return nil, fmt.Errorf("failed to process sealed epoch event at block %d: %w", transitionBlock, err) } } - return nil + return sealedEpoch.EpochNumber, nil } func (r *Service) processSealedEpochEvent( @@ -304,6 +316,11 @@ func (r *Service) processSealedEpochEvent( "input_upper_bound", event.InputIndexUpperBound, "tournament", event.Tournament) + // A seal is emitted by a transaction, so it cannot occur at genesis. + if event.Raw.BlockNumber == 0 { + return errors.New("sealed epoch event has block number zero") + } + firstBlock := uint64(0) epochNumber := event.EpochNumber.Uint64() if epochNumber == 0 { @@ -354,6 +371,15 @@ func (r *Service) processSealedEpochEvent( if epoch.FirstBlock != firstBlock || epoch.InputIndexLowerBound != event.InputIndexLowerBound.Uint64() { return fmt.Errorf("epoch %d data mismatch with sealed event", epoch.Index) } + if epoch.Status != EpochStatus_Open { + if epoch.LastBlock != event.Raw.BlockNumber || epoch.InputIndexUpperBound != event.InputIndexUpperBound.Uint64() || + epoch.TournamentAddress == nil || *epoch.TournamentAddress != event.Tournament { + return fmt.Errorf("sealed epoch %d data mismatch with replayed event", epoch.Index) + } + // The epoch and its inputs were stored atomically. Preserve any later + // claim/proof progress when replaying a partially completed scan. + return nil + } epoch.LastBlock = event.Raw.BlockNumber epoch.InputIndexUpperBound = event.InputIndexUpperBound.Uint64() epoch.TournamentAddress = &event.Tournament @@ -388,7 +414,9 @@ func (r *Service) processSealedEpochEvent( len(inputs)) } } - // Store epoch and inputs + // The seal covers every input before its transaction, but later inputs in + // the same block belong to the next epoch. Publish only complete blocks; + // the open-epoch scan must finish this boundary block before drain is safe. epochInputMap := map[*Epoch][]*Input{epoch: inputs} r.Logger.Debug("Storing sealed epoch", "application", app.application.Name, "epoch_number", epoch.Index) @@ -397,9 +425,13 @@ func (r *Service) processSealedEpochEvent( ctx, app.application.IApplicationAddress.String(), epochInputMap, - event.Raw.BlockNumber, + event.Raw.BlockNumber-1, ) if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.setApplicationCorrupted(ctx, app.application, + "sealed epoch %d input L1 log identity conflicts with stored data; operator reset required: %v", epoch.Index, err) + } return fmt.Errorf("failed to store epoch and inputs: %w", err) } @@ -417,10 +449,8 @@ func (r *Service) processApplicationOpenEpoch( app appContracts, mostRecentBlockNumber uint64, ) error { - // This guard uses the tick-start application snapshot. Sealed-epoch - // processing earlier in this same tick can advance the DB input cursor to - // mostRecentBlockNumber, but the open epoch may still need to scan that - // boundary block. The fresh DB cursor is read below and scanned inclusively. + // The input cursor covers complete blocks. A sealed batch in this tick + // can advance it only to the block before its seal. if mostRecentBlockNumber < app.application.LastInputCheckBlock { r.Logger.Warn( "Not checking for inputs on current open epoch: most recent block is lower than the last processed one", @@ -473,6 +503,9 @@ func (r *Service) processApplicationOpenEpoch( if err != nil { return fmt.Errorf("failed to get last input check block: %w", err) } + if lastInputCheckBlock >= mostRecentBlockNumber { + return nil + } // Fetch inputs for this epoch from the InputBox inputCount, err := r.repository.GetNumberOfInputs( @@ -482,13 +515,17 @@ func (r *Service) processApplicationOpenEpoch( "failed to get number of inputs from repository: %w", err) } prevValue := new(big.Int).SetUint64(inputCount) - inputs, _, err := r.fetchInputs(ctx, app, - lastInputCheckBlock, mostRecentBlockNumber, + inputs, endCount, err := r.fetchInputs(ctx, app, + lastInputCheckBlock+1, mostRecentBlockNumber, prevValue, openEpoch.InputIndexLowerBound, math.MaxUint64) if err != nil { return fmt.Errorf("failed to fetch inputs for epoch %d: %w", openEpoch.Index, err) } + if endCount < inputCount || endCount-inputCount != uint64(len(inputs)) { + return fmt.Errorf("open epoch %d input count mismatch: stored %d, on-chain %d, got %d", + openEpoch.Index, inputCount, endCount, len(inputs)) + } // increase the upper bound according to the number of fetched inputs openEpoch.InputIndexUpperBound += uint64(len(inputs)) @@ -510,6 +547,10 @@ func (r *Service) processApplicationOpenEpoch( ) if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.setApplicationCorrupted(ctx, app.application, + "open epoch %d input L1 log identity conflicts with stored data; operator reset required: %v", openEpoch.Index, err) + } return fmt.Errorf("failed to store epoch and inputs: %w", err) } diff --git a/internal/evmreader/sealedepochs_checkpoint_test.go b/internal/evmreader/sealedepochs_checkpoint_test.go new file mode 100644 index 000000000..38a4472df --- /dev/null +++ b/internal/evmreader/sealedepochs_checkpoint_test.go @@ -0,0 +1,168 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "io" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// A sealed batch contains only inputs before the seal transaction, not the +// later inputs in that block. A failed open scan must not publish the block as +// complete, even if the sealed scan has finished and the reader restarts. +func TestDaveInputCheckpointAfterFailedOpenScan(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + ctx := context.Background() + release, err := db.LockTestPostgres(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(release) + + for _, test := range []struct { + name string + sealedSizes []uint64 + cancelWrite bool + }{ + {name: "empty seal then RPC failure", sealedSizes: []uint64{0}}, + {name: "input seal input then RPC failure", sealedSizes: []uint64{1}}, + {name: "two seals then cancelled write", sealedSizes: []uint64{1, 1}, cancelWrite: true}, + } { + t.Run(test.name, func(t *testing.T) { + const ( + previousSealBlock uint64 = 50 + forecloseBlock uint64 = 100 + ) + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { repo.Close() }) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(ctx, t, repo) + address := app.IApplicationAddress.Hex() + previousEpoch := &Epoch{Index: 0, FirstBlock: 10, LastBlock: previousSealBlock, Status: EpochStatus_Closed} + require.NoError(t, repo.CreateEpochsAndInputs(ctx, address, + map[*Epoch][]*Input{previousEpoch: nil}, forecloseBlock-2)) + require.NoError(t, repo.UpdateEventLastCheckBlock(ctx, []int64{app.ID}, + MonitoredEvent_EpochSealed, forecloseBlock-2)) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + + var sealedInputs uint64 + var events []*idaveconsensus.IDaveConsensusEpochSealed + for i, size := range test.sealedSizes { + events = append(events, makeSealedEpochEvent(int64(i+1), sealedInputs, sealedInputs+size, + forecloseBlock, repotest.UniqueAddress())) + sealedInputs += size + } + lastEvent := events[len(events)-1] + dave := newMockDaveConsensus() + dave.On("GetCurrentSealedEpoch", blockRange(0, forecloseBlock)). + Return(makeSealedEpochResult(0, 0, 0, common.Address{}), nil) + dave.On("GetCurrentSealedEpoch", blockFrom(forecloseBlock)).Return(makeSealedEpochResult( + lastEvent.EpochNumber.Int64(), lastEvent.InputIndexLowerBound.Uint64(), sealedInputs, lastEvent.Tournament), nil) + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == forecloseBlock && opts.End != nil && *opts.End == forecloseBlock + })).Return(events, nil).Once() + + inputBox := newMockInputBox() + inputBox.On("GetNumberOfInputs", blockRange(0, forecloseBlock), app.IApplicationAddress). + Return(big.NewInt(0), nil).Maybe() + inputBox.On("GetNumberOfInputs", blockFrom(forecloseBlock), app.IApplicationAddress). + Return(new(big.Int).SetUint64(sealedInputs+1), nil) + inputEvents := make([]iinputbox.IInputBoxInputAdded, 0, sealedInputs+1) + for index := uint64(0); index <= sealedInputs; index++ { + inputEvents = append(inputEvents, makeInputEvent(app.IApplicationAddress, index, forecloseBlock)) + } + inputBlock := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == forecloseBlock && opts.End != nil && *opts.End == forecloseBlock + }) + inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything). + Return(inputEvents, nil) + contracts := appContracts{application: app, inputSource: inputBox, daveConsensus: dave} + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + require.NoError(t, reader.processApplicationSealedEpochs(ctx, contracts, forecloseBlock)) + + // Interrupt the open scan either at its RPC read or before the input + // transaction starts. No open epoch or final input may be published. + inputBox.Unset("RetrieveInputs") + openCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + failedRead := inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything).Once() + if test.cancelWrite { + failedRead.Run(func(mock.Arguments) { cancel() }).Return(inputEvents, nil) + } else { + failedRead.Return([]iinputbox.IInputBoxInputAdded{}, context.Canceled) + } + require.ErrorIs(t, reader.processApplicationOpenEpoch(openCtx, contracts, forecloseBlock), context.Canceled) + count, err := repo.GetNumberOfInputs(ctx, address) + require.NoError(t, err) + require.Equal(t, sealedInputs, count) + openEpochIndex := lastEvent.EpochNumber.Uint64() + 1 + openEpoch, err := repo.GetEpoch(ctx, address, openEpochIndex) + require.NoError(t, err) + require.Nil(t, openEpoch) + + // Recreate both the repository connection and reader, then discover + // foreclosure in the same block. No tick-start snapshot survives. + repo.Close() + repo, err = factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + require.NoError(t, repo.UpdateApplicationForeclosure(ctx, app.ID, forecloseBlock, + repotest.UniqueHash(), forecloseBlock)) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + assert.Equal(t, forecloseBlock, app.LastEpochCheckBlock) + assert.Equal(t, forecloseBlock-1, app.LastInputCheckBlock, "the seal does not cover the whole block") + assert.False(t, app.ForeclosureScanCaughtUp(), "drain must wait for the missing open input") + + inputBox.Unset("RetrieveInputs") + inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything). + Return(inputEvents, nil).Once() + contracts.application = app + reader = &Service{repository: repo} + reader.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + reader.scanDaveConsensusEpochsAndInputs(ctx, []appContracts{contracts}, forecloseBlock+10) + + input, err := repo.GetInput(ctx, address, sealedInputs) + require.NoError(t, err) + require.NotNil(t, input, "retry must fetch the input after the last seal") + assert.Equal(t, openEpochIndex, input.EpochIndex) + assert.Equal(t, forecloseBlock, input.BlockNumber) + openEpoch, err = repo.GetEpoch(ctx, address, openEpochIndex) + require.NoError(t, err) + require.NotNil(t, openEpoch) + assert.Equal(t, forecloseBlock, openEpoch.FirstBlock) + assert.Equal(t, forecloseBlock, openEpoch.LastBlock) + assert.Equal(t, sealedInputs, openEpoch.InputIndexLowerBound) + assert.Equal(t, sealedInputs+1, openEpoch.InputIndexUpperBound) + assert.Equal(t, EpochStatus_Open, openEpoch.Status) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + assert.Equal(t, forecloseBlock, app.LastInputCheckBlock) + assert.True(t, app.ForeclosureScanCaughtUp()) + undrained, err := repo.HasUndrainedEpochsBeforeBlock(ctx, app.ID, forecloseBlock) + require.NoError(t, err) + assert.True(t, undrained, "the stored input must remain visible to the machine drain") + dave.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_cursor_test.go b/internal/evmreader/sealedepochs_cursor_test.go new file mode 100644 index 000000000..70f11773e --- /dev/null +++ b/internal/evmreader/sealedepochs_cursor_test.go @@ -0,0 +1,92 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestOpenEpochSkipsAlreadyScannedFreshCursor(t *testing.T) { + const head uint64 = 200 + for _, test := range []struct { + name string + cursor uint64 + }{ + {name: "equal to head", cursor: head}, + {name: "above head", cursor: head + 1}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, IApplicationAddress: app1Addr, LastInputCheckBlock: head - 1, + }, + inputSource: inputBox, + } + sealed := &model.Epoch{Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed} + open := &model.Epoch{Index: 1, FirstBlock: sealed.LastBlock, LastBlock: test.cursor, Status: model.EpochStatus_Open} + before := *open + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(sealed, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), open.Index).Return(open, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(test.cursor, nil).Once() + + require.NoError(t, r.processApplicationOpenEpoch(t.Context(), app, head)) + require.Equal(t, before, *open, "the stored epoch must not be rewritten to an older head") + require.Empty(t, inputBox.Calls) + repo.AssertNotCalled(t, "GetNumberOfInputs", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func TestForeclosureInputDrainRunsBelowSealedCursor(t *testing.T) { + const ( + sealedCursor uint64 = 200 + foreclosure uint64 = 180 + inputCursor uint64 = 150 + ) + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(inputBox, dave) + app.application.Status = model.ApplicationStatus_MachineHalted + app.application.ForecloseBlock = foreclosure + app.application.LastEpochCheckBlock = sealedCursor + app.application.LastInputCheckBlock = inputCursor + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: 100, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(inputCursor, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(inputCursor+1, foreclosure+1), app1Addr). + Return(new(big.Int), nil).Twice() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch, inputs := range epochs { + return len(epochs) == 1 && epoch.LastBlock == foreclosure && epoch.Status == model.EpochStatus_Open && len(inputs) == 0 + } + return false + }), foreclosure).Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, sealedCursor+10)) + require.Equal(t, model.ApplicationStatus_MachineHalted, app.application.Status) + require.Empty(t, dave.Calls, "the sealed cursor already covers foreclosure") + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) +} diff --git a/internal/evmreader/sealedepochs_deployment_test.go b/internal/evmreader/sealedepochs_deployment_test.go new file mode 100644 index 000000000..0af52939f --- /dev/null +++ b/internal/evmreader/sealedepochs_deployment_test.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func (s *SealedEpochsSuite) TestDaveScanWaitsForDeploymentAndResumes() { + const ( + beforeDeployment uint64 = 90 + deploymentBlock uint64 = 100 + ) + var output bytes.Buffer + s.evmReader.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "new-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, IInputBoxBlock: 10, + }, + inputSource: s.inputBox, daveConsensus: s.dave, + } + s.dave.On("GetDeploymentBlockNumber", blockRange(beforeDeployment, beforeDeployment+1)). + Return(new(big.Int), bind.ErrNoCode).Once() + + // The absent contract must not prevent a later application from being scanned. + otherInputBox := newMockInputBox() + other := appContracts{ + application: &model.Application{ + ID: 2, Name: "existing-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app2Addr, LastEpochCheckBlock: beforeDeployment, + LastInputCheckBlock: beforeDeployment - 1, + }, + inputSource: otherInputBox, + } + s.repository.On("GetLastNonOpenEpoch", s.ctx, app2Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: beforeDeployment - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + s.repository.On("GetEpoch", s.ctx, app2Addr.Hex(), uint64(1)).Return(nil, nil).Once() + s.repository.On("GetEventLastCheckBlock", s.ctx, other.application.ID, model.MonitoredEvent_InputAdded). + Return(beforeDeployment-1, nil).Once() + s.repository.On("GetNumberOfInputs", s.ctx, app2Addr.Hex()).Return(uint64(0), nil).Once() + otherInputBox.On("GetNumberOfInputs", blockRange(beforeDeployment, beforeDeployment+1), app2Addr). + Return(new(big.Int), nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app2Addr.Hex(), mock.Anything, beforeDeployment). + Return(nil).Once() + + s.True(s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, []appContracts{app, other}, beforeDeployment)) + s.Equal(model.ApplicationStatus_OK, app.application.Status) + s.Zero(app.application.LastEpochCheckBlock) + s.Zero(app.application.LastInputCheckBlock) + s.repository.AssertNotCalled(s.T(), "GetLastNonOpenEpoch", s.ctx, app1Addr.Hex()) + s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + s.repository.AssertCalled(s.T(), "CreateEpochsAndInputs", s.ctx, app2Addr.Hex(), mock.Anything, beforeDeployment) + s.Empty(s.inputBox.Calls) + s.NotContains(output.String(), "level=ERROR") + s.repository.AssertExpectations(s.T()) + s.dave.AssertExpectations(s.T()) + otherInputBox.AssertExpectations(s.T()) + + // The configured head now includes deployment. The same registered app must + // ingest the constructor's sealed epoch and then create its open epoch. + tournament := common.HexToAddress("0x1234") + s.dave.On("GetDeploymentBlockNumber", blockRange(deploymentBlock, deploymentBlock+1)). + Return(new(big.Int).SetUint64(deploymentBlock), nil).Once() + s.repository.On("UpdateEventLastCheckBlock", s.ctx, []int64{app.application.ID}, + model.MonitoredEvent_EpochSealed, deploymentBlock).Return(nil).Once() + sealed := &model.Epoch{ + Index: 0, FirstBlock: app.application.IInputBoxBlock, LastBlock: deploymentBlock, + Status: model.EpochStatus_Closed, TournamentAddress: &tournament, + } + s.repository.On("GetLastNonOpenEpoch", s.ctx, app1Addr.Hex()).Return(sealed, nil).Once() + s.repository.On("GetEpoch", s.ctx, app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", s.ctx, app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + s.dave.On("GetCurrentSealedEpoch", blockRange(deploymentBlock, deploymentBlock+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Twice() + s.dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == deploymentBlock && opts.End != nil && *opts.End == deploymentBlock + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, deploymentBlock, tournament), + }, nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch := range epochs { + return len(epochs) == 1 && epoch.Index == 0 && epoch.Status == model.EpochStatus_Closed + } + return false + }), deploymentBlock-1).Return(nil).Once() + s.repository.On("GetEventLastCheckBlock", s.ctx, app.application.ID, model.MonitoredEvent_InputAdded). + Return(deploymentBlock-1, nil).Once() + s.repository.On("GetNumberOfInputs", s.ctx, app1Addr.Hex()).Return(uint64(0), nil).Once() + s.inputBox.On("GetNumberOfInputs", blockRange(deploymentBlock, deploymentBlock+1), app1Addr). + Return(new(big.Int), nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch := range epochs { + return len(epochs) == 1 && epoch.Index == 1 && epoch.Status == model.EpochStatus_Open + } + return false + }), deploymentBlock).Return(nil).Once() + + s.True(s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, []appContracts{app}, deploymentBlock)) + s.Equal(model.ApplicationStatus_OK, app.application.Status) + s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) + s.repository.AssertExpectations(s.T()) + s.dave.AssertExpectations(s.T()) + s.inputBox.AssertExpectations(s.T()) +} + +func TestFailedSealedInitializationWaitsForDeploymentAgain(t *testing.T) { + const deploymentBlock uint64 = 100 + for _, regressedHead := range []uint64{80, deploymentBlock - 1} { + t.Run(new(big.Int).SetUint64(regressedHead).String(), func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + inputBox := newMockInputBox() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(inputBox, dave) + app.application.IInputBoxBlock = 10 + app.application.Status = model.ApplicationStatus_OK + tournament := common.HexToAddress("0x1234") + dave.On("GetDeploymentBlockNumber", blockRange(deploymentBlock, deploymentBlock+1)). + Return(new(big.Int).SetUint64(deploymentBlock), nil).Twice() + dave.On("GetCurrentSealedEpoch", blockRange(deploymentBlock, deploymentBlock+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil) + filter := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == deploymentBlock && opts.End != nil && *opts.End == deploymentBlock + }) + dave.On("RetrieveSealedEpochs", filter). + Return([]*idaveconsensus.IDaveConsensusEpochSealed{}, errors.New("log retrieval interrupted")).Once() + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, deploymentBlock)) + require.Empty(t, repo.Calls, "a failed first scan must not persist the deployment floor") + require.Zero(t, app.application.LastEpochCheckBlock) + require.NotContains(t, output.String(), "sync initialized") + require.Contains(t, output.String(), "log retrieval interrupted") + output.Reset() + + dave.On("GetDeploymentBlockNumber", blockRange(regressedHead, regressedHead+1)). + Return(new(big.Int), bind.ErrNoCode).Once() + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, regressedHead)) + require.Empty(t, repo.Calls, "the open scan must not run before deployment is visible again") + require.Zero(t, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Empty(t, output.String(), "ordinary deployment lag needs no Info or Error log") + + dave.On("RetrieveSealedEpochs", filter).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, deploymentBlock, tournament), + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, deploymentBlock-1).Return(nil).Once() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_EpochSealed, deploymentBlock).Return(nil).Once() + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, FirstBlock: 10, LastBlock: deploymentBlock, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(deploymentBlock-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(deploymentBlock, deploymentBlock+1), app1Addr). + Return(new(big.Int), nil).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, deploymentBlock).Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, deploymentBlock)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Contains(t, output.String(), "sync initialized") + require.NotContains(t, output.String(), "level=ERROR") + repo.AssertExpectations(t) + dave.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_identity_test.go b/internal/evmreader/sealedepochs_identity_test.go new file mode 100644 index 000000000..b2239fcad --- /dev/null +++ b/internal/evmreader/sealedepochs_identity_test.go @@ -0,0 +1,209 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "fmt" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + daveOpenScan = "open" + daveSealedScan = "sealed" +) + +func TestDaveInputStoreErrorClassification(t *testing.T) { + const head uint64 = 100 + for _, sealed := range []bool{true, false} { + path := daveOpenScan + if sealed { + path = daveSealedScan + } + for _, test := range []struct { + name string + cause error + corrupt bool + }{ + {name: "identity conflict", cause: repository.ErrInputLogIdentityConflict, corrupt: true}, + {name: "retryable database error", cause: errors.New("database unavailable")}, + } { + t.Run(path+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "input-store-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxBlock: head, LastEpochCheckBlock: head - 1, LastInputCheckBlock: head - 1, + }, + inputSource: inputBox, daveConsensus: dave, + } + checkpoint := head + if sealed { + checkpoint-- + tournament := common.HexToAddress("0x1234") + app.application.LastEpochCheckBlock = 0 + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int).SetUint64(head), nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + dave.On("GetCurrentSealedEpoch", blockRange(head, head+1)). + Return(makeSealedEpochResult(0, 0, 1, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 1, head, tournament), + }, nil).Once() + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + } + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), app1Addr).Return(big.NewInt(1), nil).Once() + inputBox.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + }), []common.Address{app1Addr}, mock.Anything). + Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 0, head)}, nil).Once() + storeErr := fmt.Errorf("insert inputs: %w", test.cause) + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, checkpoint). + Return(storeErr).Once() + if test.corrupt { + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, + model.ApplicationStatus_Corrupted, mock.Anything).Return(nil).Once() + } + + var err error + if sealed { + err = r.processApplicationSealedEpochs(t.Context(), app, head) + } else { + err = r.processApplicationOpenEpoch(t.Context(), app, head) + } + require.ErrorContains(t, err, storeErr.Error()) + if test.corrupt { + require.Equal(t, model.ApplicationStatus_Corrupted, app.application.Status) + require.ErrorContains(t, err, path+" epoch") + require.ErrorContains(t, err, "operator reset required") + } else { + require.ErrorIs(t, err, test.cause) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + if sealed { + require.Zero(t, app.application.LastEpochCheckBlock) + } else { + require.Equal(t, head-1, app.application.LastEpochCheckBlock) + } + require.Equal(t, head-1, app.application.LastInputCheckBlock) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } + } +} + +func TestDaveRecordedIntegrityFaultKeepsOtherApplicationsScanning(t *testing.T) { + const head uint64 = 100 + for _, path := range []string{daveSealedScan, daveOpenScan} { + for _, test := range []struct { + name string + status model.ApplicationStatus + statusWriteErr error + }{ + {name: "new fault", status: model.ApplicationStatus_OK}, + {name: "already corrupted", status: model.ApplicationStatus_Corrupted}, + {name: "already diverged", status: model.ApplicationStatus_Diverged}, + {name: "escalate halted machine", status: model.ApplicationStatus_MachineHalted}, + {name: "failed integrity write", status: model.ApplicationStatus_OK, statusWriteErr: errors.New("write unavailable")}, + {name: "failed terminal escalation", status: model.ApplicationStatus_MachineHalted, + statusWriteErr: errors.New("write unavailable")}, + } { + t.Run(path+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + bad := newDaveAppContracts(inputBox, dave) + bad.application.Status = test.status + bad.application.IInputBoxBlock = head + bad.application.LastEpochCheckBlock = head + bad.application.LastInputCheckBlock = head - 1 + good := newDaveAppContracts(inputBox, nil) + good.application.ID = 2 + good.application.IApplicationAddress = app2Addr + good.application.Status = model.ApplicationStatus_OK + good.application.LastEpochCheckBlock = head + good.application.LastInputCheckBlock = head - 1 + + checkpoint := head + if path == daveSealedScan { + checkpoint-- + bad.application.LastEpochCheckBlock = 0 + tournament := common.HexToAddress("0x1234") + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int).SetUint64(head), nil).Once() + dave.On("GetCurrentSealedEpoch", blockRange(head, head+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", mock.Anything).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, head, tournament), + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + } + for _, app := range []appContracts{bad, good} { + if app.application.ID == bad.application.ID && path == daveSealedScan { + continue + } + address := app.application.IApplicationAddress + repo.On("GetLastNonOpenEpoch", t.Context(), address.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), address.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), address.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), address).Return(new(big.Int), nil).Once() + } + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, checkpoint). + Return(repository.ErrInputLogIdentityConflict).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app2Addr.Hex(), mock.Anything, head).Return(nil).Once() + integrityRecorded := test.status == model.ApplicationStatus_Corrupted || test.status == model.ApplicationStatus_Diverged + if !integrityRecorded { + repo.On("UpdateApplicationStatus", t.Context(), bad.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(test.statusWriteErr).Once() + } + + require.Equal(t, test.statusWriteErr == nil, + r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{bad, good}, head)) + if !integrityRecorded && test.statusWriteErr == nil { + require.Equal(t, model.ApplicationStatus_Corrupted, bad.application.Status) + } else { + require.Equal(t, test.status, bad.application.Status) + } + require.Equal(t, model.ApplicationStatus_OK, good.application.Status) + require.Equal(t, head-1, bad.application.LastInputCheckBlock) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/sealedepochs_logging_test.go b/internal/evmreader/sealedepochs_logging_test.go new file mode 100644 index 000000000..0f3e37cfd --- /dev/null +++ b/internal/evmreader/sealedepochs_logging_test.go @@ -0,0 +1,104 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveCorruptionReasonIsNotLoggedAgainOnEveryScan(t *testing.T) { + const head uint64 = 100 + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), nil) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_OK + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil).Twice() + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_Corrupted, app.application.Status) + require.Equal(t, 1, strings.Count(output.String(), "level=ERROR"), "the status helper logs the first corruption once") + require.Contains(t, output.String(), "no non open epochs found") + output.Reset() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.NotContains(t, output.String(), "level=ERROR", "the same terminal reason needs no repeated Error log") + repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestDaveStatusWriteFailureRemainsVisibleAndRetryable(t *testing.T) { + const head uint64 = 100 + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), nil) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_OK + writeErr := errors.New("status database unavailable") + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil).Twice() + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(writeErr).Twice() + + err := r.processApplicationOpenEpoch(t.Context(), app, head) + require.ErrorIs(t, err, writeErr, "the marker must preserve the actual database error") + require.ErrorIs(t, err, errApplicationStatusReported) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + output.Reset() + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Equal(t, 2, strings.Count(output.String(), "level=ERROR"), "retain the reason and the failed-write log") + require.Contains(t, output.String(), "failed to update application status") + require.Contains(t, output.String(), writeErr.Error()) + repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestDaveTerminalApplicationStillLogsObservationFailure(t *testing.T) { + const head uint64 = 100 + for _, sealed := range []bool{true, false} { + name := daveOpenScan + if sealed { + name = daveSealedScan + } + t.Run(name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), dave) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_Corrupted + readErr := errors.New("observation temporarily unavailable") + if sealed { + app.application.LastEpochCheckBlock-- + dave.On("GetCurrentSealedEpoch", blockRange(head-1, head)).Return(DaveCurrentSealedEpoch{}, readErr).Once() + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, readErr).Once() + } + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, 1, strings.Count(output.String(), "level=ERROR")) + require.Contains(t, output.String(), readErr.Error()) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_replay_test.go b/internal/evmreader/sealedepochs_replay_test.go new file mode 100644 index 000000000..f6efa977a --- /dev/null +++ b/internal/evmreader/sealedepochs_replay_test.go @@ -0,0 +1,223 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSealedEpochTransitionValidatesCompletePageBeforeWrites(t *testing.T) { + const block uint64 = 150 + tournament := common.HexToAddress("0x1234") + for _, test := range []struct { + name string + indices []int64 + change func(*idaveconsensus.IDaveConsensusEpochSealed) + }{ + {name: "empty"}, + {name: "missing first", indices: []int64{2, 3}}, + {name: "missing middle", indices: []int64{1, 3}}, + {name: "missing last", indices: []int64{1, 2}}, + {name: "duplicate", indices: []int64{1, 1, 3}}, + {name: "out of order", indices: []int64{2, 1, 3}}, + {name: "wrong block", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.Raw.BlockNumber++ + }}, + {name: "wrong lower bound", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.InputIndexLowerBound.SetUint64(1) + }}, + {name: "wrong upper bound", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.InputIndexUpperBound.SetUint64(1) + }}, + {name: "wrong tournament", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.Tournament = common.HexToAddress("0x5678") + }}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(nil, dave) + app.application.LastEpochCheckBlock = block - 1 + app.application.Status = model.ApplicationStatus_OK + events := make([]*idaveconsensus.IDaveConsensusEpochSealed, 0, len(test.indices)) + for _, index := range test.indices { + events = append(events, makeSealedEpochEvent(index, 0, 0, block, tournament)) + } + if test.change != nil { + test.change(events[len(events)-1]) + } + dave.On("GetCurrentSealedEpoch", blockRange(block-1, block)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Once() + dave.On("GetCurrentSealedEpoch", blockRange(block, block+1)). + Return(makeSealedEpochResult(3, 0, 0, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == block && opts.End != nil && *opts.End == block + })).Return(events, nil).Once() + + require.Error(t, r.processApplicationSealedEpochs(t.Context(), app, block)) + require.Empty(t, repo.Calls, "the entire page must be checked before any repository work") + require.Equal(t, block-1, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + dave.AssertExpectations(t) + }) + } +} + +// This fake makes completed per-epoch writes survive a failed scan. Reads return +// copies, as PostgreSQL does; the test does not rely on shared model pointers. +type sealedEpochReplayRepository struct { + *MockRepository + epochs map[uint64]*model.Epoch + stores []uint64 + failEpoch uint64 + storeErr error +} + +func (r *sealedEpochReplayRepository) GetEpoch(_ context.Context, _ string, index uint64) (*model.Epoch, error) { + if epoch := r.epochs[index]; epoch != nil { + copyEpoch := *epoch + return ©Epoch, nil + } + return nil, nil +} + +func (r *sealedEpochReplayRepository) CreateEpochsAndInputs( + _ context.Context, _ string, epochs map[*model.Epoch][]*model.Input, _ uint64, +) error { + for epoch := range epochs { + if epoch.Index == r.failEpoch && r.storeErr != nil { + return r.storeErr + } + copyEpoch := *epoch + r.epochs[epoch.Index] = ©Epoch + r.stores = append(r.stores, epoch.Index) + } + return nil +} + +func TestSealedEpochScanReplaysCommittedPrefix(t *testing.T) { + const ( + cursor uint64 = 100 + firstBlock uint64 = 150 + secondBlock uint64 = 175 + head uint64 = 200 + ) + tournament := common.HexToAddress("0x1234") + for _, sameBlockFailure := range []bool{true, false} { + name := "later transition page omitted" + if sameBlockFailure { + name = "second seal in same block fails to store" + } + t.Run(name, func(t *testing.T) { + repo := &sealedEpochReplayRepository{ + MockRepository: newMockRepository(), + epochs: map[uint64]*model.Epoch{0: { + Index: 0, FirstBlock: 10, LastBlock: cursor, Status: model.EpochStatus_Closed, TournamentAddress: &tournament, + }}, + } + if sameBlockFailure { + repo.failEpoch = 2 + repo.storeErr = errors.New("second seal write interrupted") + } + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(nil, dave) + app.application.LastEpochCheckBlock = cursor + app.application.IInputBoxBlock = 10 + app.application.Status = model.ApplicationStatus_OK + dave.On("GetCurrentSealedEpoch", blockRange(cursor, firstBlock)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil) + dave.On("GetCurrentSealedEpoch", blockRange(firstBlock, secondBlock)). + Return(makeSealedEpochResult(2, 0, 0, tournament), nil) + dave.On("GetCurrentSealedEpoch", blockRange(secondBlock, head+1)). + Return(makeSealedEpochResult(3, 0, 0, tournament), nil) + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == firstBlock && opts.End != nil && *opts.End == firstBlock + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(1, 0, 0, firstBlock, tournament), + makeSealedEpochEvent(2, 0, 0, firstBlock, tournament), + }, nil).Twice() + secondPage := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == secondBlock && opts.End != nil && *opts.End == secondBlock + }) + if !sameBlockFailure { + dave.On("RetrieveSealedEpochs", secondPage).Return([]*idaveconsensus.IDaveConsensusEpochSealed{}, nil).Once() + } + dave.On("RetrieveSealedEpochs", secondPage).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(3, 0, 0, secondBlock, tournament), + }, nil).Once() + repo.On("UpdateEpochClaimTransactionHash", t.Context(), app1Addr.Hex(), mock.Anything).Return(nil) + + require.Error(t, r.processApplicationSealedEpochs(t.Context(), app, head)) + require.Equal(t, cursor, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.NotNil(t, repo.epochs[1], "the failed scan must have a committed prefix") + // Other services can process that prefix before the scan retries. + repo.epochs[1].Status = model.EpochStatus_ClaimComputed + commitment := common.HexToHash("0x4567") + repo.epochs[1].Commitment = &commitment + repo.epochs[1].CommitmentProof = []common.Hash{common.HexToHash("0x89ab")} + before := *repo.epochs[1] + repo.storeErr = nil + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, model.MonitoredEvent_EpochSealed, head). + Return(nil).Once() + + require.NoError(t, r.processApplicationSealedEpochs(t.Context(), app, head)) + require.Equal(t, before, *repo.epochs[1], "replay must preserve claim and proof progress") + require.Equal(t, []uint64{1, 2, 3}, repo.stores, "already sealed rows must not be re-stored") + require.Equal(t, firstBlock, repo.epochs[2].LastBlock, "the second same-block seal must survive retry") + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } +} + +func TestSealedEpochReplayRejectsConflictingStoredFields(t *testing.T) { + const block uint64 = 100 + tournament := common.HexToAddress("0x1234") + for _, field := range []string{"last block", "upper bound", "missing tournament", "different tournament"} { + t.Run(field, func(t *testing.T) { + repo := newMockRepository() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(nil, nil) + app.application.IInputBoxBlock = 10 + epoch := &model.Epoch{Index: 0, FirstBlock: 10, LastBlock: block, + Status: model.EpochStatus_ClaimComputed, TournamentAddress: &tournament} + switch field { + case "last block": + epoch.LastBlock++ + case "upper bound": + epoch.InputIndexUpperBound++ + case "missing tournament": + epoch.TournamentAddress = nil + case "different tournament": + other := common.HexToAddress("0x5678") + epoch.TournamentAddress = &other + } + before := *epoch + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(epoch, nil).Once() + err := r.processSealedEpochEvent(t.Context(), app, makeSealedEpochEvent(0, 0, 0, block, tournament)) + require.ErrorContains(t, err, "data mismatch with replayed event") + require.Equal(t, before, *epoch) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_test.go b/internal/evmreader/sealedepochs_test.go index 3e6d962b8..8818e5be8 100644 --- a/internal/evmreader/sealedepochs_test.go +++ b/internal/evmreader/sealedepochs_test.go @@ -75,21 +75,13 @@ func (s *SealedEpochsSuite) TestProcessSealedEpochFindsInputAtOverlapBlock() { tournamentAddr := common.HexToAddress("0xAAAA") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 // Epoch 0 was already stored with LastBlock=100 and InputIndexUpperBound=3. - // CreateEpochsAndInputs set LastInputCheckBlock=100 for this app. + // An earlier open-epoch scan can already have reached block 100, so its + // input cursor must not determine where this sealed-epoch scan starts. // // Now epoch 1 is sealed at block 200: // FirstBlock = prevEpoch.LastBlock = 100 (PRT overlap) @@ -186,27 +178,13 @@ func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { forecloseBlock uint64 = 70 ) - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-prt-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - ConsensusType: Consensus_PRT, - ForecloseBlock: forecloseBlock, - LastEpochCheckBlock: lastEpochCheckBlock, - LastInputCheckBlock: forecloseBlock, - LastOutputCheckBlock: lastEpochCheckBlock, - }, - daveConsensus: s.dave, - inputSource: s.inputBox, - } - - s.repository.On("GetLastNonOpenEpoch", mock.Anything, app.application.IApplicationAddress.String()). - Return(&Epoch{ - Index: 2, - LastBlock: lastEpochCheckBlock, - }, nil).Once() + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.Name = "test-prt-app" + app.application.ConsensusType = Consensus_PRT + app.application.ForecloseBlock = forecloseBlock + app.application.LastEpochCheckBlock = lastEpochCheckBlock + app.application.LastInputCheckBlock = forecloseBlock + app.application.LastOutputCheckBlock = lastEpochCheckBlock currentSealedEpoch := DaveCurrentSealedEpoch{ EpochNumber: big.NewInt(2), @@ -233,21 +211,13 @@ func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { func (s *SealedEpochsSuite) TestTerminalDaveConsensusAppProcessesOpenEpochToForeclosure() { const forecloseBlock uint64 = 70 - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-prt-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - ConsensusType: Consensus_PRT, - Status: ApplicationStatus_MachineHalted, - ForecloseBlock: forecloseBlock, - LastEpochCheckBlock: forecloseBlock, - LastInputCheckBlock: forecloseBlock - 1, - }, - daveConsensus: s.dave, - inputSource: s.inputBox, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.Name = "test-prt-app" + app.application.ConsensusType = Consensus_PRT + app.application.Status = ApplicationStatus_MachineHalted + app.application.ForecloseBlock = forecloseBlock + app.application.LastEpochCheckBlock = forecloseBlock + app.application.LastInputCheckBlock = forecloseBlock - 1 s.repository.On("GetLastNonOpenEpoch", mock.Anything, app.application.IApplicationAddress.String()). @@ -316,3 +286,28 @@ func (s *SealedEpochsSuite) TestDaveConsensusWithMissingInputBoxAdapterDoesNotPa s.dave.AssertNotCalled(s.T(), "GetCurrentSealedEpoch") s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) } + +func (s *SealedEpochsSuite) TestSealedEpochAtGenesisDoesNotPublishCursor() { + event := makeSealedEpochEvent(0, 0, 0, 0, common.HexToAddress("0xAAAA")) + err := s.evmReader.processSealedEpochEvent(s.ctx, appContracts{}, event) + s.Require().ErrorContains(err, "sealed epoch event has block number zero") + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +func (s *SealedEpochsSuite) TestSealedEpochAtFirstBlockPublishesZeroCursor() { + app := appContracts{application: &Application{IApplicationAddress: app1Addr, IInputBoxBlock: 1}} + event := makeSealedEpochEvent(0, 0, 0, 1, common.HexToAddress("0xAAAA")) + s.repository.On("GetEpoch", mock.Anything, app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + s.repository.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*Epoch][]*Input) bool { + if len(epochs) != 1 { + return false + } + for epoch, inputs := range epochs { + return epoch.FirstBlock == 1 && epoch.LastBlock == 1 && len(inputs) == 0 + } + return false + }), uint64(0)).Return(nil).Once() + s.Require().NoError(s.evmReader.processSealedEpochEvent(s.ctx, app, event)) + s.repository.AssertExpectations(s.T()) +} From 15997099ce98992e5da1cad89c33dcddccc7202e Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:18 -0300 Subject: [PATCH 27/41] fix(advancer): guard epoch publication Treat ErrEpochForeclosed as a publication superseded by the foreclosure drain. Do not turn this expected race into a processing failure. Keep shutdown cancellation separate from dependency failures. Preserve all causes through Step and Tick, stop dispatch after cancellation, and keep mixed database failures on the fatal unconfirmed-write path. Always close the advanced machine when its result was not confirmed saved. Remove the old shutdown exception for missing machines. Both entry points close the machine manager after the supervised services have stopped. Keep publication, shutdown, service-log, and write-failure regression tests with this component. Include small lint cleanups in the affected test file. --- internal/advancer/advancer.go | 31 +++-- internal/advancer/advancer_test.go | 53 ++++---- internal/advancer/publication_test.go | 49 ++++++++ internal/advancer/service.go | 26 ++-- internal/advancer/shutdown_test.go | 166 ++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 50 deletions(-) create mode 100644 internal/advancer/publication_test.go create mode 100644 internal/advancer/shutdown_test.go diff --git a/internal/advancer/advancer.go b/internal/advancer/advancer.go index 546f40ab3..ed2f8a7f8 100644 --- a/internal/advancer/advancer.go +++ b/internal/advancer/advancer.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/manager" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" @@ -27,14 +28,18 @@ var ( // AdvancerRepository defines the repository interface needed by the Advancer service type AdvancerRepository interface { - ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool) ([]*Epoch, uint64, error) - ListInputs(ctx context.Context, nameOrAddress string, f repository.InputFilter, p repository.Pagination, descending bool) ([]*Input, uint64, error) + ListEpochs( + ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool, + ) ([]*Epoch, uint64, error) + ListInputs( + ctx context.Context, nameOrAddress string, f repository.InputFilter, p repository.Pagination, descending bool, + ) ([]*Input, uint64, error) GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) StoreAdvanceResult(ctx context.Context, appID int64, ar *AdvanceResult) error UpdateEpochInputsProcessed(ctx context.Context, nameOrAddress string, epochIndex uint64, proof *StateProof) error UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) - UpdateInputSnapshotURI(ctx context.Context, appId int64, inputIndex uint64, snapshotURI string) error + UpdateInputSnapshotURI(ctx context.Context, appID int64, inputIndex uint64, snapshotURI string) error GetLastSnapshot(ctx context.Context, nameOrAddress string) (*Input, error) GetLastProcessedInput(ctx context.Context, appAddress string) (*Input, error) } @@ -61,8 +66,8 @@ func getUnprocessedInputs( // order, and returns whether any application had work remaining. // // Per-app errors are accumulated so that a failure in one application does not block -// processing of other healthy applications. Context cancellation is always propagated -// immediately. +// processing of other healthy applications. Cancellation stops new application work +// and preserves errors already collected during the cycle. // // The returned boolean indicates whether any app successfully processed inputs and // potentially has more work. Callers use this to decide whether to re-tick immediately @@ -88,14 +93,17 @@ func (s *Service) Step(ctx context.Context) (bool, error) { anyWork := false errs := []error{updateErr} for _, app := range apps { + if err := ctx.Err(); err != nil { + return false, errors.Join(append(errs, err)...) + } hadWork, err := s.stepApp(ctx, app) if err != nil { + errs = append(errs, err) // Context errors (cancellation or timeout) mean no further apps will - // succeed — stop immediately instead of accumulating identical errors. + // succeed. Stop now, but retain earlier application failures. if ctx.Err() != nil { - return false, err + return false, errors.Join(errs...) } - errs = append(errs, err) continue } if hadWork { @@ -176,6 +184,11 @@ func (s *Service) finalizeEpoch(ctx context.Context, app *Application, epoch *Ep appAddress := app.IApplicationAddress.String() if err := s.repository.UpdateEpochInputsProcessed(ctx, appAddress, epoch.Index, proof); err != nil { + if errors.Is(err, repository.ErrEpochForeclosed) { + s.Logger.Info("Epoch was foreclosed before state-proof publication; discarding obsolete publication", + "application", app.Name, "epoch_index", epoch.Index) + return nil + } return fmt.Errorf( "publishing state proof for application %s epoch %d: %w", app.Name, @@ -315,7 +328,7 @@ func (s *Service) processInputs( if err != nil { var errCause string switch { - case errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled): + case errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err): errCause = "canceled advance result persistence" // Shutdown interrupted persistence after the machine advanced. // Discard that runtime; restart must recover from persisted state. diff --git a/internal/advancer/advancer_test.go b/internal/advancer/advancer_test.go index 2e93c7655..372225186 100644 --- a/internal/advancer/advancer_test.go +++ b/internal/advancer/advancer_test.go @@ -1427,7 +1427,7 @@ func (s *AdvancerSuite) TestRemoveSnapshot() { tmpDir := s.T().TempDir() advancer := &Service{snapshotsDir: tmpDir} - serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: "advancer"}} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) // Create a snapshot directory @@ -1446,7 +1446,7 @@ func (s *AdvancerSuite) TestRemoveSnapshot() { tmpDir := s.T().TempDir() advancer := &Service{snapshotsDir: tmpDir} - serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: "advancer"}} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) snapshotPath := filepath.Join(tmpDir, "myapp_epoch0_input0") @@ -1459,7 +1459,7 @@ func (s *AdvancerSuite) TestRemoveSnapshot() { tmpDir := s.T().TempDir() advancer := &Service{snapshotsDir: tmpDir} - serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: "advancer"}} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) // Try to traverse outside snapshotsDir @@ -1474,7 +1474,7 @@ func (s *AdvancerSuite) TestRemoveSnapshot() { tmpDir := s.T().TempDir() advancer := &Service{snapshotsDir: tmpDir} - serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: "advancer"}} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) snapshotPath := filepath.Join(tmpDir, "otherapp_epoch0_input0") @@ -2064,9 +2064,7 @@ func (s *AdvancerSuite) TestNoSelfWakeOnError() { "reschedule should NOT be signaled on error") } -// Error from Step does NOT signal reschedule. -// Uses the same pattern as FailedAppDoesNotBlockOtherApps, which -// verifies Step returns an error when one app fails. +// Progress in one application still requests another tick when another fails. func (s *AdvancerSuite) TestPartialSuccessStillReschedules() { require := s.Require() @@ -2289,7 +2287,7 @@ func (mock *MockMachineManager) GetMachine(appID int64) (manager.MachineInstance return instance, true } -func (mock *MockMachineManager) UpdateMachines(ctx context.Context) error { +func (mock *MockMachineManager) UpdateMachines(_ context.Context) error { if mock.UpdateMachinesError != nil { return mock.UpdateMachinesError } @@ -2342,13 +2340,15 @@ type MockMachineInstance struct { } // Advance implements the MachineInstance interface for testing -func (m *MockMachineInstance) Advance(ctx context.Context, input []byte, epochIndex uint64, index uint64, leafs bool) (*AdvanceResult, error) { +func (m *MockMachineInstance) Advance( + ctx context.Context, input []byte, epochIndex uint64, index uint64, leafs bool, +) (*AdvanceResult, error) { m.advanceCalls++ return m.machineImpl.Advance(ctx, input, epochIndex, index, leafs) } // Inspect implements the MachineInstance interface for testing -func (m *MockMachineInstance) Inspect(ctx context.Context, query []byte) (*manager.InspectResult, error) { +func (m *MockMachineInstance) Inspect(_ context.Context, _ []byte) (*manager.InspectResult, error) { // Not used in advancer tests, but needed to satisfy the interface return nil, nil } @@ -2370,7 +2370,7 @@ func (m *MockMachineInstance) StateProof(_ context.Context) (*StateProof, error) } // CreateSnapshot implements the MachineInstance interface for testing -func (m *MockMachineInstance) CreateSnapshot(ctx context.Context, processInputs uint64, path string) error { +func (m *MockMachineInstance) CreateSnapshot(_ context.Context, _ uint64, _ string) error { if m.createSnapshotError != nil && m.destroyAfterSnapshotError { m.machineImpl.AdvanceError = manager.ErrMachineClosed return errors.Join(manager.ErrMachineClosed, m.createSnapshotError) @@ -2379,7 +2379,7 @@ func (m *MockMachineInstance) CreateSnapshot(ctx context.Context, processInputs } // Retrieves the hash of the current machine state -func (m *MockMachineInstance) Hash(ctx context.Context) ([32]byte, error) { +func (m *MockMachineInstance) Hash(_ context.Context) ([32]byte, error) { // Not used in advancer tests, but needed to satisfy the interface return [32]byte{}, nil } @@ -2430,9 +2430,9 @@ type MockRepository struct { func (mock *MockRepository) ListEpochs( ctx context.Context, nameOrAddress string, - f repository.EpochFilter, - p repository.Pagination, - descending bool, + _ repository.EpochFilter, + _ repository.Pagination, + _ bool, ) ([]*Epoch, uint64, error) { // Check for context cancellation if ctx.Err() != nil { @@ -2454,7 +2454,7 @@ func (mock *MockRepository) ListInputs( nameOrAddress string, f repository.InputFilter, p repository.Pagination, - descending bool, + _ bool, ) ([]*Input, uint64, error) { // Check for context cancellation if ctx.Err() != nil { @@ -2567,7 +2567,7 @@ func (mock *MockRepository) UpdateEpochInputsProcessed( return mock.UpdateEpochsError } -func (mock *MockRepository) UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error { +func (mock *MockRepository) UpdateApplicationStatus(ctx context.Context, _ int64, status ApplicationStatus, reason *string) error { // Check for context cancellation if ctx.Err() != nil { return ctx.Err() @@ -2579,7 +2579,7 @@ func (mock *MockRepository) UpdateApplicationStatus(ctx context.Context, appID i return mock.UpdateApplicationStatusError } -func (mock *MockRepository) GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) { +func (mock *MockRepository) GetEpoch(ctx context.Context, _ string, _ uint64) (*Epoch, error) { if ctx.Err() != nil { return nil, ctx.Err() } @@ -2646,7 +2646,7 @@ func (mock *MockRepository) GetLastProcessedInput(ctx context.Context, appAddres return lastInput, nil } -func (mock *MockRepository) UpdateInputSnapshotURI(ctx context.Context, appId int64, inputIndex uint64, snapshotURI string) error { +func (mock *MockRepository) UpdateInputSnapshotURI(ctx context.Context, _ int64, _ uint64, _ string) error { if ctx.Err() != nil { return ctx.Err() } @@ -2654,7 +2654,7 @@ func (mock *MockRepository) UpdateInputSnapshotURI(ctx context.Context, appId in return mock.UpdateSnapshotURIError } -func (mock *MockRepository) GetLastSnapshot(ctx context.Context, nameOrAddress string) (*Input, error) { +func (mock *MockRepository) GetLastSnapshot(ctx context.Context, _ string) (*Input, error) { // Check for context cancellation if ctx.Err() != nil { return nil, ctx.Err() @@ -2717,19 +2717,19 @@ func randomSliceOfBytes() [][]byte { return slice } -func newInput(appId int64, epochIndex uint64, inputIndex uint64, data []byte) *Input { +func newInput(appID int64, epochIndex uint64, inputIndex uint64, data []byte) *Input { return &Input{ - EpochApplicationID: appId, + EpochApplicationID: appID, EpochIndex: epochIndex, Index: inputIndex, RawData: data, } } -func randomInputs(appId int64, epochIndex uint64, size int) []*Input { +func randomInputs(appID int64, epochIndex uint64, size int) []*Input { slice := make([]*Input, size) for i := range size { - slice[i] = newInput(appId, epochIndex, uint64(i), randomBytes()) + slice[i] = newInput(appID, epochIndex, uint64(i), randomBytes()) } return slice } @@ -2770,6 +2770,11 @@ func (s *AdvancerSuite) TestStoreAdvanceShutdownClassification() { {"independent cancellation", false, context.Canceled, nil, context.Canceled}, {"storage failure", false, storageErr, nil, storageErr}, {"storage failure during shutdown", true, storageErr, nil, storageErr}, + {"mixed storage failure during shutdown", true, errors.Join(context.Canceled, storageErr), nil, storageErr}, + {"nested storage failure during shutdown", true, + fmt.Errorf("store: %w", errors.Join(storageErr, context.Canceled)), nil, storageErr}, + {"deadline during shutdown", true, errors.Join(context.Canceled, context.DeadlineExceeded), nil, context.DeadlineExceeded}, + {"joined shutdown cancellation", true, errors.Join(context.Canceled, context.Canceled), nil, nil}, {"close failure during shutdown", true, context.Canceled, closeErr, closeErr}, } { s.Run(tc.name, func() { diff --git a/internal/advancer/publication_test.go b/internal/advancer/publication_test.go new file mode 100644 index 000000000..6da0de6e5 --- /dev/null +++ b/internal/advancer/publication_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + "fmt" + "log/slog" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *AdvancerSuite) TestFinalizeEpochPublicationOutcome() { + for _, test := range []struct { + name string + storeErr error + obsolete bool + }{ + {name: "published"}, + {name: "foreclosed during publication", storeErr: fmt.Errorf("state changed: %w", repository.ErrEpochForeclosed), obsolete: true}, + {name: "unexpected state", storeErr: repository.ErrNoUpdate}, + {name: "database failure", storeErr: errors.New("database write failed")}, + } { + s.Run(test.name, func() { + env := s.setupOneApp() + logs := &advancerLogCapture{} + env.service.Logger = slog.New(logs) + env.repo.UpdateEpochsError = test.storeErr + epoch := &Epoch{ApplicationID: env.app.Application.ID, Index: 2, Status: EpochStatus_Closed} + + err := env.service.finalizeEpoch(context.Background(), env.app.Application, epoch) + + if test.obsolete || test.storeErr == nil { + s.Require().NoError(err) + } else { + s.Require().ErrorIs(err, test.storeErr) + } + s.Equal(1, env.repo.EpochInputsProcessedCount, "make one publication attempt; do not retry a rejected write") + s.Equal(test.storeErr == nil, logs.contains(slog.LevelInfo, "Epoch updated to Inputs Processed")) + s.Equal(test.obsolete, logs.contains(slog.LevelInfo, + "Epoch was foreclosed before state-proof publication; discarding obsolete publication")) + s.Zero(env.repo.ApplicationStatusUpdates) + s.Equal(EpochStatus_Closed, epoch.Status, "the stale input object must not be used to reset stored state") + }) + } +} diff --git a/internal/advancer/service.go b/internal/advancer/service.go index 1c2cb341f..0525f4f29 100644 --- a/internal/advancer/service.go +++ b/internal/advancer/service.go @@ -10,6 +10,7 @@ import ( "log/slog" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/manager" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/machine" @@ -106,23 +107,14 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { // Without this, one failing app delays all healthy apps by a full poll interval. hadWork, err := s.Step(ctx) - if err == nil { - return hadWork, nil - } - // During shutdown, the machine manager is closed and GetMachine() may - // return ErrNoApp. Suppress this to avoid spurious ERR log entries. - if errors.Is(err, ErrNoApp) && ctx.Err() != nil { - s.Logger.Warn("Tick interrupted by shutdown", "error", err) - return hadWork, nil - } - // Canceled is graceful per the project convention: code paths that - // wrap cancellation (e.g. handleSnapshot → createSnapshot → - // "failed to update input snapshot URI: %w") would otherwise surface - // at ERR via the framework's Tick wrapper. DeadlineExceeded remains a - // real failure and is propagated. - if errors.Is(err, context.Canceled) { - s.Logger.Debug("Tick cancelled (shutdown)", "error", err) - return hadWork, nil + if ctx.Err() != nil { + hadWork = false + // Only cancellation caused by service shutdown is quiet. Preserve + // independent failures, including those joined with cancellation. + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick canceled during shutdown", "error", err) + err = nil + } } return hadWork, err } diff --git a/internal/advancer/shutdown_test.go b/internal/advancer/shutdown_test.go new file mode 100644 index 000000000..eb674df90 --- /dev/null +++ b/internal/advancer/shutdown_test.go @@ -0,0 +1,166 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/require" +) + +func TestAdvancerTickCancellation(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + shutdown bool + cause error + quiet bool + }{ + {name: "active cancellation", cause: context.Canceled}, + {name: "active mixed failure", cause: errors.Join(context.Canceled, dbErr)}, + {name: "shutdown cancellation", shutdown: true, cause: context.Canceled, quiet: true}, + {name: "wrapped shutdown", shutdown: true, cause: fmt.Errorf("query: %w", context.Canceled), quiet: true}, + {name: "joined shutdown", shutdown: true, cause: errors.Join(context.Canceled, context.Canceled), quiet: true}, + {name: "shutdown database failure", shutdown: true, cause: dbErr}, + {name: "shutdown mixed failure", shutdown: true, cause: errors.Join(context.Canceled, dbErr)}, + {name: "shutdown nested failure", shutdown: true, cause: fmt.Errorf("query: %w", errors.Join(dbErr, context.Canceled))}, + {name: "active deadline", cause: context.DeadlineExceeded}, + {name: "shutdown deadline", shutdown: true, cause: context.DeadlineExceeded}, + {name: "shutdown mixed deadline", shutdown: true, cause: errors.Join(context.Canceled, context.DeadlineExceeded)}, + {name: "active missing machine", cause: ErrNoApp}, + {name: "shutdown missing machine", shutdown: true, cause: ErrNoApp}, + {name: "shutdown missing machine and failure", shutdown: true, cause: errors.Join(ErrNoApp, dbErr)}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := &MockRepository{} + mm := newMockMachineManager() + svc, err := newMockAdvancerService(mm, repo) + require.NoError(t, err) + svc.machineManager = &shutdownMachineManager{MockMachineManager: mm, update: func(context.Context) error { + if test.shutdown { + cancel() + } + return test.cause + }} + logs := &advancerLogCapture{} + svc.Logger = slog.New(logs) + + reschedule, err := svc.Tick(ctx) + require.False(t, reschedule) + if test.quiet { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.cause, "preserve the complete dependency error") + } + require.Equal(t, test.quiet, logs.contains(slog.LevelDebug, "Tick canceled during shutdown")) + require.Zero(t, repo.ApplicationStatusUpdates) + require.False(t, mm.PendingApplicationFailures) + }) + } +} + +func TestAdvancerStepPreservesEarlierFailures(t *testing.T) { + firstErr := errors.New("first application database failure") + for _, lastErr := range []error{nil, context.Canceled, errors.Join(context.Canceled, errors.New("second database failure"))} { + for _, method := range []string{"Step", "Tick"} { + t.Run(fmt.Sprintf("%s/%v", method, lastErr), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + mm := newMockMachineManager() + for id := int64(1); id <= 3; id++ { + mm.Map[id] = newMockInstance(newMockMachine(id)) + } + baseRepo := &MockRepository{} + svc, err := newMockAdvancerService(mm, baseRepo) + require.NoError(t, err) + var calls []string + svc.repository = &shutdownEpochRepository{MockRepository: baseRepo, list: func(address string) error { + calls = append(calls, address) + if len(calls) == 1 { + return firstErr + } + cancel() + return lastErr + }} + run := svc.Step + if method == "Tick" { + run = svc.Tick + } + + reschedule, err := run(ctx) + require.False(t, reschedule) + require.ErrorIs(t, err, firstErr) + if lastErr != nil { + require.ErrorIs(t, err, lastErr) + } else { + require.ErrorIs(t, err, context.Canceled, "stop before the next application even after a successful read") + } + require.Equal(t, []string{ + mm.Map[1].application.IApplicationAddress.Hex(), + mm.Map[2].application.IApplicationAddress.Hex(), + }, calls, "do not dispatch another application after cancellation") + require.Zero(t, baseRepo.ApplicationStatusUpdates) + require.False(t, mm.PendingApplicationFailures) + }) + } + } +} + +func TestAdvancerServeReportsShutdownFailures(t *testing.T) { + for _, test := range []struct { + name string + cause error + quiet bool + }{ + {"cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"mixed database failure", errors.Join(context.Canceled, errors.New("database unavailable")), false}, + {"deadline", context.DeadlineExceeded, false}, + {"missing machine and failure", errors.Join(ErrNoApp, errors.New("database unavailable")), false}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + mm := newMockMachineManager() + svc, err := newMockAdvancerService(mm, &MockRepository{}) + require.NoError(t, err) + svc.machineManager = &shutdownMachineManager{MockMachineManager: mm, update: func(context.Context) error { + cancel() + return test.cause + }} + logs := &advancerLogCapture{} + svc.Logger = slog.New(logs) + + require.ErrorIs(t, svc.Serve(ctx), context.Canceled) + require.Equal(t, !test.quiet, logs.contains(slog.LevelError, "Tick")) + }) + } +} + +type shutdownMachineManager struct { + *MockMachineManager + update func(context.Context) error +} + +func (m *shutdownMachineManager) UpdateMachines(ctx context.Context) error { + return m.update(ctx) +} + +type shutdownEpochRepository struct { + *MockRepository + list func(string) error +} + +func (r *shutdownEpochRepository) ListEpochs( + _ context.Context, address string, _ repository.EpochFilter, _ repository.Pagination, _ bool, +) ([]*model.Epoch, uint64, error) { + return nil, 0, r.list(address) +} From 69c69963448059d108e71928132e033114df2d8a Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:18 -0300 Subject: [PATCH 28/41] fix(validator): guard epoch publication Handle a claim publication that loses to foreclosure without restoring non-terminal claim state or reporting local corruption. Include the focused publication test, remove obsolete data-availability fixture fields, and keep the nearby Validator cleanup in this component. Treat failure to prove the freshly built commitment tree as an internal FAILED condition. Keep invalid stored dimensions, repetitions, and final hashes classified as CORRUPTED. --- internal/validator/commitment_proof_test.go | 102 +++++++++++ internal/validator/publication_test.go | 88 +++++++++ internal/validator/shutdown_test.go | 190 ++++++++++++++++++++ internal/validator/validator.go | 79 +++++--- test/validator/validator_test.go | 4 - 5 files changed, 430 insertions(+), 33 deletions(-) create mode 100644 internal/validator/commitment_proof_test.go create mode 100644 internal/validator/publication_test.go create mode 100644 internal/validator/shutdown_test.go diff --git a/internal/validator/commitment_proof_test.go b/internal/validator/commitment_proof_test.go new file mode 100644 index 000000000..31f4b58ae --- /dev/null +++ b/internal/validator/commitment_proof_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "errors" + "io" + "log/slog" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" +) + +func TestCommitmentProofFailureClassification(t *testing.T) { + machineHash := common.HexToHash("0x1234") + dbErr := errors.New("status write failed") + for _, tc := range []struct { + name string + tree *merkle.Tree + status model.ApplicationStatus + errorText string + cause error + writeError error + }{ + { + name: "internal tree invariant", tree: &merkle.Tree{Height: uint32(model.Log2EpochComputationHashLeafCount)}, + status: model.ApplicationStatus_Failed, errorText: "internal invariant violated", cause: merkle.ErrInvariant, + }, + { + name: "invariant with status write failure", tree: &merkle.Tree{Height: uint32(model.Log2EpochComputationHashLeafCount)}, + status: model.ApplicationStatus_Failed, errorText: "internal invariant violated", cause: merkle.ErrInvariant, writeError: dbErr, + }, + { + name: "internal child height mismatch", + tree: &merkle.Tree{ + Height: uint32(model.Log2EpochComputationHashLeafCount), + Subtrees: &merkle.InnerNode{ + LHS: merkle.TreeLeaf(machineHash), RHS: merkle.TreeLeaf(machineHash), + }, + }, + status: model.ApplicationStatus_Failed, errorText: "index out of bounds", cause: merkle.ErrBadInput, + }, + { + name: "stored height mismatch", tree: merkle.TreeLeaf(machineHash).Iterated(model.Log2EpochComputationHashLeafCount - 1), + status: model.ApplicationStatus_Corrupted, errorText: "commitment tree height", + }, + { + name: "stored final hash mismatch", tree: merkle.TreeLeaf(common.Hash{}).Iterated(model.Log2EpochComputationHashLeafCount), + status: model.ApplicationStatus_Corrupted, errorText: "does not match machine hash", + }, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newMockrepo() + app := &model.Application{ID: 42, Name: "proof-app", Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, tc.status, mock.Anything). + Return(tc.writeError).Once() + + commitment, proof, err := s.proveCommitment(t.Context(), app, epoch, tc.tree) + + require.ErrorContains(t, err, tc.errorText) + require.Nil(t, commitment) + require.Nil(t, proof) + if tc.cause != nil { + require.ErrorIs(t, err, tc.cause) + } + if tc.writeError != nil { + require.ErrorIs(t, err, tc.writeError) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } else { + require.Equal(t, tc.status, app.Status) + } + repo.AssertExpectations(t) + }) + } +} + +func TestCommitmentProofMatchesEpoch(t *testing.T) { + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + machineHash := common.HexToHash("0x1234") + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + tree := merkle.TreeLeaf(machineHash).Iterated(model.Log2EpochComputationHashLeafCount) + app := &model.Application{Status: model.ApplicationStatus_OK} + + commitment, proof, err := s.proveCommitment(t.Context(), app, epoch, tree) + + require.NoError(t, err) + require.Equal(t, tree.GetRootHash(), *commitment) + require.Equal(t, machineHash, proof.Node) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/validator/publication_test.go b/internal/validator/publication_test.go new file mode 100644 index 000000000..506e73b1a --- /dev/null +++ b/internal/validator/publication_test.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "bytes" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestClaimPublicationOutcome(t *testing.T) { + for _, test := range []struct { + name string + storeErr error + obsolete bool + }{ + {name: "published"}, + {name: "foreclosed during publication", storeErr: fmt.Errorf("state changed: %w", repository.ErrEpochForeclosed), obsolete: true}, + {name: "unexpected state", storeErr: repository.ErrNoUpdate}, + {name: "database failure", storeErr: errors.New("database write failed")}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockrepo() + var output bytes.Buffer + postContext := merkle.CreatePostContext() + s := &Service{ + repository: repo, pristinePostContext: postContext, pristineRootHash: postContext[merkle.TREE_DEPTH], + } + s.Logger = slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + // Keep the application snapshot stale. The repository outcome, not + // this object's foreclosure field, must decide whether work is obsolete. + app := &Application{ID: 7, Name: "publication-app", ConsensusType: Consensus_PRT, + Status: ApplicationStatus_OK, TemplateHash: s.pristineRootHash} + epochs := []*Epoch{ + {ApplicationID: app.ID, Index: 0, VirtualIndex: 0, LastBlock: 9, Status: EpochStatus_InputsProcessed, + MachineHash: &s.pristineRootHash, TxBufferDataBlock: &s.pristineRootHash}, + {ApplicationID: app.ID, Index: 1, VirtualIndex: 1, FirstBlock: 10, LastBlock: 19, Status: EpochStatus_InputsProcessed, + MachineHash: &s.pristineRootHash, TxBufferDataBlock: &s.pristineRootHash}, + } + appAddress := app.IApplicationAddress.String() + repo.On("ListEpochs", mock.Anything, appAddress, mock.Anything, repository.Pagination{}, false). + Return(epochs, uint64(len(epochs)), nil).Once() + wantContinued := test.obsolete || test.storeErr == nil + for i, epoch := range epochs { + if i > 0 && !wantContinued { + break + } + repo.On("ListOutputs", mock.Anything, appAddress, repository.OutputFilter{EpochIndex: &epoch.Index}, + repository.Pagination{}, false).Return([]*Output{}, uint64(0), nil).Once() + repo.On("GetLastInput", mock.Anything, appAddress, epoch.Index).Return((*Input)(nil), nil).Once() + storeErr := test.storeErr + if i > 0 { + storeErr = nil + repo.On("GetEpochByVirtualIndex", mock.Anything, appAddress, epoch.VirtualIndex-1).Return(epochs[0], nil).Twice() + } + repo.On("StoreClaimAndProofs", mock.Anything, epoch, mock.Anything).Return(storeErr).Once() + } + + err := s.validateApplication(t.Context(), app) + + if wantContinued { + require.NoError(t, err) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 2) + } else { + require.ErrorIs(t, err, test.storeErr) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 1) + } + const obsoleteMessage = "Epoch was foreclosed before claim publication; discarding obsolete claim and proofs" + if test.obsolete { + require.Contains(t, output.String(), "level=INFO msg=\""+obsoleteMessage+"\"") + } else { + require.NotContains(t, output.String(), obsoleteMessage) + } + require.Equal(t, ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/validator/shutdown_test.go b/internal/validator/shutdown_test.go new file mode 100644 index 000000000..2937b2ebe --- /dev/null +++ b/internal/validator/shutdown_test.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/service" +) + +func TestValidatorTickShutdownErrors(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, operation := range []string{"applications", "epochs", "outputs", "claim write"} { + for _, stopping := range []bool{false, true} { + for _, test := range []struct { + name string + cause error + cancellationOnly bool + }{ + {"cancellation", context.Canceled, true}, + {"wrapped cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined cancellations", errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), true}, + {"database query failure", dbErr, false}, + {"deadline", context.DeadlineExceeded, false}, + {"mixed deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"mixed database failure", errors.Join(context.Canceled, dbErr), false}, + {"nested database failure", fmt.Errorf("query: %w", errors.Join(context.Canceled, dbErr)), false}, + } { + t.Run(fmt.Sprintf("%s/stopping=%t/%s", operation, stopping, test.name), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s, logs := validatorShutdownFixture(ctx, t, operation, test.cause, func(mock.Arguments) { + if stopping { + cancel() + } + }) + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if stopping && test.cancellationOnly { + require.NoError(t, err) + require.NotContains(t, logs.String(), "level=ERROR") + require.NotContains(t, logs.String(), "level=WARN") + } else { + require.ErrorIs(t, err, test.cause, "preserve all causes, not just one joined error") + } + }) + } + } + } +} + +func TestValidatorClaimWriteShutdownLogs(t *testing.T) { + dbErr := errors.New("database write failed") + for _, test := range []struct { + name string + cause error + wantLog bool + }{ + {"canceled write", fmt.Errorf("write: %w", context.Canceled), false}, + {"completed write", nil, false}, + {"database failure", dbErr, true}, + {"mixed failure", errors.Join(context.Canceled, dbErr), true}, + {"deadline", context.DeadlineExceeded, true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s, logs := validatorShutdownFixture(ctx, t, "claim write", test.cause, func(mock.Arguments) { cancel() }) + require.ErrorIs(t, s.Serve(ctx), context.Canceled) + if test.wantLog { + require.Contains(t, logs.String(), "level=ERROR msg=Tick") + if errors.Is(test.cause, dbErr) { + require.Contains(t, logs.String(), dbErr.Error()) + } else { + require.Contains(t, logs.String(), test.cause.Error()) + } + } else { + require.NotContains(t, logs.String(), "level=ERROR") + } + }) + } +} + +// Cancel from the failing operation, after Tick has started. No status-write +// expectation is installed: an I/O failure must not change application health. +func validatorShutdownFixture( + ctx context.Context, t *testing.T, operation string, cause error, hook func(mock.Arguments), +) (*Service, *bytes.Buffer) { + t.Helper() + repo := newMockrepo() + postContext := merkle.CreatePostContext() + s := &Service{repository: repo, pristinePostContext: postContext, pristineRootHash: postContext[merkle.TREE_DEPTH]} + logs := new(bytes.Buffer) + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: "validator", Logger: slog.New(slog.NewTextHandler(logs, nil))}, + }, s)) + app := &model.Application{ID: 1, Name: "validator-app", Status: model.ApplicationStatus_OK} + t.Cleanup(func() { + repo.AssertExpectations(t) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + }) + apps := repo.On("ListApplications", ctx, mock.Anything, mock.Anything, false).Once() + if operation == "applications" { + apps.Run(hook).Return([]*model.Application(nil), uint64(0), cause) + return s, logs + } + apps.Return([]*model.Application{app}, uint64(1), nil) + epochs := repo.On("ListEpochs", ctx, app.IApplicationAddress.Hex(), mock.Anything, mock.Anything, false).Once() + if operation == "epochs" { + epochs.Run(hook).Return([]*model.Epoch(nil), uint64(0), cause) + return s, logs + } + epoch := &model.Epoch{Status: model.EpochStatus_InputsProcessed, + MachineHash: new(common.HexToHash("0x123")), TxBufferDataBlock: &s.pristineRootHash} + epochs.Return([]*model.Epoch{epoch}, uint64(1), nil) + outputs := repo.On("ListOutputs", ctx, app.IApplicationAddress.Hex(), mock.Anything, mock.Anything, false).Once() + if operation == "outputs" { + outputs.Run(hook).Return([]*model.Output(nil), uint64(0), cause) + return s, logs + } + outputs.Return([]*model.Output(nil), uint64(0), nil) + repo.On("GetLastInput", ctx, app.IApplicationAddress.Hex(), epoch.Index). + Return(&model.Input{MachineHash: epoch.MachineHash, TxBufferDataBlock: epoch.TxBufferDataBlock}, nil).Once() + repo.On("StoreClaimAndProofs", ctx, epoch, mock.Anything).Run(hook).Return(cause).Once() + return s, logs +} + +func TestValidatorTickShutdownRetainsEarlierError(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(new(bytes.Buffer), nil)) + apps := []*model.Application{ + {IApplicationAddress: common.HexToAddress("0x1")}, + {IApplicationAddress: common.HexToAddress("0x2")}, + {IApplicationAddress: common.HexToAddress("0x3")}, + } + repo.On("ListApplications", ctx, mock.Anything, mock.Anything, false).Return(apps, uint64(len(apps)), nil).Once() + firstErr := errors.New("first application database read failed") + repo.On("ListEpochs", ctx, apps[0].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false). + Return([]*model.Epoch(nil), uint64(0), firstErr).Once() + repo.On("ListEpochs", ctx, apps[1].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false). + Run(func(mock.Arguments) { cancel() }).Return([]*model.Epoch(nil), uint64(0), context.Canceled).Once() + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + require.ErrorIs(t, err, firstErr) + repo.AssertNotCalled(t, "ListEpochs", ctx, apps[2].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false) + repo.AssertExpectations(t) +} + +func TestValidatorTickDoneContext(t *testing.T) { + for _, deadline := range []bool{false, true} { + t.Run(fmt.Sprintf("deadline=%t", deadline), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + if deadline { + cancel() + ctx, cancel = context.WithDeadline(t.Context(), time.Time{}) + } + defer cancel() + cancel() + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(new(bytes.Buffer), nil)) + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if deadline { + require.ErrorIs(t, err, context.DeadlineExceeded) + } else { + require.NoError(t, err) + } + require.Empty(t, repo.Calls, "a stopped tick must not query the database") + }) + } +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 753867ac2..296b29a9d 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -16,6 +16,7 @@ import ( "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/merkle" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" @@ -74,30 +75,28 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro // Tick executes the Validator main logic of producing claims and/or proofs // for processed epochs of all running applications. -func (s *Service) Tick(ctx context.Context) (bool, error) { +func (s *Service) Tick(ctx context.Context) (reschedule bool, err error) { + defer func() { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "error", err) + err = nil + } + }() + if err := ctx.Err(); err != nil { + return false, err + } apps, _, err := getAllRunningApplications(ctx, s.repository) if err != nil { - // During shutdown the parent context is canceled and every in- - // flight DB query returns context.Canceled. Suppress only the - // graceful-shutdown case; deadline-exceeded (real failure) still - // propagates. Mirrors internal/prt/service.go's Tick pattern. - if ctx.Err() != nil && errors.Is(err, context.Canceled) { - s.Logger.Warn("Tick interrupted by shutdown", "error", err) - return false, nil - } return false, fmt.Errorf("failed to get running applications. %w", err) } // validate each application errs := []error{} for idx := range apps { + if err := ctx.Err(); err != nil { + return false, errors.Join(append(errs, err)...) + } if err := s.validateApplication(ctx, apps[idx]); err != nil { - // Same shutdown-cancellation suppression as above, per-app. - if ctx.Err() != nil && errors.Is(err, context.Canceled) { - s.Logger.Warn("Tick interrupted by shutdown", - "application", apps[idx].IApplicationAddress, "error", err) - continue - } errs = append(errs, err) } } @@ -105,15 +104,23 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { } type ValidatorRepository interface { - ListApplications(ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool) ([]*Application, uint64, error) + ListApplications( + ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool, + ) ([]*Application, uint64, error) UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error - ListOutputs(ctx context.Context, nameOrAddress string, f repository.OutputFilter, p repository.Pagination, descending bool) ([]*Output, uint64, error) + ListOutputs( + ctx context.Context, nameOrAddress string, f repository.OutputFilter, p repository.Pagination, descending bool, + ) ([]*Output, uint64, error) GetOutput(ctx context.Context, nameOrAddress string, outputIndex uint64) (*Output, error) - ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool) ([]*Epoch, uint64, error) + ListEpochs( + ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool, + ) ([]*Epoch, uint64, error) GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) // FIXME migrate to list GetEpochByVirtualIndex(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) StoreClaimAndProofs(ctx context.Context, epoch *Epoch, outputs []*Output) error - ListStateHashes(ctx context.Context, nameOrAddress string, f repository.StateHashFilter, p repository.Pagination, descending bool) ([]*StateHash, uint64, error) + ListStateHashes( + ctx context.Context, nameOrAddress string, f repository.StateHashFilter, p repository.Pagination, descending bool, + ) ([]*StateHash, uint64, error) } func getAllRunningApplications(ctx context.Context, er ValidatorRepository) ([]*Application, uint64, error) { @@ -180,13 +187,8 @@ func (s *Service) validateApplication(ctx context.Context, app *Application) err merkleRoot, outputs, err := s.computeMerkleTreeAndProofs(ctx, app, epoch) if err != nil { - // Don't log shutdown-cancellation at ERR — every in-flight DB - // query returns context.Canceled and Tick's outer suppression - // requires the service context to be done as well. - // DeadlineExceeded is a real failure and must still be logged. - if ctx.Err() == nil || !errors.Is(err, context.Canceled) { - s.Logger.Error("failed to create claim and proofs.", "error", err) - } + // Tick owns operational error reporting. The returned error already + // identifies the failed operation, application, and epoch. return err } @@ -308,6 +310,11 @@ func (s *Service) validateApplication(ctx context.Context, app *Application) err // store the epoch and proofs in the database err = s.repository.StoreClaimAndProofs(ctx, epoch, outputs) if err != nil { + if errors.Is(err, repository.ErrEpochForeclosed) { + s.Logger.Info("Epoch was foreclosed before claim publication; discarding obsolete claim and proofs", + "application", appAddress, "epoch_index", epoch.Index) + continue + } return fmt.Errorf( "failed to store claim and proofs for epoch %v of application %v. %w", epoch.Index, appAddress, err, @@ -392,6 +399,18 @@ func (s *Service) buildCommitment(ctx context.Context, app *Application, epoch * return nil, nil, s.setApplicationCorrupted(ctx, app, "failed to build commitment for epoch %d of application %s with error: %v", epoch.Index, app.Name, err) } + return s.proveCommitment(ctx, app, epoch, epochCommitmentTree) +} + +// proveCommitment checks the completed tree and proves its final machine state. +// Invalid stored dimensions or hashes are corruption. A broken internal tree +// invariant is a computation failure, not evidence that persisted data is wrong. +func (s *Service) proveCommitment( + ctx context.Context, + app *Application, + epoch *Epoch, + epochCommitmentTree *merkle.Tree, +) (*common.Hash, *merkle.Proof, error) { // The commitment geometry is fixed: 2²⁴ inputs × 2²⁴ entries ⇒ height 48. const expectedHeight = Log2EpochComputationHashLeafCount if uint64(epochCommitmentTree.Height) != expectedHeight { @@ -403,8 +422,11 @@ func (s *Service) buildCommitment(ctx context.Context, app *Application, epoch * commitment := epochCommitmentTree.GetRootHash() proof, err := epochCommitmentTree.ProveLast() if err != nil { - return nil, nil, s.setApplicationCorrupted(ctx, app, - "failed to retrieve commitment proof for epoch %d of application %s with error: %v", epoch.Index, app.Name, err) + // ProveLast derives its own valid index. Any traversal failure in this + // newly built tree is an internal error, even an out-of-range child index. + failure := fmt.Errorf("failed to retrieve commitment proof for epoch %d of application %s: %w", epoch.Index, app.Name, err) + statusErr := appstatus.SetFailed(ctx, s.Logger, s.repository, app, failure.Error()) + return nil, nil, errors.Join(failure, statusErr) } // PRT reconstructs the root children from (epoch.MachineHash, proof). // The tree's last leaf must therefore be the epoch's final machine hash. @@ -418,7 +440,6 @@ func (s *Service) buildCommitment(ctx context.Context, app *Application, epoch * "epoch", epoch.Index, "commitment", commitment.String()) return &commitment, proof, nil - } // computeMerkleTreeAndProofs calculates the claim and proofs for an epoch. It returns diff --git a/test/validator/validator_test.go b/test/validator/validator_test.go index 81359f322..83a3a4ca3 100644 --- a/test/validator/validator_test.go +++ b/test/validator/validator_test.go @@ -106,7 +106,6 @@ func (s *ValidatorRepositoryIntegrationSuite) TestItReturnsPristineClaim() { IConsensusAddress: common.BytesToAddress([]byte("beadbeef")), TemplateHash: common.BytesToHash([]byte("template")), TemplateURI: "/template/path", - DataAvailability: model.DataAvailability_InputBox[:], EpochLength: 10, Enabled: true, Status: model.ApplicationStatus_OK, @@ -173,7 +172,6 @@ func (s *ValidatorRepositoryIntegrationSuite) TestItReturnsPreviousClaim() { IConsensusAddress: common.BytesToAddress([]byte("beadbeef")), TemplateHash: common.BytesToHash([]byte("template")), TemplateURI: "/template/path", - DataAvailability: model.DataAvailability_InputBox[:], EpochLength: 10, Enabled: true, Status: model.ApplicationStatus_OK, @@ -281,7 +279,6 @@ func (s *ValidatorRepositoryIntegrationSuite) TestItReturnsANewClaimAndProofs() IConsensusAddress: common.BytesToAddress([]byte("beadbeef")), TemplateHash: common.BytesToHash([]byte("template")), TemplateURI: "/template/path", - DataAvailability: model.DataAvailability_InputBox[:], EpochLength: 10, Enabled: true, Status: model.ApplicationStatus_OK, @@ -369,7 +366,6 @@ func (s *ValidatorRepositoryIntegrationSuite) TestItReturnsANewClaimAndProofs() IConsensusAddress: common.BytesToAddress([]byte("beadbeef")), TemplateHash: common.BytesToHash([]byte("template")), TemplateURI: "/template/path", - DataAvailability: model.DataAvailability_InputBox[:], EpochLength: 10, Enabled: true, Status: model.ApplicationStatus_OK, From 406029f684dbef91bab63ecb24f43b885a37e6f9 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:18 -0300 Subject: [PATCH 29/41] test(merkle): clean up proof test setup Check the Merkle test setup error and simplify the empty expected hash slice. Keep these small pre-existing test cleanups out of service and repository commits. --- internal/merkle/builder_test.go | 1 + internal/merkle/proof_test.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/merkle/builder_test.go b/internal/merkle/builder_test.go index 1ef5da419..65859abe1 100644 --- a/internal/merkle/builder_test.go +++ b/internal/merkle/builder_test.go @@ -208,6 +208,7 @@ func TestBuildRootChildrenAgainstBuilder(t *testing.T) { rootHashBuilder := proofBuilder.BuildRoot() lhsBuilder, rhsBuilder, err := proofBuilder.BuildRootChildren() + require.NoError(t, err) proofSiblings := Proof{ Pos: new(big.Int).SetUint64((1 << 48) - 1), diff --git a/internal/merkle/proof_test.go b/internal/merkle/proof_test.go index 3d7ca8912..3c2263c45 100644 --- a/internal/merkle/proof_test.go +++ b/internal/merkle/proof_test.go @@ -96,7 +96,7 @@ func TestIncorrectCreateProofsLevel(t *testing.T) { func TestComputSiblingsMatrixAssertions(t *testing.T) { var err error - outputs := []common.Hash{common.Hash{}} + outputs := []common.Hash{{}} post := CreatePostContext() // always the same pre := post index := uint64(0) From f08e5e4fe5e52605b079f69f4dd067a6934714ab Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:54:42 -0300 Subject: [PATCH 30/41] feat(claimer): submit v3 machine proofs Submit the machine root and the complete three-leaf validity proof to IConsensus. Build the ABI proof from owned persisted proof data and reject incomplete state before submission. Introduce the final shared revert recognizer and direct ABI status decoding. Keep known proof failures distinct from race, staging, and application-call failures. The following commit supplies Authority owner evidence for otherwise-unclassified submission errors. Include submission, proof, revert, and in-flight regression tests. Validate saved chain, observation policy, and submission mode before constructing the signer. Use insert-only startup configuration and honor the explicit legacy-fee override. # Conflicts: # internal/claimer/reverts.go # internal/claimer/reverts_test.go # internal/claimer/service.go # internal/claimer/service_test.go --- internal/claimer/blockchain.go | 66 ++++----- internal/claimer/claimer.go | 44 +++--- internal/claimer/claimer_test.go | 194 ++++++++++++++++++++++++++- internal/claimer/fixtures_test.go | 66 ++++----- internal/claimer/foreclosure.go | 7 +- internal/claimer/inflight_test.go | 4 +- internal/claimer/legacy_fees_test.go | 100 ++++++++++++++ internal/claimer/mocks_test.go | 15 ++- internal/claimer/repository.go | 1 + internal/claimer/reverts.go | 154 ++++++++++++--------- internal/claimer/reverts_test.go | 142 +++++++++++--------- internal/claimer/service.go | 56 ++++---- internal/claimer/service_test.go | 93 ++++++++++++- internal/claimer/state_proof.go | 26 ++++ internal/claimer/state_proof_test.go | 84 ++++++++++++ internal/claimer/submit.go | 15 ++- internal/claimer/submit_test.go | 81 +++++++++-- 17 files changed, 853 insertions(+), 295 deletions(-) create mode 100644 internal/claimer/legacy_fees_test.go create mode 100644 internal/claimer/state_proof.go create mode 100644 internal/claimer/state_proof_test.go diff --git a/internal/claimer/blockchain.go b/internal/claimer/blockchain.go index b49cf22e3..74aed4140 100644 --- a/internal/claimer/blockchain.go +++ b/internal/claimer/blockchain.go @@ -12,7 +12,6 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/model" - "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/contracts/iquorum" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -53,9 +52,10 @@ type iclaimerBlockchain interface { submitClaimToBlockchain( ctx context.Context, - ic *iconsensus.IConsensus, + ic consensusClaimSubmitter, application *model.Application, epoch *model.Epoch, + proof model.StateProof, ) (common.Hash, error) acceptClaimOnBlockchain( @@ -101,6 +101,16 @@ type iclaimerBlockchain interface { claimSubmitterAddress() (common.Address, bool) } +type consensusClaimSubmitter interface { + SubmitClaim( + opts *bind.TransactOpts, + appContract common.Address, + lastProcessedBlockNumber *big.Int, + machineMerkleRoot [32]byte, + proof iconsensus.MachineValidityProof, + ) (*types.Transaction, error) +} + type claimerBlockchain struct { client *ethclient.Client txOptsFactory ethutil.TransactOptsFactory @@ -117,50 +127,35 @@ func (cb *claimerBlockchain) claimSubmitterAddress() (common.Address, bool) { func (cb *claimerBlockchain) submitClaimToBlockchain( ctx context.Context, - ic *iconsensus.IConsensus, + ic consensusClaimSubmitter, application *model.Application, epoch *model.Epoch, + proof model.StateProof, ) (common.Hash, error) { txHash := common.Hash{} if cb.txOptsFactory == nil { return txHash, fmt.Errorf("txOptsFactory is required for claim submission") } - if epoch.TxBufferDataBlock == nil { - return txHash, fmt.Errorf( - "epoch %d (%d) has no tx_buffer_data_block to supply as the contract outputs Merkle root; refusing to submit claim", - epoch.Index, epoch.VirtualIndex) - } - // The DB trigger checks tx_buffer_proof when an epoch moves to - // CLAIM_COMPUTED. It does not stop a later UPDATE from clearing the proof. - // Submitting without a proof would revert on chain, so fail here with a - // clear local error. - if epoch.TxBufferProof == nil { - return txHash, fmt.Errorf( - "epoch %d (%d) has no tx_buffer_proof to supply as the contract outputs Merkle proof; refusing to submit claim", - epoch.Index, epoch.VirtualIndex) - } - proof := make([][32]byte, len(epoch.TxBufferProof)) - for i, h := range epoch.TxBufferProof { - proof[i] = h - } txOpts, err := cb.txOptsFactory.NewTransactOpts(ctx) if err != nil { return txHash, fmt.Errorf("creating transaction options for claim submission: %w", err) } lastBlockNumber := new(big.Int).SetUint64(epoch.LastBlock) tx, err := ic.SubmitClaim(txOpts, application.IApplicationAddress, - lastBlockNumber, *epoch.TxBufferDataBlock, proof) + lastBlockNumber, proof.MachineHash, consensusMachineValidityProof(proof)) if err != nil { cb.logger.Warn("submitClaimToBlockchain:failed", "appContractAddress", application.IApplicationAddress, - "claimHash", *epoch.TxBufferDataBlock, + "machine_merkle_root", proof.MachineHash, + "outputs_merkle_root", proof.TxBufferDataBlock, "last_block", epoch.LastBlock, "error", err) } else { txHash = tx.Hash() cb.logger.Debug("submitClaimToBlockchain:success", "appContractAddress", application.IApplicationAddress, - "claimHash", *epoch.TxBufferDataBlock, + "machine_merkle_root", proof.MachineHash, + "outputs_merkle_root", proof.TxBufferDataBlock, "last_block", epoch.LastBlock, "TxHash", txHash) } @@ -329,11 +324,12 @@ func (cb *claimerBlockchain) findClaimStagedEventAndSucc( application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) } - if len(events) == 0 { + switch len(events) { + case 0: return ic, nil, nil, nil - } else if len(events) == 1 { + case 1: return ic, events[0], nil, nil - } else { + default: return ic, events[0], events[1], nil } } @@ -390,11 +386,12 @@ func (cb *claimerBlockchain) findClaimAcceptedEventAndSucc( application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) } - if len(events) == 0 { + switch len(events) { + case 0: return ic, nil, nil, nil - } else if len(events) == 1 { + case 1: return ic, events[0], nil, nil - } else { + default: return ic, events[0], events[1], nil } } @@ -489,15 +486,6 @@ func isCustomConsensusError(err error, name string) bool { ethutil.IsCustomError(err, iquorum.IQuorumMetaData, name) } -// isCustomApplicationError matches a typed Solidity error declared in the -// IApplication ABI. The on-chain merkle library errors (e.g. InvalidNodeIndex) -// are raised by consensus calls but are not declared in the IConsensus ABI; -// the selector is derived from the error signature alone, so any ABI that -// declares the error works for matching. -func isCustomApplicationError(err error, name string) bool { - return ethutil.IsCustomError(err, iapplication.IApplicationMetaData, name) -} - // poll a transaction for its receipt func (cb *claimerBlockchain) pollTransaction( ctx context.Context, diff --git a/internal/claimer/claimer.go b/internal/claimer/claimer.go index 52a990b24..166fa364f 100644 --- a/internal/claimer/claimer.go +++ b/internal/claimer/claimer.go @@ -37,36 +37,37 @@ // status OK. If it was already DIVERGED because of a divergence, EVM reader // preserves that status while still recording foreclose_block. // -// PRT (DaveConsensus) uses a different path. PRT epochs go directly from -// CLAIM_COMPUTED to CLAIM_ACCEPTED through tournament resolution. They never -// reach CLAIM_STAGED, and the claimer queries exclude PRT apps. +// PRT (DaveConsensus) stages and accepts tournament results through the PRT +// service. The claimer queries exclude PRT apps. package claimer import ( "context" "errors" + + "github.com/cartesi/rollups-node/internal/errutil" ) -func (s *Service) shutdownInterrupted(ctx context.Context, stage string, err error) bool { - // During shutdown, the parent context is canceled and RPC/DB calls - // return context.Canceled. Ignore only that normal shutdown case. Other - // errors, such as deadline exceeded, must still be returned. - if ctx.Err() == nil || !errors.Is(err, context.Canceled) { - return false - } - s.Logger.Warn("Tick interrupted by shutdown", "stage", stage, "error", err) - return true -} +func (s *Service) Tick(ctx context.Context) (reschedule bool, err error) { + // Classify the complete result once, on every return path. Cancellation can + // interrupt claim writes as well as work-selection queries. Keep all causes + // when a real failure accompanies shutdown; never hide it behind cancellation. + defer func() { + if !errors.Is(ctx.Err(), context.Canceled) { + return + } + reschedule = false + if errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "error", err) + err = nil + } + }() -func (s *Service) Tick(ctx context.Context) (bool, error) { // Use the same finalized block number for all chain reads in this tick. // This is one RPC per tick even when there is no DB work. The call is // cheap, and Tick already runs on a polling interval. defaultBlockNumber, err := s.blockchain.getDefaultBlockNumber(ctx) if err != nil { - if s.shutdownInterrupted(ctx, "getDefaultBlockNumber", err) { - return false, nil - } return false, err } s.consensusAddressChecks = map[consensusAddressCheckKey]error{} @@ -82,9 +83,6 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { // transaction receipt already contains ClaimStaged. prevSubmittedOrStaged, computedEpochs, computedApps, errComputed := s.repository.SelectClaimsToSubmitPerApp(ctx) if errComputed != nil { - if s.shutdownInterrupted(ctx, "SelectClaimsToSubmitPerApp", errComputed) { - return false, nil - } return false, errComputed } submitted, err := s.submitClaimsAndUpdateDatabase(ctx, prevSubmittedOrStaged, computedEpochs, computedApps, defaultBlockNumber) @@ -92,9 +90,6 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { // Stage 2: stage. SUBMITTED -> STAGED. This read sees stage 1 updates. prevAcceptedForSubmitted, submittedEpochs, submittedApps, errSubmitted := s.repository.SelectClaimsToStagePerApp(ctx) if errSubmitted != nil { - if s.shutdownInterrupted(ctx, "SelectClaimsToStagePerApp", errSubmitted) { - return false, err // Preserve errors accumulated by stage 1. - } return false, errors.Join(err, errSubmitted) } staged, stageErr := s.stageClaimsAndUpdateDatabase(ctx, prevAcceptedForSubmitted, submittedEpochs, submittedApps, defaultBlockNumber) @@ -105,9 +100,6 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { // This read sees stage 1 and stage 2 updates. prevAcceptedForStaged, stagedEpochs, stagedApps, errStaged := s.repository.SelectClaimsToAcceptPerApp(ctx) if errStaged != nil { - if s.shutdownInterrupted(ctx, "SelectClaimsToAcceptPerApp", errStaged) { - return false, err // Preserve errors accumulated by earlier stages. - } return false, errors.Join(err, errStaged) } diff --git a/internal/claimer/claimer_test.go b/internal/claimer/claimer_test.go index 457b76b3c..0c97f9852 100644 --- a/internal/claimer/claimer_test.go +++ b/internal/claimer/claimer_test.go @@ -4,8 +4,11 @@ package claimer import ( + "bytes" "context" + "errors" "fmt" + "log/slog" "math/big" "testing" "time" @@ -15,6 +18,8 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -120,11 +125,186 @@ func TestTickCancellationRequiresCanceledServiceContext(t *testing.T) { } } -func TestShutdownInterruptedDoesNotSuppressOtherErrors(t *testing.T) { - m, _, _ := newServiceMock(t) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - require.False(t, m.shutdownInterrupted(ctx, "test", context.DeadlineExceeded)) - require.False(t, m.shutdownInterrupted(ctx, "test", fmt.Errorf("database unavailable"))) - require.False(t, m.shutdownInterrupted(ctx, "test", nil)) +func TestTickShutdownErrorClassification(t *testing.T) { + const canceledState = "canceled" + dbErr := errors.New("database unavailable") + for _, state := range []string{"active", canceledState, "deadline exceeded"} { + for _, test := range []struct { + name string + err error + cancellationOnly bool + }{ + {"cancellation", context.Canceled, true}, + {"wrapped cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined cancellations", errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), true}, + {"database failure", dbErr, false}, + {"deadline", context.DeadlineExceeded, false}, + {"cancellation and deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"cancellation and database failure", errors.Join(context.Canceled, dbErr), false}, + {"nested database failure", fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), false}, + } { + t.Run(state+"/"+test.name, func(t *testing.T) { + m, _, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch state { + case canceledState: + cancel() + case "deadline exceeded": + var deadlineCancel context.CancelFunc + ctx, deadlineCancel = context.WithDeadline(ctx, time.Now().Add(-time.Second)) + defer deadlineCancel() + } + blockchain.On("getDefaultBlockNumber", ctx).Return(big.NewInt(100), test.err).Once() + + reschedule, err := m.Tick(ctx) + + require.False(t, reschedule) + if state == canceledState && test.cancellationOnly { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.err, "keep the complete operational error") + } + blockchain.AssertExpectations(t) + }) + } + } +} + +// Exercise the receipt-to-database path that shutdown interrupted in the +// snapshot integration test. Also run it through Serve to check the ERROR log. +func TestTickStagingWriteShutdown(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + writeErr error + queryFails bool + wantErr error + }{ + {"shutdown at next query", context.Canceled, true, nil}, + {"shutdown at tick end", fmt.Errorf("write: %w", context.Canceled), false, nil}, + {"shutdown after completed write", nil, false, nil}, + {"database failure before shutdown", dbErr, true, dbErr}, + {"mixed failure at tick end", errors.Join(context.Canceled, dbErr), false, dbErr}, + {"timeout before shutdown", context.DeadlineExceeded, true, context.DeadlineExceeded}, + } { + for _, throughServe := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/serve=%t", test.name, throughServe), func(t *testing.T) { + m, repo, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var logs bytes.Buffer + m.Logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + app := makeApplication() + epoch := makeComputedEpoch(app, 3) + computed := makeEpochMap(epoch) + txHash := common.HexToHash("0x10") + pending := inFlightTx{txHash: txHash} + m.claimsInFlight[app.ID] = pending + tickBlock := big.NewInt(100) + stagedLog := makeClaimStagedLog(app, epoch) + stagedLog.BlockNumber = epoch.LastBlock + 1 + blockchain.On("getDefaultBlockNumber", ctx).Return(tickBlock, nil).Once() + repo.On("SelectClaimsToSubmitPerApp", ctx). + Return(makeEpochMap(), computed, makeApplicationMap(app), nil).Once() + blockchain.On("pollTransaction", ctx, txHash, tickBlock). + Return(true, &types.Receipt{ + TxHash: txHash, Status: types.ReceiptStatusSuccessful, + BlockNumber: new(big.Int).SetUint64(stagedLog.BlockNumber), + Logs: []*types.Log{&stagedLog}, + }, nil).Once() + repo.On("UpdateEpochThroughStaging", ctx, app.ID, epoch.Index, txHash, stagedLog.BlockNumber). + Run(func(mock.Arguments) { cancel() }).Return(test.writeErr).Once() + var queryErr error + if test.queryFails { + queryErr = context.Canceled + } + repo.On("SelectClaimsToStagePerApp", ctx). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), queryErr).Once() + if !test.queryFails { + repo.On("SelectClaimsToAcceptPerApp", ctx). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), nil).Once() + repo.On("ListApplications", ctx, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, 0, nil).Once() + } + + if throughServe { + require.ErrorIs(t, m.Serve(ctx), context.Canceled) + if test.wantErr == nil { + require.NotContains(t, logs.String(), "level=ERROR") + } else { + require.Contains(t, logs.String(), "level=ERROR msg=Tick") + require.Contains(t, logs.String(), test.wantErr.Error()) + } + } else { + reschedule, err := m.Tick(ctx) + require.False(t, reschedule) + if test.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.wantErr) + } + } + if test.writeErr == nil { + require.NotContains(t, m.claimsInFlight, app.ID) + require.NotContains(t, computed, app.ID) + } else { + require.Equal(t, pending, m.claimsInFlight[app.ID], "keep tracking until the database write succeeds") + require.Contains(t, computed, app.ID) + } + repo.AssertExpectations(t) + blockchain.AssertExpectations(t) + }) + } + } +} + +func TestTickPreservesEarlierErrorsOnShutdown(t *testing.T) { + for _, test := range []struct { + name string + failStage bool + cancelAccept bool + }{ + {name: "submit error before stage cancellation"}, + {name: "submit error before accept cancellation", cancelAccept: true}, + {name: "stage error before accept cancellation", failStage: true, cancelAccept: true}, + } { + t.Run(test.name, func(t *testing.T) { + m, repo, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + workErr := errors.New("claim work RPC failed") + app := makeApplication() + computed, submitted := makeEpochMap(), makeEpochMap() + if test.failStage { + submitted = makeEpochMap(makeSubmittedEpoch(app, 3)) + } else { + computed = makeEpochMap(makeComputedEpoch(app, 3)) + } + blockchain.On("getDefaultBlockNumber", ctx).Return(big.NewInt(100), nil).Once() + blockchain.On("getConsensusAddress", ctx, app, big.NewInt(100)). + Return(app.IConsensusAddress, workErr).Once() + repo.On("SelectClaimsToSubmitPerApp", ctx). + Return(makeEpochMap(), computed, makeApplicationMap(app), nil).Once() + if test.cancelAccept { + repo.On("SelectClaimsToStagePerApp", ctx). + Return(makeEpochMap(), submitted, makeApplicationMap(app), nil).Once() + repo.On("SelectClaimsToAcceptPerApp", ctx). + Run(func(mock.Arguments) { cancel() }). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), context.Canceled).Once() + } else { + repo.On("SelectClaimsToStagePerApp", ctx). + Run(func(mock.Arguments) { cancel() }). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), context.Canceled).Once() + } + + reschedule, err := m.Tick(ctx) + + require.False(t, reschedule) + require.ErrorIs(t, err, workErr) + require.ErrorIs(t, err, context.Canceled, "keep both causes when shutdown accompanies a real error") + repo.AssertExpectations(t) + blockchain.AssertExpectations(t) + }) + } } diff --git a/internal/claimer/fixtures_test.go b/internal/claimer/fixtures_test.go index cdd157b01..0de5bc678 100644 --- a/internal/claimer/fixtures_test.go +++ b/internal/claimer/fixtures_test.go @@ -13,7 +13,6 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository/repotest" - "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/contracts/iquorum" "github.com/cartesi/rollups-node/pkg/service" @@ -94,17 +93,22 @@ func makeApplication() *model.Application { } func makeEpoch(id int64, status model.EpochStatus, i uint64) *model.Epoch { - outputsMerkleRoot := common.HexToHash("0x01") // dummy value - machineHash := common.HexToHash("0x03") // dummy value; matches events via testMachineHash - txHash := common.HexToHash("0x02") // dummy value + outputsMerkleRoot := common.HexToHash("0x01") + proof := repotest.KeccakStateProof(outputsMerkleRoot) + txHash := common.HexToHash("0x02") e := repotest.NewEpochBuilder(id). WithIndex(i). WithBlocks(i*10, i*10+9). WithStatus(status). WithClaimTransactionHash(txHash). WithTxBufferDataBlock(outputsMerkleRoot). - WithMachineHash(machineHash). + WithMachineHash(proof.MachineHash). Build() + e.TxBufferProof = stateProofHashes(proof.TxBufferProof) + e.IflagsYDataBlock = model.Pointer(proof.IflagsYDataBlock) + e.IflagsYProof = stateProofHashes(proof.IflagsYProof) + e.HtifTohostDataBlock = model.Pointer(proof.HtifTohostDataBlock) + e.HtifTohostProof = stateProofHashes(proof.HtifTohostProof) if status == model.EpochStatus_ClaimStaged { // CHECK constraint: staged_iff_block. b := uint64(i*10 + 1) @@ -113,6 +117,14 @@ func makeEpoch(id int64, status model.EpochStatus, i uint64) *model.Epoch { return e } +func stateProofHashes(proof [][32]byte) []common.Hash { + result := make([]common.Hash, len(proof)) + for i := range proof { + result[i] = proof[i] + } + return result +} + func makeAcceptedEpoch(app *model.Application, i uint64) *model.Epoch { return makeEpoch(app.ID, model.EpochStatus_ClaimAccepted, i) } @@ -258,7 +270,7 @@ func notFirstClaimError() error { selector := fmt.Sprintf("0x%x", id[:4]) return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: selector + "000000000000000000000000" + "01000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000027", @@ -267,27 +279,22 @@ func notFirstClaimError() error { // consensusRevertError creates a typed revert with only the 4-byte selector — // sufficient for the classifier to match by name. Looks up the error in -// IConsensus first, then IQuorum (for Quorum-only errors like -// CallerIsNotValidator), then IApplication (for merkle library errors like -// InvalidNodeIndex, which consensus calls raise but only the application ABI -// declares). +// IConsensus first, then IQuorum for Quorum-only errors such as +// CallerIsNotValidator. func consensusRevertError(errorName string) error { consensusABI, _ := iconsensus.IConsensusMetaData.GetAbi() quorumABI, _ := iquorum.IQuorumMetaData.GetAbi() - applicationABI, _ := iapplication.IApplicationMetaData.GetAbi() var id common.Hash if e, ok := consensusABI.Errors[errorName]; ok { id = e.ID } else if e, ok := quorumABI.Errors[errorName]; ok { id = e.ID - } else if e, ok := applicationABI.Errors[errorName]; ok { - id = e.ID } else { panic(fmt.Sprintf("unknown typed error: %s", errorName)) } return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", id[:4]), } } @@ -314,7 +321,7 @@ func appRevertDataError(errorName string, returndata []byte) error { payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", payload), } } @@ -338,7 +345,7 @@ func notPastBlockError(lastProcessed, upperBound uint64) error { payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", payload), } } @@ -363,38 +370,26 @@ func claimNotStagedError(status uint8) error { payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", payload), } } -// TestDecodeClaimNotStagedStatus pins the ABI-decode path used by -// handleAcceptClaimRevert. The status byte must come from the contract's - +// withForeclosed returns a copy with the foreclosure block and transaction set. func withForeclosed(app *model.Application, block uint64) *model.Application { - copy := *app - copy.ForecloseBlock = block + foreclosed := *app + foreclosed.ForecloseBlock = block txHash := common.HexToHash("0xcafe") - copy.ForecloseTransaction = &txHash - return © + foreclosed.ForecloseTransaction = &txHash + return &foreclosed } -// TestSubmitClaimForeclosesUnstagedForeclosedApp verifies the -// foreclosure-broadcast guard. A foreclosed app whose chain state is -// UNSTAGED still goes through the pre-submit reconciliation read -// (findClaimSubmittedEventAndSucc + getClaimStatus) — those would mirror -// any pre-foreclosure on-chain-accepted state into the local DB — but the -// submitClaimToBlockchain broadcast must be SKIPPED and the local claim - func makeStagedEpoch(app *model.Application, i uint64, stagedAtBlock uint64) *model.Epoch { e := makeEpoch(app.ID, model.EpochStatus_ClaimStaged, i) e.StagedAtBlock = &stagedAtBlock return e } -// TestStagingFastPathDivergence — Authority's submitClaim receipt contains a -// ClaimStaged event with a divergent machineMerkleRoot. The fast path detects - func buildClaimStagedLog(app *model.Application, epoch *model.Epoch, outputs common.Hash, machine common.Hash) types.Log { parsed, err := iconsensus.IConsensusMetaData.GetAbi() @@ -419,6 +414,3 @@ func buildClaimStagedLog(app *model.Application, epoch *model.Epoch, Data: data, } } - -// TestStageByObservation — submitted epoch + ClaimStaged event observed in -// the next-tick scan → transition to CLAIM_STAGED with staged_at_block diff --git a/internal/claimer/foreclosure.go b/internal/claimer/foreclosure.go index 56b9ef616..54754c0e6 100644 --- a/internal/claimer/foreclosure.go +++ b/internal/claimer/foreclosure.go @@ -51,9 +51,10 @@ func foreclosedClaimDrainApplicationsFilter() repository.ApplicationFilter { // // A foreclosed application is identified by foreclose_block, not by status: a // healthy foreclosed app is enabled with status=OK and foreclose_block set, and -// is drained here. A foreclosed app that is also DIVERGED or CORRUPTED is -// terminal and excluded by the selecting filter, but EVM reader keeps observing -// it (drive-prove, withdrawals) because observation is gated on enabled. +// is drained here. Any non-OK app is excluded by the selecting filter. FAILED +// requires repair before the operator clears it and resumes the drain. Terminal +// health statuses remain blocked. EVM reader still observes enabled apps +// (drive-prove, withdrawals), regardless of their health. // // This function does not send transactions. Submit and accept code already skip // broadcasts when foreclose_block is set. Once all drain checks pass there is no diff --git a/internal/claimer/inflight_test.go b/internal/claimer/inflight_test.go index 87bbc0c0d..e9cddd6e6 100644 --- a/internal/claimer/inflight_test.go +++ b/internal/claimer/inflight_test.go @@ -130,7 +130,7 @@ func TestInFlightReverted(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.HexToHash("0x10"), nil).Once() _, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -272,7 +272,7 @@ func TestClaimInFlightReceiptNotFoundAfterTimeoutClearsAndRetries(t *testing.T) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted(nil), nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(newTxHash, nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) diff --git a/internal/claimer/legacy_fees_test.go b/internal/claimer/legacy_fees_test.go new file mode 100644 index 000000000..0447f072e --- /dev/null +++ b/internal/claimer/legacy_fees_test.go @@ -0,0 +1,100 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" +) + +func TestCreateWiresLegacyFees(t *testing.T) { + for _, legacy := range []bool{false, true} { + for _, injectClient := range []bool{false, true} { + t.Run(fmt.Sprintf("legacy=%t/injected=%t", legacy, injectClient), func(t *testing.T) { + viper.Set(config.AUTH_KIND, "private_key") + viper.Set(config.AUTH_PRIVATE_KEY, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + t.Cleanup(func() { + viper.Set(config.AUTH_KIND, nil) + viper.Set(config.AUTH_PRIVATE_KEY, nil) + }) + raw, err := json.Marshal(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: true, + }) + require.NoError(t, err) + repo := &claimerCreateRepositoryMock{} + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey).Return(raw, time.Time{}, time.Time{}, nil).Once() + backend := &claimerLegacyFeeRPC{chainIDRPC: chainIDRPC{chainID: 42}} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + endpoint, err := config.ToURLFromString(httpServer.URL) + require.NoError(t, err) + var client *ethclient.Client + if injectClient { + client = ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + } + s, err := Create(t.Context(), &CreateInfo{ + Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainHttpEndpoint: endpoint, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: time.Second, + BlockchainLegacyEnabled: legacy, + }, + EthConn: client, Repository: repo, + }) + require.NoError(t, err) + blockchain, ok := s.(*Service).blockchain.(*claimerBlockchain) + require.True(t, ok) + if !injectClient { + t.Cleanup(blockchain.client.Close) + } + for sequence := int64(1); sequence <= 2; sequence++ { + opts, err := blockchain.txOptsFactory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Zero(t, opts.GasLimit, "keep estimation enabled") + if legacy { + require.Equal(t, big.NewInt(100+sequence), opts.GasPrice) + } else { + require.Nil(t, opts.GasPrice) + } + } + if legacy { + require.EqualValues(t, 2, backend.prices.Load()) + } else { + require.Zero(t, backend.prices.Load()) + } + repo.AssertExpectations(t) + }) + } + } +} + +type claimerLegacyFeeRPC struct { + chainIDRPC + prices atomic.Int32 +} + +func (r *claimerLegacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} diff --git a/internal/claimer/mocks_test.go b/internal/claimer/mocks_test.go index 2212aad6a..48c5a99f9 100644 --- a/internal/claimer/mocks_test.go +++ b/internal/claimer/mocks_test.go @@ -27,6 +27,11 @@ type claimerCreateRepositoryMock struct { mock.Mock } +func (m *claimerCreateRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + func (m *claimerCreateRepositoryMock) SaveNodeConfigRaw( ctx context.Context, key string, @@ -206,6 +211,11 @@ func (m *claimerRepositoryMock) UpdateEpochReconciledStaged( return args.Error(0) } +func (m *claimerRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + func (m *claimerRepositoryMock) SaveNodeConfigRaw( ctx context.Context, key string, @@ -299,11 +309,12 @@ func (m *claimerBlockchainMock) findClaimAcceptedEventAndSucc( func (m *claimerBlockchainMock) submitClaimToBlockchain( ctx context.Context, - instance *iconsensus.IConsensus, + instance consensusClaimSubmitter, app *model.Application, epoch *model.Epoch, + proof model.StateProof, ) (common.Hash, error) { - rets := m.Called(ctx, instance, app, epoch) + rets := m.Called(ctx, instance, app, epoch, proof) return rets.Get(0).(common.Hash), rets.Error(1) } diff --git a/internal/claimer/repository.go b/internal/claimer/repository.go index 2920bef9d..7b0869a1c 100644 --- a/internal/claimer/repository.go +++ b/internal/claimer/repository.go @@ -108,6 +108,7 @@ type iclaimerRepository interface { descending bool, ) ([]*model.Application, uint64, error) + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) } diff --git a/internal/claimer/reverts.go b/internal/claimer/reverts.go index d896ab345..cfe46e21b 100644 --- a/internal/claimer/reverts.go +++ b/internal/claimer/reverts.go @@ -5,9 +5,8 @@ package claimer import ( "context" - "fmt" "math/big" - "reflect" + "slices" "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/model" @@ -85,9 +84,12 @@ const ( // - ApplicationForeclosed: retry later while EVM reader records foreclose_block. // The app remains enabled for L1 observation; normal work stops once // foreclose_block is recorded. -// - InvalidOutputsMerkleRootProofSize: CORRUPTED; local data corruption. -// - InvalidNodeIndex: CORRUPTED; the outputs merkle proof in the DB does not -// form a valid machine-tree replacement proof. +// - InvalidSiblingsArrayLength, InvalidMachineMerkleProof, +// InvalidPostEpochMachineIflagsYRegister, and +// InvalidPostEpochMachineHtifTohostRegister: FAILED; the deployed +// contract rejected a proof that the node validated when it collected it. +// The stored proof may be corrupt, or node and contract validation may +// disagree. // - CallerIsNotValidator: FAILED; wrong operator key. // - the reverts shared with acceptClaim (see classifySharedConsensusRevert): // ApplicationNotDeployed, ApplicationReverted, @@ -102,6 +104,7 @@ func (s *Service) handleSubmitClaimRevert( app *model.Application, epoch *model.Epoch, ) (submitClaimRevertOutcome, error) { + revertName := submitClaimRevertName(err) switch { case ethutil.IsNonceTooLowError(err): // A transaction with this signer nonce was already mined. This can @@ -119,7 +122,7 @@ func (s *Service) handleSubmitClaimRevert( ) return submitClaimRetryLater, nil - case isCustomConsensusError(err, "NotFirstClaim"): + case revertName == "NotFirstClaim": // Gas estimation runs the call before sending the transaction. With the // default GasLimit == 0, this revert is caught before spending gas. If // GasLimit were set manually, the transaction could revert on chain. @@ -149,7 +152,7 @@ func (s *Service) handleSubmitClaimRevert( ) return submitClaimAlreadyOnChain, nil - case isCustomConsensusError(err, "ApplicationForeclosed"): + case revertName == "ApplicationForeclosed": // EVM reader should record foreclose_block soon. Keep the epoch for now. // Later ticks will see foreclose_block and skip new broadcasts. Read-only // reconciliation can still copy any already-accepted chain state into @@ -162,17 +165,16 @@ func (s *Service) handleSubmitClaimRevert( ) return submitClaimRetryLater, nil - case isCustomConsensusError(err, "InvalidOutputsMerkleRootProofSize"): - stateErr := s.setApplicationCorrupted( - ctx, app, - "submitClaim reverted with InvalidOutputsMerkleRootProofSize for "+ - "epoch %d (%d), last_block %d — tx_buffer_proof in DB is "+ - "the wrong length for the machine memory tree.", - epoch.Index, epoch.VirtualIndex, epoch.LastBlock, + case slices.Contains(machineValidationRevertNames[:], revertName): + stateErr := appstatus.SetFailedf( + ctx, s.Logger, s.repository, app, + "submitClaim rejected the persisted machine state proof with %s for epoch %d (%d), last_block %d. %s", + revertName, epoch.Index, epoch.VirtualIndex, epoch.LastBlock, + machineValidationFailureGuidance(revertName), ) return submitClaimAppHalted, stateErr - case isCustomConsensusError(err, "CallerIsNotValidator"): + case revertName == "CallerIsNotValidator": // Operator configuration error: the signing key is not a Quorum // validator. The operator can fix the key, so use FAILED rather than a // terminal DIVERGED/CORRUPTED status. @@ -183,20 +185,6 @@ func (s *Service) handleSubmitClaimRevert( app.IApplicationAddress, ) return submitClaimAppHalted, stateErr - - case isCustomApplicationError(err, "InvalidNodeIndex"): - // Raised by the on-chain merkle library while replacing the - // outputs-root leaf in the machine memory tree: the proof's shape does - // not fit the tree. Like InvalidOutputsMerkleRootProofSize, this means - // the proof stored locally is bad — local data corruption. - stateErr := s.setApplicationCorrupted( - ctx, app, - "submitClaim reverted with InvalidNodeIndex for "+ - "epoch %d (%d), last_block %d — tx_buffer_proof in DB does "+ - "not form a valid replacement proof for the machine memory tree.", - epoch.Index, epoch.VirtualIndex, epoch.LastBlock, - ) - return submitClaimAppHalted, stateErr } switch action, stateErr := s.classifySharedConsensusRevert(ctx, "submitClaim", err, app, epoch); action { @@ -210,6 +198,51 @@ func (s *Service) handleSubmitClaimRevert( return submitClaimUnknown, nil } +// submitClaimRevertName recognizes only the reverts handled by the submit path. +// An error declared in the ABI but used only by acceptClaim is not a match. +// The handler and Authority owner probe share this side-effect-free recognizer. +func submitClaimRevertName(err error) string { + for _, name := range []string{"NotFirstClaim", "ApplicationForeclosed", "CallerIsNotValidator"} { + if isCustomConsensusError(err, name) { + return name + } + } + if name := machineValidationRevertName(err); name != "" { + return name + } + return sharedConsensusRevertName(err) +} + +var machineValidationRevertNames = [...]string{ + "InvalidSiblingsArrayLength", + "InvalidMachineMerkleProof", + "InvalidPostEpochMachineIflagsYRegister", + "InvalidPostEpochMachineHtifTohostRegister", +} + +func machineValidationRevertName(err error) string { + for _, name := range machineValidationRevertNames { + if isCustomConsensusError(err, name) { + return name + } + } + return "" +} + +// Keep this guidance aligned with PRT's machine-validation diagnostics. The +// classifiers remain separate because the two services use different ABIs. +func machineValidationFailureGuidance(name string) string { + switch name { + case "InvalidPostEpochMachineIflagsYRegister", "InvalidPostEpochMachineHtifTohostRegister": + return "The proven post-epoch machine state cannot finalize. " + + "Check the selected post-epoch state, stored data, and node and contract versions. " + + "If this state is unrecoverable, consider guardian foreclosure to permit emergency withdrawals and deposit refunds." + default: + return "The node validated the proof when it collected it. " + + "Check proof serialization, stored proof data, and node and contract versions before re-enabling." + } +} + // sharedRevertAction tells the caller how to map a revert that submitClaim // and acceptClaim have in common onto its own outcome type. type sharedRevertAction int @@ -228,6 +261,21 @@ const ( sharedRevertRetryLater ) +func sharedConsensusRevertName(err error) string { + for _, name := range []string{ + "ApplicationNotDeployed", + "ApplicationReverted", + "IllformedApplicationReturnData", + "NotEpochFinalBlock", + "NotPastBlock", + } { + if isCustomConsensusError(err, name) { + return name + } + } + return "" +} + // classifySharedConsensusRevert handles the reverts that submitClaim and // acceptClaim have in common. Both methods run the application foreclosure // probe — ApplicationNotDeployed, ApplicationReverted, and @@ -241,8 +289,8 @@ func (s *Service) classifySharedConsensusRevert( app *model.Application, epoch *model.Epoch, ) (sharedRevertAction, error) { - switch { - case isCustomConsensusError(err, "ApplicationNotDeployed"): + switch sharedConsensusRevertName(err) { + case "ApplicationNotDeployed": // The consensus contract found no code at the application address — // the common wrong-address case, which the contract reports separately // from ApplicationReverted. A provider simulating against a block just @@ -258,7 +306,7 @@ func (s *Service) classifySharedConsensusRevert( ) return sharedRevertAppHalted, stateErr - case isCustomConsensusError(err, "ApplicationReverted"): + case "ApplicationReverted": // Not a divergence (no on-chain claim disagrees with ours) and not // local DB corruption — operator action can fix it, so FAILED. The // application's own revert data is the only evidence of why the call @@ -272,11 +320,11 @@ func (s *Service) classifySharedConsensusRevert( "contract and its compatibility with the consensus contract "+ "before re-enabling.%s", call, app.IApplicationAddress, epoch.Index, epoch.VirtualIndex, epoch.LastBlock, - appReturnDataSuffix(err, "ApplicationReverted"), + ethutil.ApplicationReturnDataSuffix(err, iconsensus.IConsensusMetaData, "ApplicationReverted"), ) return sharedRevertAppHalted, stateErr - case isCustomConsensusError(err, "IllformedApplicationReturnData"): + case "IllformedApplicationReturnData": stateErr := appstatus.SetFailedf(ctx, s.Logger, s.repository, app, "%s reverted with IllformedApplicationReturnData for app %s, "+ "epoch %d (%d), last_block %d: the application contract returned "+ @@ -284,11 +332,11 @@ func (s *Service) classifySharedConsensusRevert( "the deployed contract and its compatibility with the consensus "+ "contract before re-enabling.%s", call, app.IApplicationAddress, epoch.Index, epoch.VirtualIndex, epoch.LastBlock, - appReturnDataSuffix(err, "IllformedApplicationReturnData"), + ethutil.ApplicationReturnDataSuffix(err, iconsensus.IConsensusMetaData, "IllformedApplicationReturnData"), ) return sharedRevertAppHalted, stateErr - case isCustomConsensusError(err, "NotEpochFinalBlock"): + case "NotEpochFinalBlock": stateErr := appstatus.SetFailedf(ctx, s.Logger, s.repository, app, "%s reverted with NotEpochFinalBlock for app %s, "+ "epoch %d (%d), last_block %d: the node submitted a "+ @@ -300,7 +348,7 @@ func (s *Service) classifySharedConsensusRevert( ) return sharedRevertAppHalted, stateErr - case isCustomConsensusError(err, "NotPastBlock"): + case "NotPastBlock": // Can be transient: the RPC provider may simulate the call against a // block newer or different from the block used by this tick's pinned // reads. It is permanent when last_block is beyond the real chain head @@ -477,36 +525,8 @@ func decodeClaimNotStagedStatus(err error) (uint8, bool) { if !ok || len(values) < 4 { return 0, false } - // Use reflection instead of a direct `.(uint8)` cast. abigen returns uint8 - // today, but a future version may return a named uint8 type. Checking the - // kind works for both forms. - v := reflect.ValueOf(values[3]) - if !v.IsValid() || v.Kind() != reflect.Uint8 { - return 0, false - } - return uint8(v.Uint()), true -} - -// appReturnDataSuffix formats the application-provided returndata carried by -// ApplicationReverted and IllformedApplicationReturnData reverts as a reason -// suffix: -// -// error ApplicationReverted(address appContract, bytes returndata); -// error IllformedApplicationReturnData(address appContract, bytes returndata); -// -// The bytes are controlled by the application contract, so they are -// hex-encoded rather than string-decoded to keep them inert in logs and in -// the database. Returns "" when the revert data cannot be decoded. -func appReturnDataSuffix(err error, name string) string { - values, ok := unpackConsensusRevert(err, name) - if !ok || len(values) < 2 { - return "" - } - data, ok := values[1].([]byte) - if !ok { - return "" - } - return fmt.Sprintf(" Application return data: 0x%x.", data) + status, ok := values[3].(uint8) + return status, ok } // decodeNotPastBlockBounds reads the arguments from a NotPastBlock revert: diff --git a/internal/claimer/reverts_test.go b/internal/claimer/reverts_test.go index 1a30f0a2b..fac98656d 100644 --- a/internal/claimer/reverts_test.go +++ b/internal/claimer/reverts_test.go @@ -19,6 +19,14 @@ import ( "github.com/stretchr/testify/mock" ) +const ( + executionRevertedErrorMessage = "execution reverted" + notEpochFinalBlockRevert = "NotEpochFinalBlock" + applicationNotDeployedRevert = "ApplicationNotDeployed" + applicationRevertedRevert = "ApplicationReverted" + illformedApplicationReturnDataRevert = "IllformedApplicationReturnData" +) + func TestDecodeClaimNotStagedStatus(t *testing.T) { t.Run("ValidStatuses", func(t *testing.T) { for _, s := range []uint8{0, 1, 2, 3} { @@ -40,13 +48,13 @@ func TestDecodeClaimNotStagedStatus(t *testing.T) { }) t.Run("EmptyPayload", func(t *testing.T) { - e := &rpcDataError{code: 3, msg: "execution reverted", data: "0x"} + e := &rpcDataError{code: 3, msg: executionRevertedErrorMessage, data: "0x"} _, ok := decodeClaimNotStagedStatus(e) assert.False(t, ok) }) t.Run("PayloadShorterThanSelector", func(t *testing.T) { - e := &rpcDataError{code: 3, msg: "execution reverted", data: "0xabcd"} + e := &rpcDataError{code: 3, msg: executionRevertedErrorMessage, data: "0xabcd"} _, ok := decodeClaimNotStagedStatus(e) assert.False(t, ok) }) @@ -54,7 +62,7 @@ func TestDecodeClaimNotStagedStatus(t *testing.T) { t.Run("WrongSelector", func(t *testing.T) { e := &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, // Valid 132-byte payload, but selector is for a different error. data: "0xdeadbeef" + strings.Repeat("00", 128), } @@ -70,7 +78,7 @@ func TestDecodeClaimNotStagedStatus(t *testing.T) { payload := append(append([]byte{}, abiErr.ID[:4]...), make([]byte, 32)...) e := &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", payload), } _, ok := decodeClaimNotStagedStatus(e) @@ -93,7 +101,7 @@ func TestNotFirstClaimHandledGracefully(t *testing.T) { expectPreSubmitPath(b, app, currEpoch, endBlock) // submitClaim reverts with NotFirstClaim (caught by eth_estimateGas). - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, notFirstClaimError()).Once() _, err := m.submitClaimsAndUpdateDatabase( @@ -118,7 +126,7 @@ func TestNotFirstClaimQuorumRetriesForEventSync(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, notFirstClaimError()).Once() _, err := m.submitClaimsAndUpdateDatabase( @@ -142,7 +150,7 @@ func TestApplicationForeclosedIsTransient(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, consensusRevertError("ApplicationForeclosed")).Once() currEpochs := makeEpochMap(currEpoch) @@ -154,30 +162,52 @@ func TestApplicationForeclosedIsTransient(t *testing.T) { assert.Equal(t, 0, len(m.claimsInFlight), "no claim in flight") } -// TestInvalidOutputsMerkleRootProofSizeSetsCorrupted verifies that a -// proof-size revert is treated as local data corruption — the app moves -// to CORRUPTED. -func TestInvalidOutputsMerkleRootProofSizeSetsCorrupted(t *testing.T) { - m, r, b := newServiceMock(t) - defer r.AssertExpectations(t) - defer b.AssertExpectations(t) +func TestMachineValidationRevertsSetFailed(t *testing.T) { + for _, test := range []struct { + name string + register bool + }{ + {name: "InvalidSiblingsArrayLength"}, + {name: "InvalidMachineMerkleProof"}, + {name: "InvalidPostEpochMachineIflagsYRegister", register: true}, + {name: "InvalidPostEpochMachineHtifTohostRegister", register: true}, + } { + t.Run(test.name, func(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) - endBlock := big.NewInt(40) - app := makeApplication() - currEpoch := makeComputedEpoch(app, 3) + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) - expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). - Return(common.Hash{}, consensusRevertError("InvalidOutputsMerkleRootProofSize")).Once() - r.On("UpdateApplicationStatus", mock.Anything, int64(0), model.ApplicationStatus_Corrupted, mock.Anything). - Return(nil).Once() + expectPreSubmitPath(b, app, currEpoch, endBlock) + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). + Return(common.Hash{}, consensusRevertError(test.name)).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + if reason == nil || !strings.Contains(*reason, test.name) || + !strings.Contains(*reason, "node and contract versions") { + return false + } + if test.register { + return strings.Contains(*reason, "proven post-epoch machine state cannot finalize") && + strings.Contains(*reason, "selected post-epoch state") && + strings.Contains(*reason, "If this state is unrecoverable, consider guardian foreclosure") + } + return strings.Contains(*reason, "proof serialization, stored proof data") && + !strings.Contains(*reason, "guardian foreclosure") + })).Return(nil).Once() - currEpochs := makeEpochMap(currEpoch) - _, err := m.submitClaimsAndUpdateDatabase( - context.Background(), makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) - assert.Error(t, err, "CORRUPTED transition must surface a terminal error") - assert.Equal(t, 0, len(currEpochs), "epoch must be dropped from work map") - assert.Equal(t, 0, len(m.claimsInFlight)) + currEpochs := makeEpochMap(currEpoch) + _, err := m.submitClaimsAndUpdateDatabase( + t.Context(), makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + assert.NoError(t, err, "a successful FAILED transition does not surface an error") + assert.Empty(t, currEpochs, "epoch must be dropped from work map") + assert.Empty(t, m.claimsInFlight) + assert.Equal(t, model.ApplicationStatus_Failed, app.Status) + }) + } } // TestCallerIsNotValidatorSetsFailed verifies that a Quorum membership @@ -194,7 +224,7 @@ func TestCallerIsNotValidatorSetsFailed(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, consensusRevertError("CallerIsNotValidator")).Once() r.On("UpdateApplicationStatus", mock.Anything, int64(0), model.ApplicationStatus_Failed, mock.Anything). Return(nil).Once() @@ -224,7 +254,7 @@ func TestNotPastBlockRetriesLater(t *testing.T) { expectPreSubmitPath(b, app, currEpoch, endBlock) // The revert carries the contract's (lastProcessedBlockNumber, upperBound) // arguments, exercising the bounds decode in the warn path. - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, notPastBlockError(currEpoch.LastBlock, currEpoch.LastBlock-1)).Once() currEpochs := makeEpochMap(currEpoch) @@ -252,22 +282,22 @@ func TestSubmitClaimRevertsSetApplicationFailed(t *testing.T) { extraReason []string }{ { - revertName: "ApplicationReverted", - err: appRevertDataError("ApplicationReverted", []byte{0xde, 0xad, 0xbe, 0xef}), + revertName: applicationRevertedRevert, + err: appRevertDataError(applicationRevertedRevert, []byte{0xde, 0xad, 0xbe, 0xef}), extraReason: []string{"Application return data: 0xdeadbeef"}, }, { - revertName: "IllformedApplicationReturnData", - err: appRevertDataError("IllformedApplicationReturnData", []byte{0x01, 0x02}), + revertName: illformedApplicationReturnDataRevert, + err: appRevertDataError(illformedApplicationReturnDataRevert, []byte{0x01, 0x02}), extraReason: []string{"Application return data: 0x0102"}, }, { - revertName: "NotEpochFinalBlock", - err: consensusRevertError("NotEpochFinalBlock"), + revertName: notEpochFinalBlockRevert, + err: consensusRevertError(notEpochFinalBlockRevert), }, { - revertName: "ApplicationNotDeployed", - err: consensusRevertError("ApplicationNotDeployed"), + revertName: applicationNotDeployedRevert, + err: consensusRevertError(applicationNotDeployedRevert), }, } for _, tc := range cases { @@ -281,7 +311,7 @@ func TestSubmitClaimRevertsSetApplicationFailed(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, tc.err).Once() r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.MatchedBy(func(reason *string) bool { @@ -330,8 +360,8 @@ func TestSubmitClaimFailedRevertWithDBError(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). - Return(common.Hash{}, consensusRevertError("ApplicationReverted")).Once() + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). + Return(common.Hash{}, consensusRevertError(applicationRevertedRevert)).Once() r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). Return(fmt.Errorf("db down")).Once() @@ -438,7 +468,7 @@ func TestDecodeNotPastBlockBounds(t *testing.T) { }) t.Run("WrongSelector", func(t *testing.T) { - _, _, ok := decodeNotPastBlockBounds(consensusRevertError("NotEpochFinalBlock")) + _, _, ok := decodeNotPastBlockBounds(consensusRevertError(notEpochFinalBlockRevert)) assert.False(t, ok) }) } @@ -472,9 +502,9 @@ func TestClaimNotStagedUnmodeledStatusFailsClosed(t *testing.T) { // FAILED with a reason naming acceptClaim and carrying operational context. func TestAcceptClaimRevertsSetApplicationFailed(t *testing.T) { for _, revertName := range []string{ - "ApplicationNotDeployed", - "ApplicationReverted", - "IllformedApplicationReturnData", + applicationNotDeployedRevert, + applicationRevertedRevert, + illformedApplicationReturnDataRevert, "NotEpochFinalBlock", } { t.Run(revertName, func(t *testing.T) { @@ -512,29 +542,11 @@ func TestAcceptClaimRevertsSetApplicationFailed(t *testing.T) { } } -// TestInvalidNodeIndexSetsCorrupted verifies that a submitClaim revert from -// the on-chain merkle library is treated as local data corruption: the stored -// outputs_merkle_proof does not form a valid machine-tree replacement proof, -// so the app moves to CORRUPTED. -func TestInvalidNodeIndexSetsCorrupted(t *testing.T) { - m, r, _ := newServiceMock(t) - defer r.AssertExpectations(t) - app := makeApplication() - epoch := makeComputedEpoch(app, 3) - - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, mock.Anything). - Return(nil).Once() - - outcome, stateErr := m.handleSubmitClaimRevert(context.Background(), consensusRevertError("InvalidNodeIndex"), app, epoch) - assert.Equal(t, submitClaimAppHalted, outcome) - assert.Error(t, stateErr, "CORRUPTED is terminal; the handler must return the reason error") -} - // TestHandleSubmitClaimRevert — dispatch matrix for the non-mutating typed // reverts handleSubmitClaimRevert recognises plus the JSON-RPC // "nonce too low" broadcast rejection. The classifier mutates state only -// for the AppHalted outcomes (InvalidOutputsMerkleRootProofSize, -// InvalidNodeIndex, CallerIsNotValidator, ApplicationNotDeployed, +// for the AppHalted outcomes (the four machine-validation errors, +// CallerIsNotValidator, ApplicationNotDeployed, // ApplicationReverted, IllformedApplicationReturnData, NotEpochFinalBlock); // those paths are covered by the end-to-end submit pipeline tests above and // the direct dispatch tests, with repository expectations for the status diff --git a/internal/claimer/service.go b/internal/claimer/service.go index f88b01855..3bbe6df79 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -69,11 +69,7 @@ const defaultMaxAcceptAttempts uint64 = 5 const ClaimerConfigKey = "claimer" -type PersistentConfig struct { - DefaultBlock model.DefaultBlock - ClaimSubmissionEnabled bool - ChainID uint64 -} +type PersistentConfig = config.PersistentSubmitterConfig func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { var err error @@ -122,22 +118,16 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro } } - nodeConfig, err := setupPersistentConfig(ctx, s.Logger, c.Repository, &c.Config) - if err != nil { - return nil, fmt.Errorf("setting up persistent config: %w", err) - } - - chainId, err := ethClient.ChainID(ctx) + chainID, err := ethClient.ChainID(ctx) if err != nil { return nil, fmt.Errorf("querying chain ID: %w", err) } - if chainId.Uint64() != c.Config.BlockchainId { - return nil, fmt.Errorf("chainId mismatch: network %d != provided %d", chainId.Uint64(), c.Config.BlockchainId) + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err } - - if chainId.Uint64() != nodeConfig.ChainID { - return nil, fmt.Errorf("NodeConfig chainId mismatch: network %d != config %d", - chainId.Uint64(), nodeConfig.ChainID) + nodeConfig, err := setupPersistentConfig(ctx, s.Logger, c.Repository, &c.Config) + if err != nil { + return nil, fmt.Errorf("setting up persistent config: %w", err) } s.submissionEnabled = nodeConfig.ClaimSubmissionEnabled s.claimsInFlight = map[int64]inFlightTx{} @@ -154,10 +144,13 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro if s.submissionTimeout == 0 { return nil, fmt.Errorf("BlockchainHttpRequestTimeout must be different from zero") } - txOptsFactory, err = auth.GetTransactOptsFactory(ctx, chainId) + txOptsFactory, err = auth.GetTransactOptsFactory(ctx, chainID) if err != nil { return nil, fmt.Errorf("getting transaction options: %w", err) } + if c.Config.BlockchainLegacyEnabled { + txOptsFactory = ethutil.WithLegacyFees(txOptsFactory, ethClient) + } s.Logger.Info("Claim submitter identity", "address", txOptsFactory.From()) } @@ -180,24 +173,27 @@ func setupPersistentConfig( repo iclaimerRepository, c *config.ClaimerConfig, ) (*PersistentConfig, error) { + requested := PersistentConfig{ + DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId, + ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, + } + if err := requested.Validate(); err != nil { + return nil, fmt.Errorf("invalid claimer config: %w", err) + } config, err := repository.LoadNodeConfig[PersistentConfig](ctx, repo, ClaimerConfigKey) if config == nil && errors.Is(err, repository.ErrNotFound) { nc := model.NodeConfig[PersistentConfig]{ - Key: ClaimerConfigKey, - Value: PersistentConfig{ - DefaultBlock: c.BlockchainDefaultBlock, - ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, - ChainID: c.BlockchainId, - }, + Key: ClaimerConfigKey, + Value: requested, } logger.Info("Initializing claimer persistent config", "config", nc.Value) - err = repository.SaveNodeConfig(ctx, repo, &nc) - if err != nil { - return nil, fmt.Errorf("saving claimer persistent config: %w", err) + config, err = repository.InitializeNodeConfig(ctx, repo, &nc) + } + if err == nil { + if err := config.Value.CheckRequested(requested); err != nil { + return nil, fmt.Errorf("claimer persistent config: %w", err) } - return &nc.Value, nil - } else if err == nil { - logger.Info("Claimer was already configured. Using previous persistent config", "config", config.Value) + logger.Info("Claimer persistent config matches requested config", "config", config.Value) return &config.Value, nil } diff --git a/internal/claimer/service_test.go b/internal/claimer/service_test.go index a085419f0..ba5e2c513 100644 --- a/internal/claimer/service_test.go +++ b/internal/claimer/service_test.go @@ -6,18 +6,105 @@ package claimer import ( "context" "encoding/json" + "fmt" "testing" "time" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -func TestCreateUsesPersistedDefaultBlock(t *testing.T) { +func TestSetupPersistentConfigChecksInitializedValue(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, concurrent := range []bool{false, true} { + t.Run(fmt.Sprintf("enabled=%t/concurrent=%t", enabled, concurrent), func(t *testing.T) { + s, repo, _ := newServiceMock(t) + requested := PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: enabled, + } + raw, err := json.Marshal(requested) + require.NoError(t, err) + stored := requested + if concurrent { + stored.ClaimSubmissionEnabled = !enabled + } + storedRaw, err := json.Marshal(stored) + require.NoError(t, err) + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey). + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound).Once() + repo.On("InitializeNodeConfigRaw", mock.Anything, ClaimerConfigKey, raw).Return(nil).Once() + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey). + Return(storedRaw, time.Time{}, time.Time{}, nil).Once() + got, err := setupPersistentConfig(t.Context(), s.Logger, repo, &config.ClaimerConfig{ + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: enabled, + }) + if concurrent { + require.Nil(t, got) + require.ErrorContains(t, err, "claim submission mode mismatch") + } else { + require.NoError(t, err) + require.Equal(t, requested, *got) + } + repo.AssertNumberOfCalls(t, "SaveNodeConfigRaw", 0) + repo.AssertExpectations(t) + }) + } + } +} + +func TestCreateRejectsConfigBeforeSignerResolution(t *testing.T) { + // Invalid auth must not hide a saved-config error or resolve a signing key. + viper.Set(config.AUTH_KIND, "invalid") + t.Cleanup(func() { viper.Set(config.AUTH_KIND, nil) }) + for _, test := range []struct { + name string + raw string + requestedEnabled bool + want string + }{ + {"chain mismatch", `{"ChainID":2,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, true, + "chain ID mismatch: database=2, configured=42"}, + {"policy mismatch", `{"ChainID":42,"DefaultBlock":"LATEST","ClaimSubmissionEnabled":true}`, true, + "observation policy mismatch: database=LATEST, configured=FINALIZED"}, + {"enable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":false}`, true, + "claim submission mode mismatch: database=false, configured=true"}, + {"disable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, false, + "claim submission mode mismatch: database=true, configured=false"}, + {"missing mode", `{"ChainID":42,"DefaultBlock":"FINALIZED"}`, true, "non-null ClaimSubmissionEnabled"}, + {"null mode", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":null}`, true, + "non-null ClaimSubmissionEnabled"}, + {"missing chain", `{"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, true, "non-null DefaultBlock and ChainID"}, + {"invalid policy", `{"ChainID":42,"DefaultBlock":"invalid","ClaimSubmissionEnabled":true}`, true, "invalid DefaultBlock"}, + } { + t.Run(test.name, func(t *testing.T) { + repo := &claimerCreateRepositoryMock{} + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey). + Return([]byte(test.raw), time.Time{}, time.Time{}, nil).Once() + info := &CreateInfo{ + Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: test.requestedEnabled, BlockchainHttpRequestTimeout: time.Second, + }, + EthConn: newTestEthClient(t, 42), Repository: repo, + } + svc, err := Create(t.Context(), info) + require.Nil(t, svc) + require.ErrorContains(t, err, test.want) + repo.AssertNumberOfCalls(t, "SaveNodeConfigRaw", 0) + repo.AssertExpectations(t) + }) + } +} + +func TestCreateUsesMatchingPersistedDefaultBlock(t *testing.T) { persistedConfig := PersistentConfig{ DefaultBlock: model.DefaultBlock_Latest, ClaimSubmissionEnabled: false, @@ -33,9 +120,9 @@ func TestCreateUsesPersistedDefaultBlock(t *testing.T) { s, err := Create(context.Background(), &CreateInfo{ Config: config.ClaimerConfig{ ClaimerPollingInterval: time.Hour, - BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainDefaultBlock: model.DefaultBlock_Latest, BlockchainId: 42, - FeatureClaimSubmissionEnabled: true, + FeatureClaimSubmissionEnabled: false, }, EthConn: newTestEthClient(t, 42), Repository: repo, diff --git a/internal/claimer/state_proof.go b/internal/claimer/state_proof.go new file mode 100644 index 000000000..df0a4b342 --- /dev/null +++ b/internal/claimer/state_proof.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" +) + +func consensusMachineValidityProof(proof model.StateProof) iconsensus.MachineValidityProof { + return iconsensus.MachineValidityProof{ + IflagsYProof: iconsensus.LeafProof{ + DataBlock: proof.IflagsYDataBlock, + Siblings: append([][32]byte(nil), proof.IflagsYProof...), + }, + HtifTohostProof: iconsensus.LeafProof{ + DataBlock: proof.HtifTohostDataBlock, + Siblings: append([][32]byte(nil), proof.HtifTohostProof...), + }, + TxBufferProof: iconsensus.LeafProof{ + DataBlock: proof.TxBufferDataBlock, + Siblings: append([][32]byte(nil), proof.TxBufferProof...), + }, + } +} diff --git a/internal/claimer/state_proof_test.go b/internal/claimer/state_proof_test.go new file mode 100644 index 000000000..fa6ee8825 --- /dev/null +++ b/internal/claimer/state_proof_test.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/repository/repotest" + "io" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestConsensusMachineValidityProofUsesContractFieldOrder(t *testing.T) { + canonical := repotest.KeccakStateProof(common.HexToHash("0xa1")) + + wire := consensusMachineValidityProof(canonical) + require.Equal(t, [32]byte(canonical.IflagsYDataBlock), wire.IflagsYProof.DataBlock) + require.Equal(t, canonical.IflagsYProof, wire.IflagsYProof.Siblings) + require.Equal(t, [32]byte(canonical.HtifTohostDataBlock), wire.HtifTohostProof.DataBlock) + require.Equal(t, canonical.HtifTohostProof, wire.HtifTohostProof.Siblings) + require.Equal(t, [32]byte(canonical.TxBufferDataBlock), wire.TxBufferProof.DataBlock) + require.Equal(t, canonical.TxBufferProof, wire.TxBufferProof.Siblings) + + wire.IflagsYProof.Siblings[0][0] ^= 0xff + require.NotEqual(t, canonical.IflagsYProof[0], wire.IflagsYProof.Siblings[0], + "the contract proof must own its sibling storage") +} + +func TestSubmitClaimUsesMachineRootAndOutputsRoot(t *testing.T) { + app := makeApplication() + epoch := makeComputedEpoch(app, 3) + proof, err := epoch.StateProof() + require.NoError(t, err) + capture := &claimSubmitterCapture{} + blockchain := &claimerBlockchain{ + txOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{}), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + txHash, err := blockchain.submitClaimToBlockchain(t.Context(), capture, app, epoch, proof) + require.NoError(t, err) + require.Equal(t, capture.tx.Hash(), txHash) + require.Equal(t, app.IApplicationAddress, capture.appContract) + require.Equal(t, new(big.Int).SetUint64(epoch.LastBlock), capture.lastProcessedBlockNumber) + require.Equal(t, [32]byte(proof.MachineHash), capture.machineMerkleRoot) + require.Equal(t, [32]byte(proof.IflagsYDataBlock), capture.proof.IflagsYProof.DataBlock) + require.Equal(t, proof.IflagsYProof, capture.proof.IflagsYProof.Siblings) + require.Equal(t, [32]byte(proof.HtifTohostDataBlock), capture.proof.HtifTohostProof.DataBlock) + require.Equal(t, proof.HtifTohostProof, capture.proof.HtifTohostProof.Siblings) + require.Equal(t, [32]byte(proof.TxBufferDataBlock), capture.proof.TxBufferProof.DataBlock) + require.Equal(t, proof.TxBufferProof, capture.proof.TxBufferProof.Siblings) +} + +type claimSubmitterCapture struct { + tx *types.Transaction + appContract common.Address + lastProcessedBlockNumber *big.Int + machineMerkleRoot [32]byte + proof iconsensus.MachineValidityProof +} + +func (c *claimSubmitterCapture) SubmitClaim( + _ *bind.TransactOpts, + appContract common.Address, + lastProcessedBlockNumber *big.Int, + machineMerkleRoot [32]byte, + proof iconsensus.MachineValidityProof, +) (*types.Transaction, error) { + c.tx = types.NewTx(&types.LegacyTx{Nonce: 7}) + c.appContract = appContract + c.lastProcessedBlockNumber = new(big.Int).Set(lastProcessedBlockNumber) + c.machineMerkleRoot = machineMerkleRoot + c.proof = proof + return c.tx, nil +} diff --git a/internal/claimer/submit.go b/internal/claimer/submit.go index adea78c46..dbf01460c 100644 --- a/internal/claimer/submit.go +++ b/internal/claimer/submit.go @@ -427,6 +427,19 @@ func (s *Service) broadcastComputedClaim( currEpoch *model.Epoch, defaultBlockNumber *big.Int, ) claimStepResult { + proof, err := currEpoch.StateProof() + if err != nil { + stateErr := s.setApplicationCorrupted( + ctx, + app, + "cannot submit claim for epoch %d (%d), last_block %d: persisted machine state proof is incomplete: %v", + currEpoch.Index, + currEpoch.VirtualIndex, + currEpoch.LastBlock, + err, + ) + return claimDropped(stateErr) + } s.Logger.Debug("Submitting claim to blockchain", "app", app.IApplicationAddress, "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), @@ -434,7 +447,7 @@ func (s *Service) broadcastComputedClaim( ) txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) defer cancel() - txHash, err := s.blockchain.submitClaimToBlockchain(txCtx, ic, app, currEpoch) + txHash, err := s.blockchain.submitClaimToBlockchain(txCtx, ic, app, currEpoch, proof) if err != nil { switch outcome, stateErr := s.handleSubmitClaimRevert(ctx, err, app, currEpoch); outcome { case submitClaimAlreadyOnChain: diff --git a/internal/claimer/submit_test.go b/internal/claimer/submit_test.go index be456773c..200ccc61b 100644 --- a/internal/claimer/submit_test.go +++ b/internal/claimer/submit_test.go @@ -6,6 +6,7 @@ package claimer import ( "context" "math/big" + "strings" "testing" "time" @@ -36,7 +37,7 @@ func TestSubmitFirstClaim(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.HexToHash("0x10"), nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -45,10 +46,64 @@ func TestSubmitFirstClaim(t *testing.T) { assert.Equal(t, 1, transitions, "submitting a claim counts as a transition") } -// withForeclosed returns a copy of app with ForecloseBlock / ForecloseTransaction -// populated, matching the in-memory state evmreader leaves behind after -// checkForForeclosure has run on a foreclosed application. +func TestSubmitClaimRejectsIncompletePersistedStateProofBeforeBroadcast(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + currEpoch.HtifTohostProof = currEpoch.HtifTohostProof[:model.StateProofSiblingCount-1] + expectPreSubmitPath(b, app, currEpoch, endBlock) + // No submitClaimToBlockchain expectation is registered. An attempted + // broadcast makes the mock fail this test. + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "persisted machine state proof is incomplete") + })).Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + transitions, err := m.submitClaimsAndUpdateDatabase( + t.Context(), makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + require.Zero(t, transitions) + require.ErrorContains(t, err, "persisted machine state proof is incomplete") + require.Empty(t, currEpochs) + require.Empty(t, m.claimsInFlight) + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) +} + +func TestSubmitClaimDefersCompleteProofValidationToContract(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + currEpoch.IflagsYProof[0][0] ^= 0xff + corruptSibling := currEpoch.IflagsYProof[0] + expectPreSubmitPath(b, app, currEpoch, endBlock) + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, + mock.MatchedBy(func(proof model.StateProof) bool { + return proof.IflagsYProof[0] == corruptSibling + })).Return(common.Hash{}, consensusRevertError("InvalidMachineMerkleProof")).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "proof serialization, stored proof data") + })).Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + _, err := m.submitClaimsAndUpdateDatabase( + t.Context(), makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + require.NoError(t, err) + require.Empty(t, currEpochs) + require.Empty(t, m.claimsInFlight) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) +} +// TestSubmitClaimForeclosesUnstagedForeclosedApp checks that reconciliation +// continues after foreclosure but no new claim is broadcast. func TestSubmitClaimForeclosesUnstagedForeclosedApp(t *testing.T) { m, r, b := newServiceMock(t) defer r.AssertExpectations(t) @@ -145,7 +200,7 @@ func TestSubmitClaimForecloseMidFlight(t *testing.T) { Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, epochN, endBlock) tick1TxHash := common.HexToHash("0xa1") - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, epochN). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, epochN, mock.Anything). Return(tick1TxHash, nil).Once() transitions1, errs1 := m.submitClaimsAndUpdateDatabase( @@ -304,7 +359,7 @@ func TestSubmitClaimWithAntecessor(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.HexToHash("0x10"), nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -332,7 +387,7 @@ func TestSubmitClaimWithAcceptedAntecessorWithoutClaimTransactionHash(t *testing b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{prevEvent, currEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.HexToHash("0x10"), nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase( @@ -520,7 +575,7 @@ func TestQuorumDifferentOutputSubmittedEventStillSubmitsLocalClaim(t *testing.T) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(txHash, nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -548,7 +603,7 @@ func TestQuorumForeignMatchingSubmittedEventStillSubmitsLocalClaim(t *testing.T) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(txHash, nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -615,7 +670,7 @@ func TestQuorumSubmittedEventsIgnoresForeignAdversarialProofAndSubmitsLocalClaim b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent, adversarialEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(txHash, nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -779,7 +834,7 @@ func TestQuorumPreviousSubmittedEventsIgnoresForeignMismatchAndSubmitsCurrentCla b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignPrevEvent, matchingPrevEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(txHash, nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -951,7 +1006,7 @@ func TestSubmitClaimTimeout(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Run(func(args mock.Arguments) { ctx := args.Get(0).(context.Context) select { @@ -990,7 +1045,7 @@ func TestSubmitClaimContextCanceled(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Run(func(args mock.Arguments) { ctx := args.Get(0).(context.Context) select { From 28afeebe472b6875b5b0dbfb168fea008ebd1269 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:18 -0300 Subject: [PATCH 31/41] fix(claimer): detect authority signer mismatch For an otherwise-unclassified Authority submission revert, read the owner at the configured block and at latest. Preserve the original submission error with the owner evidence. If owner views differ, wait for confirmation. If both views agree and exclude the configured signer, report a recoverable configuration failure. Known contract and proof errors keep their more specific diagnosis. Add real RPC-path ownership-transfer and mismatch tests. --- internal/claimer/authority_owner_test.go | 283 +++++++++++++++++++++++ internal/claimer/blockchain.go | 62 +++++ internal/claimer/reverts.go | 17 +- 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 internal/claimer/authority_owner_test.go diff --git a/internal/claimer/authority_owner_test.go b/internal/claimer/authority_owner_test.go new file mode 100644 index 000000000..f820eecbd --- /dev/null +++ b/internal/claimer/authority_owner_test.go @@ -0,0 +1,283 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iauthority" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func TestAuthoritySubmissionOwnerDiagnosis(t *testing.T) { + signer := common.HexToAddress("0x1") + other := common.HexToAddress("0x2") + revert := &rpcDataError{code: 3, msg: "execution reverted: original failure", data: "0xdeadbeef"} + transportErr := errors.New("RPC transport failed") + noDataErr := &rpcDataError{code: 3, msg: "submission reverted without data"} + + for _, test := range []struct { + name string + consensus model.Consensus + submissionErr error + configuredOwner common.Address + latestOwner common.Address + wantReads bool + wantOutcome submitClaimRevertOutcome + }{ + {name: "wrong agreed owner", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: other, latestOwner: other, wantReads: true, wantOutcome: submitClaimAppHalted}, + {name: "correct owner unrelated revert", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: signer, latestOwner: signer, wantReads: true}, + {name: "ownership transferred away in latest", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: signer, latestOwner: other, wantReads: true, wantOutcome: submitClaimRetryLater}, + {name: "ownership transferred to signer in latest", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: other, latestOwner: signer, wantReads: true, wantOutcome: submitClaimRetryLater}, + {name: "other owner changed between views", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: other, latestOwner: common.HexToAddress("0x3"), wantReads: true, wantOutcome: submitClaimRetryLater}, + {name: "accept-only ClaimNotStaged", consensus: model.Consensus_Authority, + submissionErr: claimNotStagedError(claimStatusAccepted), configuredOwner: other, latestOwner: other, + wantReads: true, wantOutcome: submitClaimAppHalted}, + {name: "accept-only ClaimStagingPeriodNotOverYet", consensus: model.Consensus_Authority, + submissionErr: consensusRevertError("ClaimStagingPeriodNotOverYet"), configuredOwner: other, latestOwner: other, + wantReads: true, wantOutcome: submitClaimAppHalted}, + {name: "nonce rejection with revert data", consensus: model.Consensus_Authority, + submissionErr: &rpcDataError{code: 3, msg: "nonce too low", data: "0xdeadbeef"}, wantOutcome: submitClaimRetryLater}, + {name: "transport failure", consensus: model.Consensus_Authority, submissionErr: transportErr}, + {name: "no revert data", consensus: model.Consensus_Authority, submissionErr: noDataErr}, + {name: "Quorum unchanged", consensus: model.Consensus_Quorum, submissionErr: revert}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo, _ := newServiceMock(t) + app := makeApplication() + app.ConsensusType = test.consensus + epoch := makeComputedEpoch(app, 3) + backend := &authorityOwnerRPC{} + if test.wantReads { + expectAuthorityHeader(backend) + expectAuthorityOwner(t, backend, app.IConsensusAddress, 20, test.configuredOwner, nil) + expectAuthorityOwner(t, backend, app.IConsensusAddress, rpc.LatestBlockNumber, test.latestOwner, nil) + } + blockchain := &claimerBlockchain{ + client: newAuthorityOwnerClient(t, backend), logger: s.Logger, defaultBlock: model.DefaultBlock_Finalized, + txOptsFactory: &reportedSignerFactory{ + TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: signer}), + }, + } + submitter := &revertingClaimSubmitter{err: test.submissionErr} + _, err := blockchain.submitClaimToBlockchain(t.Context(), submitter, app, epoch, model.StateProof{}) + require.ErrorIs(t, err, test.submissionErr) + require.Equal(t, signer, submitter.from) + if test.wantOutcome == submitClaimUnknown { + require.Same(t, test.submissionErr, err, "an unrelated error must remain unchanged") + } + if test.wantOutcome == submitClaimAppHalted { + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, signer.String()) && + strings.Contains(*reason, other.String()) && strings.Contains(*reason, "CARTESI_AUTH_*") && + strings.Contains(*reason, test.submissionErr.Error()) + })).Return(nil).Once() + } + outcome, statusErr := s.handleSubmitClaimRevert(t.Context(), err, app, epoch) + require.NoError(t, statusErr) + require.Equal(t, test.wantOutcome, outcome) + if test.wantOutcome == submitClaimAppHalted { + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + backend.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestAuthorityOwnerReadFailurePreservesSubmissionError(t *testing.T) { + for _, failureAt := range []string{"header", "configured owner", "latest owner"} { + t.Run(failureAt, func(t *testing.T) { + s, repo, _ := newServiceMock(t) + app := makeApplication() + app.ConsensusType = model.Consensus_Authority + backend := &authorityOwnerRPC{} + lookupErr := errors.New("owner RPC unavailable") + if failureAt == "header" { + backend.On("GetBlockByNumber", rpc.FinalizedBlockNumber, false). + Return((*types.Header)(nil), lookupErr).Once() + } else { + expectAuthorityHeader(backend) + if failureAt == "configured owner" { + expectAuthorityOwner(t, backend, app.IConsensusAddress, 20, common.Address{}, lookupErr) + } else { + expectAuthorityOwner(t, backend, app.IConsensusAddress, 20, common.HexToAddress("0x2"), nil) + expectAuthorityOwner(t, backend, app.IConsensusAddress, rpc.LatestBlockNumber, common.Address{}, lookupErr) + } + } + blockchain := &claimerBlockchain{ + client: newAuthorityOwnerClient(t, backend), defaultBlock: model.DefaultBlock_Finalized, + } + original := &rpcDataError{code: 3, msg: "original submit failure", data: "0xdeadbeef"} + err := blockchain.diagnoseAuthorityOwner(t.Context(), app, common.HexToAddress("0x1"), original) + require.ErrorIs(t, err, original) + require.ErrorContains(t, err, lookupErr.Error()) + outcome, statusErr := s.handleSubmitClaimRevert(t.Context(), err, app, makeComputedEpoch(app, 3)) + require.NoError(t, statusErr) + require.Equal(t, submitClaimUnknown, outcome) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + backend.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestAuthorityOwnerDiagnosisPreservesKnownReverts(t *testing.T) { + for _, test := range []struct { + name string + want submitClaimRevertOutcome + }{ + {name: "NotFirstClaim", want: submitClaimAlreadyOnChain}, + {name: "ApplicationForeclosed", want: submitClaimRetryLater}, + {name: "CallerIsNotValidator", want: submitClaimAppHalted}, + {name: "InvalidSiblingsArrayLength", want: submitClaimAppHalted}, + {name: "InvalidMachineMerkleProof", want: submitClaimAppHalted}, + {name: "InvalidPostEpochMachineIflagsYRegister", want: submitClaimAppHalted}, + {name: "InvalidPostEpochMachineHtifTohostRegister", want: submitClaimAppHalted}, + {name: applicationNotDeployedRevert, want: submitClaimAppHalted}, + {name: applicationRevertedRevert, want: submitClaimAppHalted}, + {name: illformedApplicationReturnDataRevert, want: submitClaimAppHalted}, + {name: notEpochFinalBlockRevert, want: submitClaimAppHalted}, + {name: "NotPastBlock", want: submitClaimRetryLater}, + } { + for _, ownerEvidence := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/owner_evidence_%t", test.name, ownerEvidence), func(t *testing.T) { + s, repo, _ := newServiceMock(t) + app := makeApplication() + app.ConsensusType = model.Consensus_Authority + epoch := makeComputedEpoch(app, 3) + signer := common.HexToAddress("0x1") + backend := &authorityOwnerRPC{} + // No RPC expectation: a known submit revert must not query either + // the configured head or the Authority owner. + blockchain := &claimerBlockchain{ + client: newAuthorityOwnerClient(t, backend), logger: s.Logger, defaultBlock: model.DefaultBlock_Finalized, + txOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: signer}), + } + revert := consensusRevertError(test.name) + submitter := &revertingClaimSubmitter{err: fmt.Errorf("estimating submitClaim: %w", revert)} + _, err := blockchain.submitClaimToBlockchain(t.Context(), submitter, app, epoch, model.StateProof{}) + require.ErrorIs(t, err, revert) + require.Same(t, submitter.err, err, "the wrapped submission error must remain unchanged") + require.Empty(t, backend.Calls) + if ownerEvidence { + // Keep known-revert precedence explicit even if owner evidence is + // supplied by another caller. This transfer must not mask a proof error. + err = &authorityOwnerMismatch{signer: signer, configuredOwner: common.HexToAddress("0x2"), + latestOwner: signer, submissionErr: err} + } + if test.want == submitClaimAppHalted { + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, test.name) && !strings.Contains(*reason, "CARTESI_AUTH_*") + })).Return(nil).Once() + } + outcome, statusErr := s.handleSubmitClaimRevert(t.Context(), err, app, epoch) + require.NoError(t, statusErr) + require.Equal(t, test.want, outcome) + if test.want == submitClaimAppHalted { + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + backend.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } + } +} + +// From deliberately differs from the signer returned in the transaction +// options. Owner diagnosis must use the signer on the attempted transaction. +type reportedSignerFactory struct{ ethutil.TransactOptsFactory } + +func (*reportedSignerFactory) From() common.Address { return common.HexToAddress("0xffff") } + +type revertingClaimSubmitter struct { + err error + from common.Address +} + +func (s *revertingClaimSubmitter) SubmitClaim( + opts *bind.TransactOpts, _ common.Address, _ *big.Int, _ [32]byte, _ iconsensus.MachineValidityProof, +) (*types.Transaction, error) { + s.from = opts.From + return nil, s.err +} + +type authorityOwnerRPC struct{ mock.Mock } + +func (m *authorityOwnerRPC) GetBlockByNumber( + _ context.Context, block rpc.BlockNumber, full bool, +) (*types.Header, error) { + args := m.Called(block, full) + header, _ := args.Get(0).(*types.Header) + return header, args.Error(1) +} + +func (m *authorityOwnerRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + owner := args.Get(0).(common.Address) + return common.LeftPadBytes(owner.Bytes(), common.HashLength), args.Error(1) +} + +func newAuthorityOwnerClient(t *testing.T, backend *authorityOwnerRPC) *ethclient.Client { + t.Helper() + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + return client +} + +func expectAuthorityHeader(backend *authorityOwnerRPC) { + backend.On("GetBlockByNumber", rpc.FinalizedBlockNumber, false). + Return(&types.Header{Number: big.NewInt(20), Difficulty: big.NewInt(0), Extra: []byte{}}, nil).Once() +} + +func expectAuthorityOwner( + t *testing.T, backend *authorityOwnerRPC, address common.Address, block rpc.BlockNumber, owner common.Address, err error, +) { + t.Helper() + contractABI, parseErr := iauthority.IAuthorityMetaData.GetAbi() + require.NoError(t, parseErr) + backend.On("Call", address, contractABI.Methods["owner"].ID, block).Return(owner, err).Once() +} diff --git a/internal/claimer/blockchain.go b/internal/claimer/blockchain.go index 74aed4140..b7b91dc60 100644 --- a/internal/claimer/blockchain.go +++ b/internal/claimer/blockchain.go @@ -12,6 +12,7 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iauthority" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/contracts/iquorum" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -144,6 +145,7 @@ func (cb *claimerBlockchain) submitClaimToBlockchain( tx, err := ic.SubmitClaim(txOpts, application.IApplicationAddress, lastBlockNumber, proof.MachineHash, consensusMachineValidityProof(proof)) if err != nil { + err = cb.diagnoseAuthorityOwner(ctx, application, txOpts.From, err) cb.logger.Warn("submitClaimToBlockchain:failed", "appContractAddress", application.IApplicationAddress, "machine_merkle_root", proof.MachineHash, @@ -162,6 +164,66 @@ func (cb *claimerBlockchain) submitClaimToBlockchain( return txHash, err } +// authorityOwnerMismatch carries owner evidence without depending on the +// ownership library's revert ABI. The original submission error is preserved. +type authorityOwnerMismatch struct { + signer common.Address + configuredOwner common.Address + latestOwner common.Address + submissionErr error +} + +func (e *authorityOwnerMismatch) Error() string { + return fmt.Sprintf("Authority owner check: signer %s, configured owner %s, latest owner %s: %v", + e.signer, e.configuredOwner, e.latestOwner, e.submissionErr) +} + +func (e *authorityOwnerMismatch) Unwrap() error { return e.submissionErr } + +func (cb *claimerBlockchain) diagnoseAuthorityOwner( + ctx context.Context, + app *model.Application, + signer common.Address, + submissionErr error, +) error { + if app.ConsensusType != model.Consensus_Authority { + return submissionErr + } + // Known submit errors already have a more specific diagnosis. Keep owner + // reads for otherwise-unclassified reverts, using the actual transaction signer. + if ethutil.IsNonceTooLowError(submissionErr) || submitClaimRevertName(submissionErr) != "" { + return submissionErr + } + if _, reverted := ethclient.RevertErrorData(submissionErr); !reverted { + return submissionErr + } + block, err := cb.getDefaultBlockNumber(ctx) + if err != nil { + return errors.Join(submissionErr, fmt.Errorf("resolving block for Authority owner check: %w", err)) + } + if block == nil || block.Sign() < 0 { + return errors.Join(submissionErr, errors.New("authority owner check returned an invalid configured block")) + } + authority, err := iauthority.NewIAuthorityCaller(app.IConsensusAddress, cb.client) + if err != nil { + return errors.Join(submissionErr, fmt.Errorf("binding Authority for owner check: %w", err)) + } + configuredOwner, err := authority.Owner(&bind.CallOpts{Context: ctx, BlockNumber: block}) + if err != nil { + return errors.Join(submissionErr, fmt.Errorf("reading Authority owner at configured block %s: %w", block, err)) + } + latestOwner, err := authority.Owner(&bind.CallOpts{Context: ctx}) + if err != nil { + return errors.Join(submissionErr, fmt.Errorf("reading latest Authority owner: %w", err)) + } + if configuredOwner == signer && latestOwner == signer { + return submissionErr + } + return &authorityOwnerMismatch{ + signer: signer, configuredOwner: configuredOwner, latestOwner: latestOwner, submissionErr: submissionErr, + } +} + type eventIterator interface { Next() bool Close() error diff --git a/internal/claimer/reverts.go b/internal/claimer/reverts.go index cfe46e21b..9b6fbfe50 100644 --- a/internal/claimer/reverts.go +++ b/internal/claimer/reverts.go @@ -5,6 +5,7 @@ package claimer import ( "context" + "errors" "math/big" "slices" @@ -193,7 +194,21 @@ func (s *Service) handleSubmitClaimRevert( case sharedRevertRetryLater: return submitClaimRetryLater, nil case sharedRevertNoMatch: - // Not a shared revert either; report unknown. + // No known revert matched. Check the Authority owner diagnosis below. + } + var ownerMismatch *authorityOwnerMismatch + if errors.As(err, &ownerMismatch) { + if ownerMismatch.configuredOwner != ownerMismatch.latestOwner { + s.Logger.Warn("Authority owner views differ after claim submission failed; waiting for confirmation", + "app", app.IApplicationAddress, "error", err) + return submitClaimRetryLater, nil + } + stateErr := appstatus.SetFailedf(ctx, s.Logger, s.repository, app, + "The configured signer %s is not the Authority owner %s for consensus %s. "+ + "The configured and latest block views agree. Check CARTESI_AUTH_* against the Authority owner before re-enabling. "+ + "Original submission error: %v", + ownerMismatch.signer, ownerMismatch.configuredOwner, app.IConsensusAddress, ownerMismatch.submissionErr) + return submitClaimAppHalted, stateErr } return submitClaimUnknown, nil } From e87cfa5aef3714d5366a173bf4d0fb57c0be105a Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:18 -0300 Subject: [PATCH 32/41] feat(prt): read v3 consensus and tournaments Read the final DaveConsensus and tournament API through typed adapters. Use shared model enums from the start, preserve exact uint256 values, and reject invalid contract return values. Reads that form a current observation must use one explicit block. Expose descriptors, standing, match phases, clocks, structural event counts, staging conditions, child results, and bond disposition. Keep ABI decoding tests on the real RPC-to-binding-to-adapter path so argument order and field wiring are covered. The next PRT commits introduce final helpers before the service cycle connects them. Shared service fields and test adapters are declared once; no temporary protocol implementation or legacy adapter is added. --- internal/prt/call_opts.go | 15 + internal/prt/call_opts_test.go | 24 + internal/prt/event_filter_test.go | 258 ++++++++ internal/prt/fixture_test.go | 196 ++++--- internal/prt/idaveconsensus_adapter.go | 150 ++++- internal/prt/idaveconsensus_adapter_test.go | 101 ++++ internal/prt/itournament_adapter.go | 620 ++++++++++++++------ internal/prt/prt.go | 14 +- internal/prt/service.go | 25 +- internal/prt/state_proof_test.go | 39 ++ internal/prt/tournament_views_test.go | 385 ++++++++++++ internal/prt/types.go | 180 +++++- 12 files changed, 1684 insertions(+), 323 deletions(-) create mode 100644 internal/prt/call_opts.go create mode 100644 internal/prt/call_opts_test.go create mode 100644 internal/prt/event_filter_test.go create mode 100644 internal/prt/idaveconsensus_adapter_test.go create mode 100644 internal/prt/state_proof_test.go create mode 100644 internal/prt/tournament_views_test.go diff --git a/internal/prt/call_opts.go b/internal/prt/call_opts.go new file mode 100644 index 000000000..2556dca12 --- /dev/null +++ b/internal/prt/call_opts.go @@ -0,0 +1,15 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +func pinnedCallOpts(ctx context.Context, block uint64) *bind.CallOpts { + return &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} +} diff --git a/internal/prt/call_opts_test.go b/internal/prt/call_opts_test.go new file mode 100644 index 000000000..ad543980a --- /dev/null +++ b/internal/prt/call_opts_test.go @@ -0,0 +1,24 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPinnedCallOptsOwnsBlockNumber(t *testing.T) { + ctx := t.Context() + first := pinnedCallOpts(ctx, math.MaxUint64) + second := pinnedCallOpts(ctx, math.MaxUint64) + require.Same(t, ctx, first.Context) + require.Same(t, ctx, second.Context) + require.NotSame(t, first, second) + require.NotSame(t, first.BlockNumber, second.BlockNumber) + require.Equal(t, uint64(math.MaxUint64), first.BlockNumber.Uint64()) + first.BlockNumber.SetUint64(0) + require.Equal(t, uint64(math.MaxUint64), second.BlockNumber.Uint64()) +} diff --git a/internal/prt/event_filter_test.go b/internal/prt/event_filter_test.go new file mode 100644 index 000000000..5c0fe0999 --- /dev/null +++ b/internal/prt/event_filter_test.go @@ -0,0 +1,258 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "io" + "log/slog" + "maps" + "math/big" + "slices" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +// Keep expected event names independent of the production filter list. +const ( + testCommitmentJoinedEvent = "CommitmentJoined" + testLeafMatchSealedEvent = "LeafMatchSealed" +) + +func TestAllTournamentEventsFilter(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + end := uint64(20) + query, err := buildAllEventsFilterQuery(&bind.FilterOpts{Start: 10, End: &end}, address, contractABI) + require.NoError(t, err) + require.Equal(t, []common.Address{address}, query.Addresses) + require.Equal(t, uint64(10), query.FromBlock.Uint64()) + require.Equal(t, end, query.ToBlock.Uint64()) + require.Len(t, query.Topics, 1) + names := []string{testCommitmentJoinedEvent, "MatchAdvanced", "MatchCreated", "MatchDeleted", + "NewInnerTournament", testLeafMatchSealedEvent, "PartialBondRefund", "BondRecovered"} + expected := make([]common.Hash, 0, len(names)) + for _, name := range names { + expected = append(expected, contractABI.Events[name].ID) + t.Run("missing "+name, func(t *testing.T) { + incompleteABI := *contractABI + incompleteABI.Events = maps.Clone(contractABI.Events) + delete(incompleteABI.Events, name) + _, err := buildAllEventsFilterQuery(&bind.FilterOpts{}, address, &incompleteABI) + require.ErrorContains(t, err, "missing monitored event "+name) + }) + } + require.Equal(t, expected, query.Topics[0]) +} + +type tournamentLogsSource struct { + logs []types.Log +} + +func (s *tournamentLogsSource) GetLogs(context.Context, map[string]any) ([]types.Log, error) { + return s.logs, nil +} + +func TestRetrieveAllTournamentEventsParsesCurrentABI(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + one := common.HexToHash("0x11") + two := common.HexToHash("0x22") + three := common.HexToHash("0x33") + source := &tournamentLogsSource{} + large := new(big.Int).Lsh(big.NewInt(1), 200) + for index, event := range []struct { + name string + indexed []common.Hash + data []any + }{ + {name: testCommitmentJoinedEvent, indexed: []common.Hash{one, two}, data: []any{three}}, + {name: "MatchCreated", indexed: []common.Hash{one, two, three}, data: []any{one, uint64(20)}}, + {name: "MatchAdvanced", indexed: []common.Hash{one}, data: []any{two, three, large, uint64(21)}}, + {name: "MatchDeleted", indexed: []common.Hash{one, two, three}, data: []any{uint8(1), uint8(2)}}, + {name: "NewInnerTournament", indexed: []common.Hash{one, two}}, + {name: testLeafMatchSealedEvent, indexed: []common.Hash{one}, data: []any{uint64(22)}}, + {name: "PartialBondRefund", indexed: []common.Hash{two, common.HexToHash("0x01")}, data: []any{large}}, + {name: "BondRecovered", indexed: []common.Hash{one, two}, data: []any{large, big.NewInt(7)}}, + } { + definition := contractABI.Events[event.name] + data, err := definition.Inputs.NonIndexed().Pack(event.data...) + require.NoError(t, err) + source.logs = append(source.logs, types.Log{ + Address: address, BlockNumber: 10, Topics: append([]common.Hash{definition.ID}, event.indexed...), Data: data, + BlockHash: three, TxHash: two, Index: uint(index), + }) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", source)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(10) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + require.NoError(t, err) + require.Len(t, events.CommitmentJoined, 1) + require.Len(t, events.MatchCreated, 1) + require.Len(t, events.MatchAdvanced, 1) + require.Len(t, events.MatchDeleted, 1) + require.Len(t, events.NewInnerTournament, 1) + require.Len(t, events.LeafMatchSealed, 1) + require.Len(t, events.PartialBondRefund, 1) + require.Len(t, events.BondRecovered, 1) + require.Equal(t, [32]byte(one), events.CommitmentJoined[0].Commitment) + require.Equal(t, [32]byte(three), events.CommitmentJoined[0].FinalStateHash) + require.Equal(t, [32]byte(two), events.MatchCreated[0].One) + require.Equal(t, [32]byte(three), events.MatchCreated[0].Two) + require.Equal(t, [32]byte(one), events.MatchCreated[0].LeftOfTwo) + require.Equal(t, uint64(20), events.MatchCreated[0].EliminableAt) + require.Equal(t, [32]byte(two), events.MatchAdvanced[0].OtherParent) + require.Equal(t, [32]byte(three), events.MatchAdvanced[0].LeftNode) + require.Equal(t, large, events.MatchAdvanced[0].SegmentStartPosition) + require.Equal(t, uint64(21), events.MatchAdvanced[0].EliminableAt) + require.Equal(t, uint8(1), events.MatchDeleted[0].Reason) + require.Equal(t, uint8(2), events.MatchDeleted[0].WinnerCommitment) + require.Equal(t, common.BytesToAddress(two[:]), events.NewInnerTournament[0].ChildTournament) + require.Equal(t, uint64(22), events.LeafMatchSealed[0].EliminableAt) + require.Equal(t, [32]byte(one), events.LeafMatchSealed[0].MatchIdHash) + require.Equal(t, common.BytesToAddress(two[:]), events.PartialBondRefund[0].Recipient) + require.True(t, events.PartialBondRefund[0].Success) + require.Equal(t, large, events.PartialBondRefund[0].Value) + require.Equal(t, [32]byte(one), events.BondRecovered[0].Commitment) + require.Equal(t, common.BytesToAddress(two[:]), events.BondRecovered[0].Claimer) + require.Equal(t, large, events.BondRecovered[0].Payment) + require.Equal(t, big.NewInt(7), events.BondRecovered[0].Burned) + require.Equal(t, source.logs[7], events.BondRecovered[0].Raw) +} + +func TestRetrieveTournamentEventsRejectsInvalidLogs(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + definition := contractABI.Events["MatchDeleted"] + data, err := definition.Inputs.NonIndexed().Pack(uint8(1), uint8(2)) + require.NoError(t, err) + valid := types.Log{ + Address: address, BlockNumber: 10, BlockHash: common.HexToHash("0xb0"), TxHash: common.HexToHash("0xc0"), + Topics: []common.Hash{definition.ID, common.HexToHash("0x1"), common.HexToHash("0x2"), common.HexToHash("0x3")}, + Data: data, + } + for _, test := range []struct { + name string + mutate func(*types.Log) + duplicate bool + }{ + {name: "wrong address", mutate: func(log *types.Log) { log.Address = common.Address{} }}, + {name: "removed", mutate: func(log *types.Log) { log.Removed = true }}, + {name: "before range", mutate: func(log *types.Log) { log.BlockNumber = 9 }}, + {name: "after range", mutate: func(log *types.Log) { log.BlockNumber = 11 }}, + {name: "missing transaction", mutate: func(log *types.Log) { log.TxHash = common.Hash{} }}, + {name: "missing block hash", mutate: func(log *types.Log) { log.BlockHash = common.Hash{} }}, + {name: "missing topic", mutate: func(log *types.Log) { log.Topics = nil }}, + {name: "unexpected topic", mutate: func(log *types.Log) { log.Topics[0] = common.Hash{} }}, + {name: "bad payload", mutate: func(log *types.Log) { log.Data = nil }}, + {name: "unknown reason", mutate: func(log *types.Log) { log.Data[31] = 3 }}, + {name: "unknown winner", mutate: func(log *types.Log) { log.Data[63] = 3 }}, + {name: "node sentinel is not a contract reason", mutate: func(log *types.Log) { log.Data[31] = 255 }}, + {name: "maximum unknown winner", mutate: func(log *types.Log) { log.Data[63] = 255 }}, + {name: "duplicate identity", duplicate: true}, + } { + t.Run(test.name, func(t *testing.T) { + entry := valid + entry.Data = slices.Clone(valid.Data) + entry.Topics = slices.Clone(valid.Topics) + if test.mutate != nil { + test.mutate(&entry) + } + source := &tournamentLogsSource{logs: []types.Log{entry}} + if test.duplicate { + source.logs = append(source.logs, entry) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", source)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(10) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + require.Error(t, err) + require.Nil(t, events, "a rejected range must not return partial events") + }) + } +} + +func TestRetrieveTournamentEventsOrdersLogsAndChecksCrossLogIdentity(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + definition := contractABI.Events["PartialBondRefund"] + value := big.NewInt(17) + data, err := definition.Inputs.NonIndexed().Pack(value) + require.NoError(t, err) + first := types.Log{Address: address, BlockNumber: 10, BlockHash: common.HexToHash("0xb0"), TxHash: common.HexToHash("0xc0"), + Topics: []common.Hash{definition.ID, common.HexToHash("0x22"), {}}, Data: data} + second := first + second.Index = 1 + for _, test := range []struct { + name string + mutate func(*types.Log) + wantError string + }{ + {name: "same block order"}, + {name: "different block hashes", mutate: func(log *types.Log) { log.BlockHash = common.HexToHash("0xb1") }, + wantError: "disagree on the block hash"}, + {name: "same transaction identity in different blocks", mutate: func(log *types.Log) { + log.BlockNumber = 11 + log.Index = 0 + }, wantError: "transaction log identity"}, + } { + t.Run(test.name, func(t *testing.T) { + entry := second + if test.mutate != nil { + test.mutate(&entry) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", &tournamentLogsSource{logs: []types.Log{entry, first}})) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(11) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + require.Nil(t, events) + return + } + require.NoError(t, err) + require.Len(t, events.PartialBondRefund, 2) + require.Equal(t, first, events.PartialBondRefund[0].Raw) + require.Equal(t, second, events.PartialBondRefund[1].Raw) + for _, refund := range events.PartialBondRefund { + require.False(t, refund.Success) + require.Equal(t, value, refund.Value, "failed refund retains its requested amount") + } + }) + } +} diff --git a/internal/prt/fixture_test.go b/internal/prt/fixture_test.go index 24fc3fc47..b9d0bdcde 100644 --- a/internal/prt/fixture_test.go +++ b/internal/prt/fixture_test.go @@ -13,7 +13,6 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" - "github.com/cartesi/rollups-node/pkg/contracts/itournament" "github.com/cartesi/rollups-node/pkg/service" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -97,21 +96,23 @@ func (m *prtRepositoryMock) GetEpoch( return epoch, args.Error(1) } -func (m *prtRepositoryMock) UpdateEpochStatus( +func (m *prtRepositoryMock) UpdateEpochReconciledStaged( ctx context.Context, - nameOrAddress string, - e *model.Epoch, + applicationID int64, + index uint64, + stagedAtBlock uint64, ) error { - args := m.Called(ctx, nameOrAddress, e) + args := m.Called(ctx, applicationID, index, stagedAtBlock) return args.Error(0) } -func (m *prtRepositoryMock) CreateTournament( +func (m *prtRepositoryMock) UpdateEpochWithAcceptedClaim( ctx context.Context, - nameOrAddress string, - t *model.Tournament, + applicationID int64, + index uint64, + txHash *common.Hash, ) error { - args := m.Called(ctx, nameOrAddress, t) + args := m.Called(ctx, applicationID, index, txHash) return args.Error(0) } @@ -125,15 +126,6 @@ func (m *prtRepositoryMock) GetTournament( return tournament, args.Error(1) } -func (m *prtRepositoryMock) UpdateTournament( - ctx context.Context, - nameOrAddress string, - t *model.Tournament, -) error { - args := m.Called(ctx, nameOrAddress, t) - return args.Error(0) -} - func (m *prtRepositoryMock) ListTournaments( ctx context.Context, nameOrAddress string, @@ -149,13 +141,10 @@ func (m *prtRepositoryMock) ListTournaments( func (m *prtRepositoryMock) StoreTournamentEvents( ctx context.Context, appID int64, - commitments []*model.Commitment, - matches []*model.Match, - advancedMatches []*model.MatchAdvanced, - deletedMatches []*model.Match, + batches []*repository.TournamentEventBatch, blockNumber uint64, ) error { - args := m.Called(ctx, appID, commitments, matches, advancedMatches, deletedMatches, blockNumber) + args := m.Called(ctx, appID, batches, blockNumber) return args.Error(0) } @@ -176,6 +165,20 @@ func (m *prtRepositoryMock) SaveNodeConfigRaw(ctx context.Context, key string, r return args.Error(0) } +func (m *prtRepositoryMock) ListCommitments(ctx context.Context, app string, filter repository.CommitmentFilter, + page repository.Pagination, descending bool) ([]*model.Commitment, uint64, error) { + args := m.Called(ctx, app, filter, page, descending) + rows, _ := args.Get(0).([]*model.Commitment) + return rows, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) ListMatches(ctx context.Context, app string, filter repository.MatchFilter, + page repository.Pagination, descending bool) ([]*model.Match, uint64, error) { + args := m.Called(ctx, app, filter, page, descending) + rows, _ := args.Get(0).([]*model.Match) + return rows, args.Get(1).(uint64), args.Error(2) +} + func (m *prtRepositoryMock) LoadNodeConfigRaw( ctx context.Context, key string, @@ -211,6 +214,12 @@ func (m *ethClientMock) BlockNumber(ctx context.Context) (uint64, error) { return args.Get(0).(uint64), args.Error(1) } +func (m *ethClientMock) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + args := m.Called(ctx, number) + header, _ := args.Get(0).(*types.Header) + return header, args.Error(1) +} + func (m *ethClientMock) TransactionByHash( ctx context.Context, hash common.Hash, @@ -252,30 +261,54 @@ func (m *daveConsensusAdapterMock) ParseEpochSealed( return event, args.Error(1) } -func (m *daveConsensusAdapterMock) CanSettle(opts *bind.CallOpts) (CanSettleResult, error) { +func (m *daveConsensusAdapterMock) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { args := m.Called(opts) - result, _ := args.Get(0).(CanSettleResult) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *daveConsensusAdapterMock) GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CurrentSealedEpoch) + return result, args.Error(1) +} + +func (m *daveConsensusAdapterMock) CanStageTournamentResult( + opts *bind.CallOpts, +) (CanStageTournamentResult, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CanStageTournamentResult) return result, args.Error(1) } -func (m *daveConsensusAdapterMock) IsEpochSettled( +func (m *daveConsensusAdapterMock) CanAcceptStagedTournamentResult( opts *bind.CallOpts, +) (CanAcceptStagedTournamentResult, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CanAcceptStagedTournamentResult) + return result, args.Error(1) +} + +func (m *daveConsensusAdapterMock) StageTournamentResult( + opts *bind.TransactOpts, epochNumber uint64, -) (bool, error) { - args := m.Called(opts, epochNumber) - return args.Bool(0), args.Error(1) + proof model.StateProof, +) (*types.Transaction, error) { + args := m.Called(opts, epochNumber, proof) + tx, _ := args.Get(0).(*types.Transaction) + if fn, ok := args.Get(1).(func(*bind.TransactOpts, uint64, model.StateProof) error); ok { + return tx, fn(opts, epochNumber, proof) + } + return tx, args.Error(1) } -func (m *daveConsensusAdapterMock) Settle( +func (m *daveConsensusAdapterMock) AcceptStagedTournamentResult( opts *bind.TransactOpts, - epochNumber *big.Int, - outputsMerkleRoot [32]byte, - proof [][32]byte, + epochNumber uint64, ) (*types.Transaction, error) { - args := m.Called(opts, epochNumber, outputsMerkleRoot, proof) + args := m.Called(opts, epochNumber) tx, _ := args.Get(0).(*types.Transaction) - if fn, ok := args.Get(1).(func(*bind.TransactOpts, *big.Int, [32]byte, [][32]byte) error); ok { - return tx, fn(opts, epochNumber, outputsMerkleRoot, proof) + if fn, ok := args.Get(1).(func(*bind.TransactOpts, uint64) error); ok { + return tx, fn(opts, epochNumber) } return tx, args.Error(1) } @@ -286,66 +319,40 @@ type tournamentAdapterMock struct { var _ TournamentAdapter = (*tournamentAdapterMock)(nil) -func (m *tournamentAdapterMock) RetrieveCommitmentJoinedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentCommitmentJoined, error) { - args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentCommitmentJoined) - return events, args.Error(1) -} - -func (m *tournamentAdapterMock) RetrieveMatchAdvancedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchAdvanced, error) { - args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentMatchAdvanced) - return events, args.Error(1) -} - -func (m *tournamentAdapterMock) RetrieveMatchCreatedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchCreated, error) { - args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentMatchCreated) - return events, args.Error(1) -} - -func (m *tournamentAdapterMock) RetrieveMatchDeletedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchDeleted, error) { +func (m *tournamentAdapterMock) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentMatchDeleted) + events, _ := args.Get(0).(*TournamentEvents) return events, args.Error(1) } -func (m *tournamentAdapterMock) RetrieveNewInnerTournamentEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentNewInnerTournament, error) { +func (m *tournamentAdapterMock) Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) { args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentNewInnerTournament) - return events, args.Error(1) + result, _ := args.Get(0).(TournamentDescriptor) + return result, args.Error(1) } -func (m *tournamentAdapterMock) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { +func (m *tournamentAdapterMock) Standing(opts *bind.CallOpts) (TournamentStanding, error) { args := m.Called(opts) - events, _ := args.Get(0).(*TournamentEvents) - return events, args.Error(1) + result, _ := args.Get(0).(TournamentStanding) + return result, args.Error(1) } -func (m *tournamentAdapterMock) Result(opts *bind.CallOpts) (bool, [32]byte, [32]byte, error) { - args := m.Called(opts) - return args.Bool(0), args.Get(1).([32]byte), args.Get(2).([32]byte), args.Error(3) +func (m *tournamentAdapterMock) MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) { + args := m.Called(opts, one, two) + result, _ := args.Get(0).(ObservedMatchSnapshot) + return result, args.Error(1) } -func (m *tournamentAdapterMock) Constants(opts *bind.CallOpts) (TournamentConstants, error) { +func (m *tournamentAdapterMock) InnerResult(opts *bind.CallOpts) (InnerResult, error) { args := m.Called(opts) - constants, _ := args.Get(0).(TournamentConstants) - return constants, args.Error(1) + result, _ := args.Get(0).(InnerResult) + return result, args.Error(1) } -func (m *tournamentAdapterMock) TimeFinished(opts *bind.CallOpts) (bool, uint64, error) { +func (m *tournamentAdapterMock) StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) { args := m.Called(opts) - return args.Bool(0), args.Get(1).(uint64), args.Error(2) + result, _ := args.Get(0).(StructuralEventCounts) + return result, args.Error(1) } func (m *tournamentAdapterMock) BondValue(opts *bind.CallOpts) (*big.Int, error) { @@ -354,12 +361,19 @@ func (m *tournamentAdapterMock) BondValue(opts *bind.CallOpts) (*big.Int, error) return value, args.Error(1) } -func (m *tournamentAdapterMock) IsCommitmentJoined( +func (m *tournamentAdapterMock) BondRecovery(opts *bind.CallOpts) (BondRecovery, error) { + args := m.Called(opts) + recovery, _ := args.Get(0).(BondRecovery) + return recovery, args.Error(1) +} + +func (m *tournamentAdapterMock) CommitmentStanding( opts *bind.CallOpts, commitmentRoot [32]byte, -) (bool, error) { +) (CommitmentStanding, error) { args := m.Called(opts, commitmentRoot) - return args.Bool(0), args.Error(1) + result, _ := args.Get(0).(CommitmentStanding) + return result, args.Error(1) } func (m *tournamentAdapterMock) JoinTournament( @@ -377,6 +391,15 @@ func (m *tournamentAdapterMock) JoinTournament( return tx, args.Error(1) } +func (m *tournamentAdapterMock) TryRecoveringBond(opts *bind.TransactOpts) (*types.Transaction, error) { + args := m.Called(opts) + tx, _ := args.Get(0).(*types.Transaction) + if fn, ok := args.Get(1).(func(*bind.TransactOpts) error); ok { + return tx, fn(opts) + } + return tx, args.Error(1) +} + func newPRTServiceMock() (*Service, *prtRepositoryMock) { repo := &prtRepositoryMock{} s := &Service{ @@ -385,7 +408,12 @@ func newPRTServiceMock() (*Service, *prtRepositoryMock) { Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }, }, - repository: repo, + repository: repo, + pendingTransactions: map[int64]pendingTournamentTransaction{}, + disputeWarnings: map[common.Address]struct{}{}, + zeroStagingWarnings: map[int64]struct{}{}, + rootBondRecoveries: map[int64][]*rootBondRecovery{}, + observationFailures: map[int64]tournamentObservationFailure{}, } return s, repo } diff --git a/internal/prt/idaveconsensus_adapter.go b/internal/prt/idaveconsensus_adapter.go index af9913d65..6eb7d9e90 100644 --- a/internal/prt/idaveconsensus_adapter.go +++ b/internal/prt/idaveconsensus_adapter.go @@ -4,19 +4,25 @@ package prt import ( + "errors" + "fmt" "math/big" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/imultileveltournamentfactory" ) // DaveConsensusAdapterImpl wraps the generated IDaveConsensus binding. type DaveConsensusAdapterImpl struct { consensus *idaveconsensus.IDaveConsensus + client *ethclient.Client } // NewDaveConsensusAdapter creates a new DaveConsensusAdapter backed by the IDaveConsensus contract. @@ -28,42 +34,142 @@ func NewDaveConsensusAdapter( if err != nil { return nil, err } - return &DaveConsensusAdapterImpl{consensus: consensus}, nil + return &DaveConsensusAdapterImpl{consensus: consensus, client: client}, nil } func (a *DaveConsensusAdapterImpl) ParseEpochSealed(log types.Log) (*idaveconsensus.IDaveConsensusEpochSealed, error) { return a.consensus.ParseEpochSealed(log) } -func (a *DaveConsensusAdapterImpl) CanSettle(opts *bind.CallOpts) (CanSettleResult, error) { - result, err := a.consensus.CanSettle(opts) +func (a *DaveConsensusAdapterImpl) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { + factoryAddress, err := a.consensus.GetTournamentFactory(opts) + if err != nil { + return 0, err + } + factory, err := imultileveltournamentfactory.NewIMultiLevelTournamentFactory(factoryAddress, a.client) + if err != nil { + return 0, err + } + levelCount, err := factory.TournamentLevelCount(opts) + if err != nil { + return 0, err + } + if levelCount == 0 { + return 0, errors.New("tournament level count is zero") + } + return levelCount, nil +} + +func checkedUint64(value *big.Int, field string) (uint64, error) { + if value == nil || value.Sign() < 0 || !value.IsUint64() { + return 0, fmt.Errorf("%s is not a uint64", field) + } + return value.Uint64(), nil +} + +func (a *DaveConsensusAdapterImpl) GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) { + result, err := a.consensus.GetCurrentSealedEpoch(opts) + if err != nil { + return CurrentSealedEpoch{}, err + } + epoch, err := checkedUint64(result.EpochNumber, "sealed epoch number") + if err != nil { + return CurrentSealedEpoch{}, err + } + lowerBound, err := checkedUint64(result.InputIndexLowerBound, "sealed epoch input lower bound") + if err != nil { + return CurrentSealedEpoch{}, err + } + upperBound, err := checkedUint64(result.InputIndexUpperBound, "sealed epoch input upper bound") if err != nil { - return CanSettleResult{}, err + return CurrentSealedEpoch{}, err } - return CanSettleResult{ - IsFinished: result.IsFinished, - EpochNumber: result.EpochNumber, - WinnerCommitment: result.WinnerCommitment, + stagingBlock, err := checkedUint64(result.StagingBlockNumber, "sealed epoch staging block") + if err != nil { + return CurrentSealedEpoch{}, err + } + return CurrentSealedEpoch{ + EpochNumber: epoch, + InputIndexLowerBound: lowerBound, + InputIndexUpperBound: upperBound, + Tournament: result.Tournament, + IsTournamentResultStaged: result.IsTournamentResultStaged, + StagingBlockNumber: stagingBlock, + StagedPostEpochMachineStateHash: result.StagedPostEpochMachineStateHash, + StagedPostEpochOutputsMerkleRoot: result.StagedPostEpochOutputsMerkleRoot, + }, nil +} + +func (a *DaveConsensusAdapterImpl) CanStageTournamentResult( + opts *bind.CallOpts, +) (CanStageTournamentResult, error) { + result, err := a.consensus.CanStageTournamentResult(opts) + if err != nil { + return CanStageTournamentResult{}, err + } + epoch, err := checkedUint64(result.EpochNumber, "stage readiness epoch number") + if err != nil { + return CanStageTournamentResult{}, err + } + return CanStageTournamentResult{ + IsFinished: result.IsFinished, + IsTournamentFailed: result.IsTournamentFailed, + IsTournamentResultStaged: result.IsTournamentResultStaged, + EpochNumber: epoch, + WinnerCommitment: result.WinnerCommitment, + WinnerPostEpochMachineStateHash: result.WinnerPostEpochMachineStateHash, }, nil } -// IsEpochSettled checks on-chain whether an epoch has already been settled by -// comparing the given epoch number against the current sealed epoch. If the -// sealed epoch has advanced past it, the settlement was already performed. -// This prevents duplicate Settle calls after a node restart. -func (a *DaveConsensusAdapterImpl) IsEpochSettled( - opts *bind.CallOpts, epochNumber uint64, -) (bool, error) { - sealed, err := a.consensus.GetCurrentSealedEpoch(opts) +func (a *DaveConsensusAdapterImpl) CanAcceptStagedTournamentResult( + opts *bind.CallOpts, +) (CanAcceptStagedTournamentResult, error) { + result, err := a.consensus.CanAcceptStagedTournamentResult(opts) if err != nil { - return false, err + return CanAcceptStagedTournamentResult{}, err } - return sealed.EpochNumber.Uint64() > epochNumber, nil + epoch, err := checkedUint64(result.EpochNumber, "accept readiness epoch number") + if err != nil { + return CanAcceptStagedTournamentResult{}, err + } + return CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: result.IsTournamentResultStaged, + DoAllSentriesAgreeWithStagedTournamentResult: result.DoAllSentriesAgreeWithStagedTournamentResult, + IsClaimStagingPeriodOver: result.IsClaimStagingPeriodOver, + EpochNumber: epoch, + StagedPostEpochMachineStateHash: result.StagedPostEpochMachineStateHash, + StagedPostEpochOutputsMerkleRoot: result.StagedPostEpochOutputsMerkleRoot, + }, nil +} + +func daveMachineValidityProof(proof StateProof) idaveconsensus.MachineValidityProof { + return idaveconsensus.MachineValidityProof{ + IflagsYProof: idaveconsensus.LeafProof{ + DataBlock: proof.IflagsYDataBlock, + Siblings: append([][32]byte(nil), proof.IflagsYProof...), + }, + HtifTohostProof: idaveconsensus.LeafProof{ + DataBlock: proof.HtifTohostDataBlock, + Siblings: append([][32]byte(nil), proof.HtifTohostProof...), + }, + TxBufferProof: idaveconsensus.LeafProof{ + DataBlock: proof.TxBufferDataBlock, + Siblings: append([][32]byte(nil), proof.TxBufferProof...), + }, + } +} + +func (a *DaveConsensusAdapterImpl) StageTournamentResult( + opts *bind.TransactOpts, + epochNumber uint64, + proof StateProof, +) (*types.Transaction, error) { + return a.consensus.StageTournamentResult(opts, new(big.Int).SetUint64(epochNumber), daveMachineValidityProof(proof)) } -func (a *DaveConsensusAdapterImpl) Settle( - opts *bind.TransactOpts, epochNumber *big.Int, - outputsMerkleRoot [32]byte, proof [][32]byte, +func (a *DaveConsensusAdapterImpl) AcceptStagedTournamentResult( + opts *bind.TransactOpts, + epochNumber uint64, ) (*types.Transaction, error) { - return a.consensus.Settle(opts, epochNumber, outputsMerkleRoot, proof) + return a.consensus.AcceptStagedTournamentResult(opts, new(big.Int).SetUint64(epochNumber)) } diff --git a/internal/prt/idaveconsensus_adapter_test.go b/internal/prt/idaveconsensus_adapter_test.go new file mode 100644 index 000000000..73608d94b --- /dev/null +++ b/internal/prt/idaveconsensus_adapter_test.go @@ -0,0 +1,101 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type daveConsensusViewRPC struct{ mock.Mock } + +func (m *daveConsensusViewRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +func TestDaveConsensusAdapterDecodesContractViews(t *testing.T) { + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x100") + tournament := common.HexToAddress("0x11") + winner := common.HexToHash("0x22") + machineHash := common.HexToHash("0x33") + outputsRoot := common.HexToHash("0x44") + block := big.NewInt(20) + + for i, name := range []string{"first flag", "second flag", "third flag"} { + t.Run(name, func(t *testing.T) { + var flags [3]bool + flags[i] = true + backend := &daveConsensusViewRPC{} + for _, view := range []struct { + method string + values []any + }{ + {"getCurrentSealedEpoch", []any{ + big.NewInt(1), big.NewInt(2), big.NewInt(3), tournament, flags[0], big.NewInt(4), machineHash, outputsRoot, + }}, + {"canStageTournamentResult", []any{flags[0], flags[1], flags[2], big.NewInt(5), winner, machineHash}}, + {"canAcceptStagedTournamentResult", []any{flags[0], flags[1], flags[2], big.NewInt(6), machineHash, outputsRoot}}, + } { + method := contractABI.Methods[view.method] + encoded, err := method.Outputs.Pack(view.values...) + require.NoError(t, err) + backend.On("Call", address, method.ID, rpc.BlockNumber(block.Int64())).Return(encoded, nil).Once() + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewDaveConsensusAdapter(address, client) + require.NoError(t, err) + opts := &bind.CallOpts{Context: t.Context(), BlockNumber: block} + + sealed, err := adapter.GetCurrentSealedEpoch(opts) + require.NoError(t, err) + require.Equal(t, CurrentSealedEpoch{ + EpochNumber: 1, InputIndexLowerBound: 2, InputIndexUpperBound: 3, Tournament: tournament, + IsTournamentResultStaged: flags[0], StagingBlockNumber: 4, + StagedPostEpochMachineStateHash: machineHash, StagedPostEpochOutputsMerkleRoot: outputsRoot, + }, sealed) + stage, err := adapter.CanStageTournamentResult(opts) + require.NoError(t, err) + require.Equal(t, CanStageTournamentResult{ + IsFinished: flags[0], IsTournamentFailed: flags[1], IsTournamentResultStaged: flags[2], + EpochNumber: 5, WinnerCommitment: winner, WinnerPostEpochMachineStateHash: machineHash, + }, stage) + accept, err := adapter.CanAcceptStagedTournamentResult(opts) + require.NoError(t, err) + require.Equal(t, CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: flags[0], DoAllSentriesAgreeWithStagedTournamentResult: flags[1], + IsClaimStagingPeriodOver: flags[2], EpochNumber: 6, + StagedPostEpochMachineStateHash: machineHash, StagedPostEpochOutputsMerkleRoot: outputsRoot, + }, accept) + backend.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/itournament_adapter.go b/internal/prt/itournament_adapter.go index 0c08f1ff3..ee4c8739f 100644 --- a/internal/prt/itournament_adapter.go +++ b/internal/prt/itournament_adapter.go @@ -4,9 +4,13 @@ package prt import ( + "cmp" + "errors" + "fmt" "math/big" + "slices" - . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/contracts/itournament" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -15,9 +19,16 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" ) +var ( + errInvalidBondRecovery = errors.New("invalid tournament bond recovery state") + errTournamentMatchPhaseChanged = errors.New("tournament match phase changed within pinned observation") + errUnpinnedTournamentRead = errors.New("tournament observation requires a pinned block") +) + // ITournament Wrapper type ITournamentAdapterImpl struct { tournament *itournament.ITournament @@ -43,246 +54,511 @@ func NewITournamentAdapter( }, nil } -func (a *ITournamentAdapterImpl) Result(opts *bind.CallOpts) (bool, [32]byte, [32]byte, error) { - result, err := a.tournament.ArbitrationResult(opts) - // ArbitrationResult reverts with TournamentFailedNoWinner when the - // tournament finished with every participant eliminated. - if isTournamentError(err, "TournamentFailedNoWinner") { - return true, [32]byte{}, [32]byte{}, nil +func (a *ITournamentAdapterImpl) Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) { + descriptor, err := a.tournament.TournamentDescriptor(opts) + if err != nil { + return TournamentDescriptor{}, err } - return result.Finished, result.WinnerCommitment, result.FinalState, err + return tournamentDescriptorFromBinding(descriptor) } -func (a *ITournamentAdapterImpl) Constants(opts *bind.CallOpts) (TournamentConstants, error) { - c, err := a.tournament.TournamentLevelConstants(opts) - return TournamentConstants{ - MaxLevel: c.MaxLevel, - Level: c.Level, - Log2step: c.Log2step, - Height: c.Height, - }, err +func tournamentDescriptorFromBinding(descriptor itournament.ITournamentTournamentDescriptor) (TournamentDescriptor, error) { + kind, err := tournamentEnum("kind", descriptor.Kind, model.TournamentKindLeaf, model.TournamentKindNonLeaf) + if err != nil { + return TournamentDescriptor{}, err + } + baseCycle, err := tournamentUint256("base cycle", descriptor.BaseCycle) + if err != nil { + return TournamentDescriptor{}, err + } + return TournamentDescriptor{ + InitialHash: descriptor.InitialHash, + BaseCycle: baseCycle, + Log2Stride: descriptor.Log2Stride, + Height: descriptor.Height, + Level: descriptor.Level, + Kind: kind, + StartInstant: descriptor.StartInstant, + Allowance: descriptor.Allowance, + }, nil } -func (a *ITournamentAdapterImpl) TimeFinished(opts *bind.CallOpts) (bool, uint64, error) { - return a.tournament.TimeFinished(opts) +func (a *ITournamentAdapterImpl) Standing(opts *bind.CallOpts) (TournamentStanding, error) { + standing, err := a.tournament.TournamentStanding(opts) + if err != nil { + return TournamentStanding{}, err + } + return tournamentStandingFromBinding(standing) } -func (a *ITournamentAdapterImpl) BondValue(opts *bind.CallOpts) (*big.Int, error) { - return a.tournament.BondValue(opts) +func tournamentStandingFromBinding(standing itournament.ITournamentTournamentStandingView) (TournamentStanding, error) { + state, err := tournamentEnum("standing", standing.Standing, + model.TournamentStandingMatchesActive, model.TournamentStandingAwaitingClosure, + model.TournamentStandingRootWinner, model.TournamentStandingRootFailed, model.TournamentStandingInnerWinner, + model.TournamentStandingInnerEliminableNoWinner, model.TournamentStandingInnerEliminableWinnerExpired) + if err != nil { + return TournamentStanding{}, err + } + return TournamentStanding{ + State: state, + AcceptsJoins: standing.AcceptsJoins, + HasCandidate: standing.HasCandidate, + Candidate: standing.Candidate, + FinalState: standing.FinalState, + ParentCommitment: standing.ParentCommitment, + FinishedAt: standing.FinishedAt, + WinnerExpiresAt: standing.WinnerExpiresAt, + }, nil } -// IsCommitmentJoined checks on-chain whether a commitment has already been -// joined to this tournament. It calls the contract's getCommitment method -// and checks if the returned finalState is non-zero (indicating the commitment -// exists). This prevents duplicate JoinTournament calls after a node restart. -func (a *ITournamentAdapterImpl) IsCommitmentJoined( - opts *bind.CallOpts, commitmentRoot [32]byte, -) (bool, error) { - result, err := a.tournament.GetCommitment(opts, commitmentRoot) +func (a *ITournamentAdapterImpl) BondValue(opts *bind.CallOpts) (*big.Int, error) { + value, err := a.tournament.BondValue(opts) if err != nil { - return false, err + return nil, err } - return result.FinalState != [32]byte{}, nil + return tournamentUint256("bond value", value) } -func (a *ITournamentAdapterImpl) JoinTournament( - opts *bind.TransactOpts, finalState [32]byte, proof [][32]byte, - leftNode [32]byte, rightNode [32]byte, -) (*types.Transaction, error) { - return a.tournament.JoinTournament(opts, finalState, proof, leftNode, rightNode) +func (a *ITournamentAdapterImpl) BondRecovery(opts *bind.CallOpts) (BondRecovery, error) { + recovery, err := a.tournament.BondRecovery(opts) + if err != nil { + return BondRecovery{}, err + } + return bondRecoveryFromBinding(recovery.Disposition, recovery.Claimer, recovery.Payment) } -// buildFilterQuery creates a filter query for a specific tournament event -func buildFilterQuery( - opts *bind.FilterOpts, - tournamentAddress common.Address, - eventName string, -) (q ethereum.FilterQuery, err error) { - c, err := itournament.ITournamentMetaData.GetAbi() +func bondRecoveryFromBinding( + disposition uint8, + claimer common.Address, + payment *big.Int, +) (BondRecovery, error) { + d, err := tournamentEnum("bond disposition", disposition, model.BondDispositionTournamentRunning, + model.BondDispositionNoWinner, model.BondDispositionRecoverable, model.BondDispositionRecovered) + if err != nil { + return BondRecovery{}, fmt.Errorf("%w: %w", errInvalidBondRecovery, err) + } + amount, err := tournamentUint256("payment", payment) if err != nil { - return q, err + return BondRecovery{}, fmt.Errorf("%w: %w", errInvalidBondRecovery, err) + } + + if d == model.BondDispositionRecoverable { + if claimer == (common.Address{}) { + return BondRecovery{}, fmt.Errorf("%w: recoverable bond has zero claimer", errInvalidBondRecovery) + } + } else if claimer != (common.Address{}) || payment.Sign() != 0 { + return BondRecovery{}, fmt.Errorf( + "%w: inactive disposition %s has claimer %s or payment %s", + errInvalidBondRecovery, + d, + claimer, + payment, + ) } - topics, err := abi.MakeTopics( - []any{c.Events[eventName].ID}, - ) + return BondRecovery{ + Disposition: d, + Claimer: claimer, + Payment: amount, + }, nil +} + +func (a *ITournamentAdapterImpl) CommitmentStanding( + opts *bind.CallOpts, commitmentRoot [32]byte, +) (CommitmentStanding, error) { + standing, err := a.tournament.CommitmentStanding(opts, commitmentRoot) if err != nil { - return q, err + return CommitmentStanding{}, err } + return commitmentStandingFromBinding(standing), nil +} - q = ethereum.FilterQuery{ - Addresses: []common.Address{tournamentAddress}, - FromBlock: new(big.Int).SetUint64(opts.Start), - Topics: topics, +func commitmentStandingFromBinding(standing itournament.ITournamentCommitmentStandingView) CommitmentStanding { + return CommitmentStanding{ + Joined: standing.Joined, + FinalState: standing.FinalState, + Claimer: standing.Claimer, + ClockRunning: standing.ClockRunning, + ClockDeadline: standing.ClockDeadline, + ClockAllowance: standing.ClockAllowance, } - if opts.End != nil { - q.ToBlock = new(big.Int).SetUint64(*opts.End) +} + +// tournamentEnum maps ABI ordinal values to the shared model vocabulary. The +// explicit order at each call site follows the deployed Solidity declaration. +func tournamentEnum[T ~string](name string, raw uint8, values ...T) (T, error) { + if int(raw) >= len(values) { + var zero T + return zero, fmt.Errorf("tournament has unknown %s %d", name, raw) } - return q, err + return values[raw], nil } -// retrieveEvents retrieves and parses events of a specific type -func retrieveEvents[T any]( - a *ITournamentAdapterImpl, - opts *bind.FilterOpts, - eventName string, - parseFunc func(types.Log) (T, error), -) ([]T, error) { - q, err := buildFilterQuery(opts, a.tournamentAddress, eventName) - if err != nil { - return nil, err +const tournamentIntegerBits = 256 + +func tournamentUint256(name string, value *big.Int) (*big.Int, error) { + if value == nil { + return nil, fmt.Errorf("tournament %s is nil", name) + } + if value.Sign() < 0 { + return nil, fmt.Errorf("tournament %s is negative", name) + } + if value.BitLen() > tournamentIntegerBits { + return nil, fmt.Errorf("tournament %s exceeds uint256", name) } + return new(big.Int).Set(value), nil +} - itr, err := a.filter.ChunkedFilterLogs(opts.Context, a.client, q) - if err != nil { - return nil, err +func requirePinnedTournamentRead(opts *bind.CallOpts) error { + if opts == nil || opts.Pending { + return errUnpinnedTournamentRead + } + if opts.BlockHash != (common.Hash{}) && opts.BlockNumber == nil { + return nil + } + if opts.BlockNumber == nil || !opts.BlockNumber.IsUint64() || opts.BlockHash != (common.Hash{}) { + return errUnpinnedTournamentRead } + return nil +} - var events []T - for log, err := range itr { +func (a *ITournamentAdapterImpl) MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) { + if err := requirePinnedTournamentRead(opts); err != nil { + return ObservedMatchSnapshot{}, err + } + timeout, err := a.tournament.ClassifyMatchTimeout(opts, itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}) + if err != nil { + return ObservedMatchSnapshot{}, err + } + phase, err := tournamentEnum("match phase", timeout.ActualPhase, + model.MatchPhaseUninitialized, model.MatchPhaseBisecting, model.MatchPhaseReadyToSeal, model.MatchPhaseSealed) + if err != nil { + return ObservedMatchSnapshot{}, err + } + outcome, err := tournamentEnum("match timeout outcome", timeout.Outcome, + model.MatchTimeoutNone, model.MatchTimeoutOneWins, model.MatchTimeoutTwoWins, model.MatchTimeoutEliminateBoth) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if (outcome == model.MatchTimeoutNone || outcome == model.MatchTimeoutEliminateBoth) && timeout.DeferredCharge != 0 { + return ObservedMatchSnapshot{}, errors.New("tournament timeout has an inactive deferred charge") + } + snapshot := ObservedMatchSnapshot{Phase: phase, TimeoutOutcome: outcome, DeferredCharge: timeout.DeferredCharge} + matchHash := crypto.Keccak256Hash(one[:], two[:]) + switch phase { + case model.MatchPhaseUninitialized: + if outcome != model.MatchTimeoutNone { + return ObservedMatchSnapshot{}, errors.New("absent tournament match has a timeout outcome") + } + case model.MatchPhaseBisecting: + view, err := a.tournament.BisectingMatch(opts, matchHash) if err != nil { - return nil, err + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged } - ev, err := parseFunc(*log) + value := view.Value + position, cycle, side, err := bisectionFields(value.SegmentStartPosition, value.SegmentStartCycle, value.Responder) if err != nil { - return nil, err + return ObservedMatchSnapshot{}, err + } + if value.CurrentHeight <= 1 { + return ObservedMatchSnapshot{}, errors.New("bisecting tournament match has height below two") + } + snapshot.Bisecting = &BisectingMatch{RevealingParent: value.RevealingParent, + WaitingLeft: value.WaitingLeft, WaitingRight: value.WaitingRight, SegmentStartPosition: position, + SegmentStartCycle: cycle, CurrentHeight: value.CurrentHeight, Responder: side} + case model.MatchPhaseReadyToSeal: + view, err := a.tournament.ReadyToSealMatch(opts, matchHash) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged + } + value := view.Value + position, cycle, side, err := bisectionFields(value.SegmentStartPosition, value.SegmentStartCycle, value.Responder) + if err != nil { + return ObservedMatchSnapshot{}, err + } + snapshot.ReadyToSeal = &ReadyToSealMatch{RevealingParent: value.RevealingParent, + WaitingLeft: value.WaitingLeft, WaitingRight: value.WaitingRight, SegmentStartPosition: position, + SegmentStartCycle: cycle, Responder: side} + case model.MatchPhaseSealed: + view, err := a.tournament.SealedMatch(opts, matchHash) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged + } + value := view.Value + position, err := tournamentUint256("divergence position", value.DivergencePosition) + if err != nil { + return ObservedMatchSnapshot{}, err + } + cycle, err := tournamentUint256("divergence cycle", value.DivergenceCycle) + if err != nil { + return ObservedMatchSnapshot{}, err } - events = append(events, ev) + if timeout.DeferredCharge != 0 { + return ObservedMatchSnapshot{}, errors.New("sealed tournament match has a deferred charge") + } + snapshot.Sealed = &SealedMatch{AgreeState: value.AgreeState, DivergencePosition: position, + DivergenceCycle: cycle, FinalStateOne: value.FinalStateOne, FinalStateTwo: value.FinalStateTwo} } - return events, nil + return snapshot, nil } -func (a *ITournamentAdapterImpl) RetrieveCommitmentJoinedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentCommitmentJoined, error) { - return retrieveEvents(a, opts, MonitoredEvent_CommitmentJoined.String(), a.tournament.ParseCommitmentJoined) +func bisectionFields(position, cycle *big.Int, rawSide uint8) (*big.Int, *big.Int, model.CommitmentSide, error) { + positionCopy, err := tournamentUint256("segment start position", position) + if err != nil { + return nil, nil, "", err + } + cycleCopy, err := tournamentUint256("segment start cycle", cycle) + if err != nil { + return nil, nil, "", err + } + side, err := tournamentEnum("responder", rawSide, model.CommitmentSideOne, model.CommitmentSideTwo) + return positionCopy, cycleCopy, side, err } -func (a *ITournamentAdapterImpl) RetrieveMatchAdvancedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchAdvanced, error) { - return retrieveEvents(a, opts, MonitoredEvent_MatchAdvanced.String(), a.tournament.ParseMatchAdvanced) +func (a *ITournamentAdapterImpl) InnerResult(opts *bind.CallOpts) (InnerResult, error) { + value, err := a.tournament.InnerResult(opts) + if err != nil { + return InnerResult{}, err + } + disposition, err := tournamentEnum("inner disposition", value.Disposition, + model.InnerTournamentUnsettled, model.InnerTournamentWinner, model.InnerTournamentEliminable) + if err != nil { + return InnerResult{}, err + } + if disposition != model.InnerTournamentWinner && (value.ParentCommitment != [32]byte{} || value.PausedAllowance != 0) { + return InnerResult{}, errors.New("inactive tournament inner result has winner fields") + } + return InnerResult{Disposition: disposition, ParentCommitment: value.ParentCommitment, PausedAllowance: value.PausedAllowance}, nil } -func (a *ITournamentAdapterImpl) RetrieveMatchCreatedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchCreated, error) { - return retrieveEvents(a, opts, MonitoredEvent_MatchCreated.String(), a.tournament.ParseMatchCreated) +func (a *ITournamentAdapterImpl) StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) { + if err := requirePinnedTournamentRead(opts); err != nil { + return StructuralEventCounts{}, err + } + var counts StructuralEventCounts + for _, counter := range []struct { + name string + read func(*bind.CallOpts) (*big.Int, error) + value **big.Int + }{ + {tournamentEventCommitmentJoined, a.tournament.GetCommitmentJoinedCount, &counts.CommitmentJoined}, + {tournamentEventMatchCreated, a.tournament.GetMatchCreatedCount, &counts.MatchCreated}, + {tournamentEventMatchAdvanced, a.tournament.GetMatchAdvancedCount, &counts.MatchAdvanced}, + {tournamentEventLeafMatchSealed, a.tournament.GetLeafMatchSealedCount, &counts.LeafMatchSealed}, + {tournamentEventMatchDeleted, a.tournament.GetMatchDeletedCount, &counts.MatchDeleted}, + {tournamentEventNewInnerTournament, a.tournament.GetNewInnerTournamentCount, &counts.NewInnerTournament}, + } { + value, err := counter.read(opts) + if err != nil { + return StructuralEventCounts{}, fmt.Errorf("reading %s count: %w", counter.name, err) + } + *counter.value, err = tournamentUint256(counter.name+" count", value) + if err != nil { + return StructuralEventCounts{}, err + } + } + return counts, nil } -func (a *ITournamentAdapterImpl) RetrieveMatchDeletedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchDeleted, error) { - return retrieveEvents(a, opts, MonitoredEvent_MatchDeleted.String(), a.tournament.ParseMatchDeleted) +func (a *ITournamentAdapterImpl) JoinTournament( + opts *bind.TransactOpts, finalState [32]byte, proof [][32]byte, + leftNode [32]byte, rightNode [32]byte, +) (*types.Transaction, error) { + return a.tournament.JoinTournament(opts, finalState, proof, leftNode, rightNode) } -func (a *ITournamentAdapterImpl) RetrieveNewInnerTournamentEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentNewInnerTournament, error) { - return retrieveEvents(a, opts, MonitoredEvent_NewInnerTournament.String(), a.tournament.ParseNewInnerTournament) +func (a *ITournamentAdapterImpl) TryRecoveringBond(opts *bind.TransactOpts) (*types.Transaction, error) { + return a.tournament.TryRecoveringBond(opts) +} + +var tournamentEventNames = [...]string{ + "CommitmentJoined", "MatchAdvanced", "MatchCreated", "MatchDeleted", + "NewInnerTournament", "LeafMatchSealed", "PartialBondRefund", "BondRecovered", } func buildAllEventsFilterQuery( opts *bind.FilterOpts, tournamentAddress common.Address, -) (q ethereum.FilterQuery, err error) { - c, err := itournament.ITournamentMetaData.GetAbi() - if err != nil { - return q, err - } - - topics, err := abi.MakeTopics( - []any{ - c.Events[MonitoredEvent_CommitmentJoined.String()].ID, - c.Events[MonitoredEvent_MatchAdvanced.String()].ID, - c.Events[MonitoredEvent_MatchCreated.String()].ID, - c.Events[MonitoredEvent_MatchDeleted.String()].ID, - c.Events[MonitoredEvent_NewInnerTournament.String()].ID, - }, - ) - if err != nil { - return q, err + contractABI *abi.ABI, +) (ethereum.FilterQuery, error) { + if opts == nil { + return ethereum.FilterQuery{}, errors.New("tournament event filter is nil") } - - q = ethereum.FilterQuery{ + topics := make([]common.Hash, 0, len(tournamentEventNames)) + for _, name := range tournamentEventNames { + event, ok := contractABI.Events[name] + if !ok { + return ethereum.FilterQuery{}, fmt.Errorf("ITournament ABI is missing monitored event %s", name) + } + topics = append(topics, event.ID) + } + q := ethereum.FilterQuery{ Addresses: []common.Address{tournamentAddress}, FromBlock: new(big.Int).SetUint64(opts.Start), - Topics: topics, + Topics: [][]common.Hash{topics}, } if opts.End != nil { q.ToBlock = new(big.Int).SetUint64(*opts.End) } - return q, err + return q, nil } -func (a *ITournamentAdapterImpl) RetrieveAllEvents( - opts *bind.FilterOpts, -) (*TournamentEvents, error) { - q, err := buildAllEventsFilterQuery(opts, a.tournamentAddress) +func (a *ITournamentAdapterImpl) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { + if opts == nil || opts.End == nil || opts.Start > *opts.End { + return nil, errors.New("tournament events require a fixed, nonempty block range") + } + contractABI, err := itournament.ITournamentMetaData.GetAbi() if err != nil { return nil, err } - - itr, err := a.filter.ChunkedFilterLogs(opts.Context, a.client, q) + query, err := buildAllEventsFilterQuery(opts, a.tournamentAddress, contractABI) if err != nil { return nil, err } - - var commitmentJoined []*itournament.ITournamentCommitmentJoined - var matchAdvanced []*itournament.ITournamentMatchAdvanced - var matchCreated []*itournament.ITournamentMatchCreated - var matchDeleted []*itournament.ITournamentMatchDeleted - var newInnerTournament []*itournament.ITournamentNewInnerTournament - - c, err := itournament.ITournamentMetaData.GetAbi() + // The filter owns its chunk sizing. A local copy prevents concurrent reads + // through one adapter from changing each other's filter state. + filter := a.filter + iterator, err := filter.ChunkedFilterLogs(opts.Context, a.client, query) if err != nil { return nil, err } - - for log, err := range itr { + type logPosition struct { + block uint64 + index uint + } + type eventIdentity struct { + transaction common.Hash + index uint + } + positions := make(map[logPosition]struct{}) + identities := make(map[eventIdentity]struct{}) + blockHashes := make(map[uint64]common.Hash) + var logs []types.Log + for entry, err := range iterator { if err != nil { return nil, err } + if entry == nil || entry.Address != a.tournamentAddress || entry.Removed { + return nil, errors.New("tournament event has an invalid address or removed flag") + } + if entry.BlockNumber < opts.Start || entry.BlockNumber > *opts.End { + return nil, errors.New("tournament event is outside the requested block range") + } + if entry.TxHash == (common.Hash{}) || entry.BlockHash == (common.Hash{}) { + return nil, errors.New("tournament event has an incomplete log identity") + } + position := logPosition{entry.BlockNumber, entry.Index} + identity := eventIdentity{entry.TxHash, entry.Index} + if _, exists := positions[position]; exists { + return nil, errors.New("tournament event repeats a block log position") + } + if _, exists := identities[identity]; exists { + return nil, errors.New("tournament event repeats a transaction log identity") + } + if hash, exists := blockHashes[entry.BlockNumber]; exists && hash != entry.BlockHash { + return nil, errors.New("tournament events disagree on the block hash") + } + positions[position] = struct{}{} + identities[identity] = struct{}{} + blockHashes[entry.BlockNumber] = entry.BlockHash + logs = append(logs, *entry) + } + slices.SortFunc(logs, func(one, two types.Log) int { + if order := cmp.Compare(one.BlockNumber, two.BlockNumber); order != 0 { + return order + } + return cmp.Compare(one.Index, two.Index) + }) + events := &TournamentEvents{} + for _, entry := range logs { + if err := a.appendTournamentEvent(contractABI, entry, events); err != nil { + return nil, err + } + } + return events, nil +} - switch log.Topics[0] { - case c.Events[MonitoredEvent_CommitmentJoined.String()].ID: - ev, err := a.tournament.ParseCommitmentJoined(*log) - if err != nil { - return nil, err - } - commitmentJoined = append(commitmentJoined, ev) - case c.Events[MonitoredEvent_MatchAdvanced.String()].ID: - ev, err := a.tournament.ParseMatchAdvanced(*log) - if err != nil { - return nil, err - } - matchAdvanced = append(matchAdvanced, ev) - case c.Events[MonitoredEvent_MatchCreated.String()].ID: - ev, err := a.tournament.ParseMatchCreated(*log) - if err != nil { - return nil, err - } - matchCreated = append(matchCreated, ev) - case c.Events[MonitoredEvent_MatchDeleted.String()].ID: - ev, err := a.tournament.ParseMatchDeleted(*log) - if err != nil { - return nil, err - } - matchDeleted = append(matchDeleted, ev) - case c.Events[MonitoredEvent_NewInnerTournament.String()].ID: - ev, err := a.tournament.ParseNewInnerTournament(*log) - if err != nil { - return nil, err - } - newInnerTournament = append(newInnerTournament, ev) - } - } - - return &TournamentEvents{ - CommitmentJoined: commitmentJoined, - MatchAdvanced: matchAdvanced, - MatchCreated: matchCreated, - MatchDeleted: matchDeleted, - NewInnerTournament: newInnerTournament, - }, nil +func (a *ITournamentAdapterImpl) appendTournamentEvent(contractABI *abi.ABI, entry types.Log, events *TournamentEvents) error { + if len(entry.Topics) == 0 { + return errors.New("tournament event has no topic") + } + definition, err := contractABI.EventByID(entry.Topics[0]) + if err != nil { + return fmt.Errorf("tournament event has unexpected topic %s: %w", entry.Topics[0], err) + } + // Every current tournament event field occupies one static ABI word. + // UnpackLog skips decoding empty data, so validate its exact size first. + const abiWordBytes = 32 + nonIndexed := len(definition.Inputs.NonIndexed()) + if len(entry.Data) != nonIndexed*abiWordBytes || len(entry.Topics) != len(definition.Inputs)-nonIndexed+1 { + return fmt.Errorf("tournament %s event has invalid ABI field lengths", definition.Name) + } + switch entry.Topics[0] { + case contractABI.Events["CommitmentJoined"].ID: + event, err := a.tournament.ParseCommitmentJoined(entry) + if err != nil { + return err + } + events.CommitmentJoined = append(events.CommitmentJoined, event) + case contractABI.Events["MatchAdvanced"].ID: + event, err := a.tournament.ParseMatchAdvanced(entry) + if err != nil { + return err + } + events.MatchAdvanced = append(events.MatchAdvanced, event) + case contractABI.Events["MatchCreated"].ID: + event, err := a.tournament.ParseMatchCreated(entry) + if err != nil { + return err + } + events.MatchCreated = append(events.MatchCreated, event) + case contractABI.Events["MatchDeleted"].ID: + event, err := a.tournament.ParseMatchDeleted(entry) + if err != nil { + return err + } + if _, err := model.MatchDeletionReasonFromUint8(event.Reason); err != nil { + return fmt.Errorf("tournament deletion reason: %w", err) + } + if _, err := model.WinnerCommitmentFromUint8(event.WinnerCommitment); err != nil { + return fmt.Errorf("tournament deletion winner: %w", err) + } + events.MatchDeleted = append(events.MatchDeleted, event) + case contractABI.Events["NewInnerTournament"].ID: + event, err := a.tournament.ParseNewInnerTournament(entry) + if err != nil { + return err + } + events.NewInnerTournament = append(events.NewInnerTournament, event) + case contractABI.Events["LeafMatchSealed"].ID: + event, err := a.tournament.ParseLeafMatchSealed(entry) + if err != nil { + return err + } + events.LeafMatchSealed = append(events.LeafMatchSealed, event) + case contractABI.Events["PartialBondRefund"].ID: + event, err := a.tournament.ParsePartialBondRefund(entry) + if err != nil { + return err + } + events.PartialBondRefund = append(events.PartialBondRefund, event) + case contractABI.Events["BondRecovered"].ID: + event, err := a.tournament.ParseBondRecovered(entry) + if err != nil { + return err + } + events.BondRecovered = append(events.BondRecovered, event) + default: + return fmt.Errorf("tournament event has unexpected topic %s", entry.Topics[0]) + } + return nil } diff --git a/internal/prt/prt.go b/internal/prt/prt.go index 88c375154..9521726e0 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -15,6 +15,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/merkle" @@ -36,19 +37,21 @@ type prtRepository interface { ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool) ([]*Epoch, uint64, error) GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) - UpdateEpochStatus(ctx context.Context, nameOrAddress string, e *Epoch) error + UpdateEpochReconciledStaged(ctx context.Context, applicationID int64, index uint64, stagedAtBlock uint64) error + UpdateEpochWithAcceptedClaim(ctx context.Context, applicationID int64, index uint64, txHash *common.Hash) error - CreateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) - UpdateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error ListTournaments(ctx context.Context, nameOrAddress string, f repository.TournamentFilter, p repository.Pagination, descending bool) ([]*Tournament, uint64, error) - StoreTournamentEvents(ctx context.Context, appID int64, commitments []*Commitment, matches []*Match, - matchAdvanced []*MatchAdvanced, matchDeleted []*Match, lastBlock uint64) error + StoreTournamentEvents(ctx context.Context, appID int64, batches []*repository.TournamentEventBatch, lastBlock uint64) error GetCommitment(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, commitmentHex string) (*Commitment, error) + ListCommitments(ctx context.Context, nameOrAddress string, filter repository.CommitmentFilter, + pagination repository.Pagination, descending bool) ([]*Commitment, uint64, error) + ListMatches(ctx context.Context, nameOrAddress string, filter repository.MatchFilter, + pagination repository.Pagination, descending bool) ([]*Match, uint64, error) SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) @@ -59,6 +62,7 @@ type EthClientInterface interface { TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) ChainID(ctx context.Context) (*big.Int, error) BlockNumber(ctx context.Context) (uint64, error) + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) TransactionByHash(ctx context.Context, hash common.Hash) (*types.Transaction, bool, error) } diff --git a/internal/prt/service.go b/internal/prt/service.go index b3153e11b..132e2685e 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "math/big" + "sync" "time" "github.com/cartesi/rollups-node/internal/config" @@ -28,16 +29,20 @@ type CreateInfo struct { type Service struct { service.TickServiceTemplate - repository prtRepository - client EthClientInterface - adapterFactory AdapterFactory - submissionEnabled bool - submissionTimeout time.Duration - filter ethutil.Filter - txOptsFactory ethutil.TransactOptsFactory - currentEpochIndex map[int64]uint64 // application.ID -> epochIndex - settleInFlight map[int64]*common.Hash // application.ID -> txHash - joinInFlight map[int64]*common.Hash // application.ID -> txHash + repository prtRepository + client EthClientInterface + adapterFactory AdapterFactory + submissionEnabled bool + defaultBlock DefaultBlock + submissionTimeout time.Duration + filter ethutil.Filter + txOptsFactory ethutil.TransactOptsFactory // Set by Create whenever submission is enabled. + pendingTransactions map[int64]pendingTournamentTransaction // application.ID -> pending action + disputeWarnings map[common.Address]struct{} + zeroStagingWarnings map[int64]struct{} + rootBondRecoveries map[int64][]*rootBondRecovery + observationHealthMu sync.RWMutex // Ready runs concurrently with the observation loop. + observationFailures map[int64]tournamentObservationFailure } const PrtConfigKey = "prt" diff --git a/internal/prt/state_proof_test.go b/internal/prt/state_proof_test.go new file mode 100644 index 000000000..22c20d1c6 --- /dev/null +++ b/internal/prt/state_proof_test.go @@ -0,0 +1,39 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestStageTournamentResultEncodesModelProof(t *testing.T) { + proof := repotest.KeccakStateProof(common.HexToHash("0xab")) + contract, err := idaveconsensus.NewIDaveConsensus(common.HexToAddress("0x1234"), nil) + require.NoError(t, err) + adapter := &DaveConsensusAdapterImpl{consensus: contract} + opts := &bind.TransactOpts{ + Context: t.Context(), Nonce: big.NewInt(0), GasPrice: big.NewInt(1), GasLimit: 1_000_000, + NoSend: true, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + tx, err := adapter.StageTournamentResult(opts, 3, proof) + require.NoError(t, err) + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + want, err := contractABI.Pack("stageTournamentResult", big.NewInt(3), idaveconsensus.MachineValidityProof{ + IflagsYProof: idaveconsensus.LeafProof{DataBlock: proof.IflagsYDataBlock, Siblings: proof.IflagsYProof}, + HtifTohostProof: idaveconsensus.LeafProof{DataBlock: proof.HtifTohostDataBlock, Siblings: proof.HtifTohostProof}, + TxBufferProof: idaveconsensus.LeafProof{DataBlock: proof.TxBufferDataBlock, Siblings: proof.TxBufferProof}, + }) + require.NoError(t, err) + require.Equal(t, want, tx.Data()) +} diff --git a/internal/prt/tournament_views_test.go b/internal/prt/tournament_views_test.go new file mode 100644 index 000000000..9d0a93c86 --- /dev/null +++ b/internal/prt/tournament_views_test.go @@ -0,0 +1,385 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type tournamentViewRPC struct{ mock.Mock } + +func (m *tournamentViewRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +type tournamentViewFixture struct { + t *testing.T + adapter TournamentAdapter + backend *tournamentViewRPC + abi *abi.ABI + address common.Address + opts *bind.CallOpts +} + +func newTournamentViewFixture(t *testing.T) *tournamentViewFixture { + t.Helper() + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + backend := &tournamentViewRPC{} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + address := common.HexToAddress("0x1234") + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) + require.NoError(t, err) + t.Cleanup(func() { backend.AssertExpectations(t) }) + return &tournamentViewFixture{t: t, adapter: adapter, backend: backend, abi: contractABI, + address: address, opts: &bind.CallOpts{Context: t.Context(), BlockNumber: big.NewInt(200)}} +} + +func (f *tournamentViewFixture) expect(name string, inputs []any, outputs ...any) { + f.t.Helper() + encoded, err := f.abi.Methods[name].Outputs.Pack(outputs...) + require.NoError(f.t, err) + f.expectBytes(name, inputs, encoded, nil) +} + +func (f *tournamentViewFixture) expectBytes(name string, inputs []any, output []byte, callErr error) { + f.t.Helper() + input, err := f.abi.Pack(name, inputs...) + require.NoError(f.t, err) + f.backend.On("Call", f.address, input, rpc.BlockNumber(f.opts.BlockNumber.Int64())).Return(output, callErr).Once() +} + +func TestTournamentAdapterDecodesCompleteViews(t *testing.T) { + f := newTournamentViewFixture(t) + one, two, three := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + large := new(big.Int).Lsh(big.NewInt(1), 200) + f.expect("tournamentDescriptor", nil, itournament.ITournamentTournamentDescriptor{ + InitialHash: one, BaseCycle: large, Log2Stride: 3, Height: 4, Level: 5, Kind: 1, StartInstant: 6, Allowance: 7, + }) + descriptor, err := f.adapter.Descriptor(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentDescriptor{InitialHash: one, BaseCycle: large, Log2Stride: 3, Height: 4, Level: 5, + Kind: model.TournamentKindNonLeaf, StartInstant: 6, Allowance: 7}, descriptor) + + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{ + Standing: 4, HasCandidate: true, Candidate: one, FinalState: two, ParentCommitment: three, FinishedAt: 8, WinnerExpiresAt: 9, + }) + standing, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentStanding{State: model.TournamentStandingInnerWinner, HasCandidate: true, + Candidate: one, FinalState: two, ParentCommitment: three, FinishedAt: 8, WinnerExpiresAt: 9}, standing) + + f.expect("commitmentStanding", []any{one}, itournament.ITournamentCommitmentStandingView{ + Joined: true, FinalState: two, Claimer: f.address, ClockRunning: true, ClockDeadline: 11, ClockAllowance: 12, + }) + commitment, err := f.adapter.CommitmentStanding(f.opts, one) + require.NoError(t, err) + require.Equal(t, CommitmentStanding{Joined: true, FinalState: two, Claimer: f.address, + ClockRunning: true, ClockDeadline: 11, ClockAllowance: 12}, commitment) + + f.expect("innerResult", nil, itournament.ITournamentInnerResultView{Disposition: 1, ParentCommitment: three, PausedAllowance: 13}) + inner, err := f.adapter.InnerResult(f.opts) + require.NoError(t, err) + require.Equal(t, InnerResult{Disposition: model.InnerTournamentWinner, ParentCommitment: three, PausedAllowance: 13}, inner) + f.expect("bondRecovery", nil, uint8(2), f.address, large) + bond, err := f.adapter.BondRecovery(f.opts) + require.NoError(t, err) + require.Equal(t, BondRecovery{Disposition: model.BondDispositionRecoverable, Claimer: f.address, Payment: large}, bond) + f.expect("bondValue", nil, large) + value, err := f.adapter.BondValue(f.opts) + require.NoError(t, err) + require.Equal(t, large, value) +} + +func TestTournamentAdapterReadsSixDistinctCounters(t *testing.T) { + f := newTournamentViewFixture(t) + names := []string{"getCommitmentJoinedCount", "getMatchCreatedCount", "getMatchAdvancedCount", + "getLeafMatchSealedCount", "getMatchDeletedCount", "getNewInnerTournamentCount"} + values := make([]*big.Int, len(names)) + for index, name := range names { + values[index] = new(big.Int).Lsh(big.NewInt(1), 200+uint(index)) + f.expect(name, nil, values[index]) + } + counts, err := f.adapter.StructuralEventCounts(f.opts) + require.NoError(t, err) + require.Equal(t, StructuralEventCounts{CommitmentJoined: values[0], MatchCreated: values[1], MatchAdvanced: values[2], + LeafMatchSealed: values[3], MatchDeleted: values[4], NewInnerTournament: values[5]}, counts) +} + +func TestTournamentAdapterDecodesStandingOrdinalsAndTimeOnlyChanges(t *testing.T) { + states := []struct { + ordinal uint8 + state model.TournamentStandingState + }{ + {0, model.TournamentStandingMatchesActive}, {1, model.TournamentStandingAwaitingClosure}, + {2, model.TournamentStandingRootWinner}, {3, model.TournamentStandingRootFailed}, {4, model.TournamentStandingInnerWinner}, + {5, model.TournamentStandingInnerEliminableNoWinner}, {6, model.TournamentStandingInnerEliminableWinnerExpired}, + } + for _, test := range states { + t.Run(string(test.state), func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{Standing: test.ordinal}) + standing, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, test.state, standing.State) + }) + } + f := newTournamentViewFixture(t) + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{AcceptsJoins: true}) + open, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.True(t, open.AcceptsJoins) + require.False(t, open.HasCandidate) + one := common.HexToHash("0x11") + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{ + Standing: 6, HasCandidate: true, Candidate: one, FinishedAt: 100, + }) + expired, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentStanding{State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: one, FinishedAt: 100}, expired) +} + +func TestTournamentAdapterDecodesInactiveViews(t *testing.T) { + f := newTournamentViewFixture(t) + one := common.HexToHash("0x11") + for _, binding := range []itournament.ITournamentCommitmentStandingView{ + {}, {Joined: true, FinalState: one, ClockAllowance: 12}, + } { + f.expect("commitmentStanding", []any{one}, binding) + standing, err := f.adapter.CommitmentStanding(f.opts, one) + require.NoError(t, err) + require.Equal(t, binding.Joined, standing.Joined) + require.False(t, standing.ClockRunning) + require.Zero(t, standing.ClockDeadline) + require.Equal(t, binding.ClockAllowance, standing.ClockAllowance) + require.Zero(t, standing.Claimer, "bond recovery can clear the claimer without clearing the join record") + } + for _, test := range []struct { + raw uint8 + want model.InnerTournamentDisposition + }{{0, model.InnerTournamentUnsettled}, {2, model.InnerTournamentEliminable}} { + f.expect("innerResult", nil, itournament.ITournamentInnerResultView{Disposition: test.raw}) + inner, err := f.adapter.InnerResult(f.opts) + require.NoError(t, err) + require.Equal(t, InnerResult{Disposition: test.want}, inner) + } + for _, test := range []struct { + raw uint8 + want model.BondDisposition + }{{0, model.BondDispositionTournamentRunning}, {1, model.BondDispositionNoWinner}, {3, model.BondDispositionRecovered}} { + f.expect("bondRecovery", nil, test.raw, common.Address{}, big.NewInt(0)) + bond, err := f.adapter.BondRecovery(f.opts) + require.NoError(t, err) + require.Equal(t, BondRecovery{Disposition: test.want, Payment: big.NewInt(0)}, bond) + } +} + +func TestTournamentAdapterDecodesEveryMatchPhase(t *testing.T) { + one, two, three := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + position := new(big.Int).Lsh(big.NewInt(1), 100) + cycle := new(big.Int).Lsh(big.NewInt(1), 200) + for _, test := range []struct { + name string + phase uint8 + outcome uint8 + charge uint64 + method string + payload any + want ObservedMatchSnapshot + }{ + {name: "absent", want: ObservedMatchSnapshot{Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone}}, + {name: "bisecting", phase: 1, outcome: 2, charge: 7, method: "bisectingMatch", + payload: itournament.ITournamentBisectingMatchView{RevealingParent: one, WaitingLeft: two, WaitingRight: three, + SegmentStartPosition: position, SegmentStartCycle: cycle, CurrentHeight: 4, Responder: 0}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseBisecting, TimeoutOutcome: model.MatchTimeoutTwoWins, DeferredCharge: 7, + Bisecting: &BisectingMatch{RevealingParent: one, WaitingLeft: two, WaitingRight: three, + SegmentStartPosition: position, SegmentStartCycle: cycle, CurrentHeight: 4, Responder: model.CommitmentSideOne}}}, + {name: "ready to seal", phase: 2, outcome: 1, charge: 8, method: "readyToSealMatch", + payload: itournament.ITournamentReadyToSealMatchView{RevealingParent: three, WaitingLeft: two, WaitingRight: one, + SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: 1}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseReadyToSeal, TimeoutOutcome: model.MatchTimeoutOneWins, DeferredCharge: 8, + ReadyToSeal: &ReadyToSealMatch{RevealingParent: three, WaitingLeft: two, WaitingRight: one, + SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: model.CommitmentSideTwo}}}, + {name: "sealed", phase: 3, outcome: 3, method: "sealedMatch", + payload: itournament.ITournamentSealedMatchView{AgreeState: one, DivergencePosition: position, + DivergenceCycle: cycle, FinalStateOne: two, FinalStateTwo: three}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseSealed, TimeoutOutcome: model.MatchTimeoutEliminateBoth, + Sealed: &SealedMatch{AgreeState: one, DivergencePosition: position, DivergenceCycle: cycle, + FinalStateOne: two, FinalStateTwo: three}}}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, test.outcome, test.charge) + if test.method != "" { + f.expect(test.method, []any{crypto.Keccak256Hash(one[:], two[:])}, test.phase, test.payload) + } + snapshot, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.NoError(t, err) + require.Equal(t, test.want, snapshot) + }) + } +} + +func TestTournamentAdapterRejectsUnknownViewEnums(t *testing.T) { + for _, test := range []struct { + name string + method string + output []any + read func(TournamentAdapter, *bind.CallOpts) error + }{ + {"kind", "tournamentDescriptor", []any{itournament.ITournamentTournamentDescriptor{Kind: 2, BaseCycle: big.NewInt(0)}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.Descriptor(o); return err }}, + {"standing", "tournamentStanding", []any{itournament.ITournamentTournamentStandingView{Standing: 7}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.Standing(o); return err }}, + {"inner result", "innerResult", []any{itournament.ITournamentInnerResultView{Disposition: 3}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.InnerResult(o); return err }}, + {"bond", "bondRecovery", []any{uint8(4), common.Address{}, big.NewInt(0)}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.BondRecovery(o); return err }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect(test.method, nil, test.output...) + require.ErrorContains(t, test.read(f.adapter, f.opts), "unknown") + }) + } +} + +func TestTournamentAdapterRejectsInvalidMatchViews(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + name string + phase uint8 + outcome uint8 + charge uint64 + projectionPhase uint8 + responder uint8 + readProjection bool + }{ + {name: "unknown phase", phase: 4}, + {name: "unknown timeout", phase: 1, outcome: 4}, + {name: "absent timeout", outcome: 1}, + {name: "inactive charge", phase: 1, charge: 1}, + {name: "different pinned phase", phase: 1, projectionPhase: 2, readProjection: true}, + {name: "unknown responder", phase: 1, projectionPhase: 1, responder: 2, readProjection: true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, test.outcome, test.charge) + if test.readProjection { + f.expect("bisectingMatch", []any{crypto.Keccak256Hash(one[:], two[:])}, test.projectionPhase, + itournament.ITournamentBisectingMatchView{SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0), + CurrentHeight: 2, Responder: test.responder}) + } + snapshot, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.Error(t, err) + require.Equal(t, ObservedMatchSnapshot{}, snapshot) + }) + } +} + +func TestTournamentReadsRejectUnpinnedSnapshots(t *testing.T) { + for _, opts := range []*bind.CallOpts{nil, {}, {BlockNumber: big.NewInt(-1)}, {BlockNumber: big.NewInt(10), Pending: true}} { + f := newTournamentViewFixture(t) + _, err := f.adapter.StructuralEventCounts(opts) + require.ErrorContains(t, err, "pinned block") + _, err = f.adapter.MatchSnapshot(opts, [32]byte{}, [32]byte{}) + require.ErrorContains(t, err, "pinned block") + } +} + +func TestTournamentAdapterChecksEachPinnedProjectionPhase(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + phase uint8 + method string + payload any + }{ + {1, "bisectingMatch", itournament.ITournamentBisectingMatchView{ + SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, + {2, "readyToSealMatch", itournament.ITournamentReadyToSealMatchView{ + SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, + {3, "sealedMatch", itournament.ITournamentSealedMatchView{ + DivergencePosition: big.NewInt(0), DivergenceCycle: big.NewInt(0)}}, + } { + t.Run(test.method, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, uint8(0), uint64(0)) + f.expect(test.method, []any{crypto.Keccak256Hash(one[:], two[:])}, uint8(0), test.payload) + _, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.ErrorContains(t, err, "phase changed") + }) + } +} + +func TestTournamentReadsPropagateRPCAndScalarErrors(t *testing.T) { + t.Run("counter read error", func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("getCommitmentJoinedCount", nil, big.NewInt(1)) + f.expectBytes("getMatchCreatedCount", nil, nil, errors.New("provider unavailable")) + counts, err := f.adapter.StructuralEventCounts(f.opts) + require.ErrorContains(t, err, "provider unavailable") + require.Equal(t, StructuralEventCounts{}, counts) + }) + t.Run("uint64 overflow", func(t *testing.T) { + f := newTournamentViewFixture(t) + encoded, err := f.abi.Methods["innerResult"].Outputs.Pack(itournament.ITournamentInnerResultView{Disposition: 1}) + require.NoError(t, err) + const allowanceWordStart = 64 + encoded[allowanceWordStart] = 1 + f.expectBytes("innerResult", nil, encoded, nil) + _, err = f.adapter.InnerResult(f.opts) + require.Error(t, err) + }) +} + +func TestTournamentUint256BoundsAndOwnership(t *testing.T) { + maximum := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) + for _, value := range []*big.Int{nil, big.NewInt(-1), new(big.Int).Add(maximum, big.NewInt(1))} { + _, err := tournamentUint256("test", value) + require.Error(t, err) + } + copiedAmount, err := tournamentUint256("test", maximum) + require.NoError(t, err) + require.Equal(t, maximum, copiedAmount) + copiedAmount.SetInt64(0) + require.NotZero(t, maximum.Sign(), "the DTO owns its integer") +} diff --git a/internal/prt/types.go b/internal/prt/types.go index 490cb64dd..932ca1d0d 100644 --- a/internal/prt/types.go +++ b/internal/prt/types.go @@ -6,6 +6,8 @@ package prt import ( "math/big" + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -14,44 +16,165 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/itournament" ) -type TournamentConstants struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 +// TournamentDescriptor is the immutable tournament configuration exposed by +// the Dave tournament contract. +type TournamentDescriptor struct { + InitialHash common.Hash + BaseCycle *big.Int + Log2Stride uint64 + Height uint64 + Level uint64 + Kind model.TournamentKind + StartInstant uint64 + Allowance uint64 +} + +// TournamentStanding is the current tournament result and timing projection. +type TournamentStanding struct { + State model.TournamentStandingState + AcceptsJoins bool + HasCandidate bool + Candidate common.Hash + FinalState common.Hash + ParentCommitment common.Hash + FinishedAt uint64 + WinnerExpiresAt uint64 +} + +// CommitmentStanding is the current on-chain record for one commitment. +type CommitmentStanding struct { + Joined bool + FinalState common.Hash + Claimer common.Address + ClockRunning bool + ClockDeadline uint64 + ClockAllowance uint64 +} + +// BisectingMatch is the current divergence frontier before the final opening. +type BisectingMatch struct { + RevealingParent common.Hash + WaitingLeft common.Hash + WaitingRight common.Hash + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + CurrentHeight uint64 + Responder model.CommitmentSide +} + +// ReadyToSealMatch is the final divergence frontier, before sealing. +type ReadyToSealMatch struct { + RevealingParent common.Hash + WaitingLeft common.Hash + WaitingRight common.Hash + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + Responder model.CommitmentSide +} + +// SealedMatch stores states in commitment-side order, not reveal order. +type SealedMatch struct { + AgreeState common.Hash + DivergencePosition *big.Int + DivergenceCycle *big.Int + FinalStateOne common.Hash + FinalStateTwo common.Hash +} + +// ObservedMatchSnapshot contains only the payload selected by Phase. Deleted matches +// have phase UNINITIALIZED and no payload; their events remain historical data. +type ObservedMatchSnapshot struct { + Phase model.MatchPhase + TimeoutOutcome model.MatchTimeoutOutcome + DeferredCharge uint64 + Bisecting *BisectingMatch + ReadyToSeal *ReadyToSealMatch + Sealed *SealedMatch +} + +// InnerResult includes the carryover allowance at the observation block. +type InnerResult struct { + Disposition model.InnerTournamentDisposition + ParentCommitment common.Hash + PausedAllowance uint64 +} + +// StructuralEventCounts contains six independent on-chain counters. Financial +// events have no dedicated counter in this contract version. +type StructuralEventCounts struct { + CommitmentJoined *big.Int + MatchCreated *big.Int + MatchAdvanced *big.Int + LeafMatchSealed *big.Int + MatchDeleted *big.Int + NewInnerTournament *big.Int +} + +// BondRecovery is the current recovery state of a tournament bond. +type BondRecovery struct { + Disposition model.BondDisposition + Claimer common.Address + Payment *big.Int } // TournamentAdapter provides read and write access to tournament contracts. type TournamentAdapter interface { - RetrieveCommitmentJoinedEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentCommitmentJoined, error) - RetrieveMatchAdvancedEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentMatchAdvanced, error) - RetrieveMatchCreatedEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentMatchCreated, error) - RetrieveMatchDeletedEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentMatchDeleted, error) - RetrieveNewInnerTournamentEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentNewInnerTournament, error) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) - Result(opts *bind.CallOpts) (bool, [32]byte, [32]byte, error) - Constants(opts *bind.CallOpts) (TournamentConstants, error) - TimeFinished(opts *bind.CallOpts) (bool, uint64, error) + Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) + Standing(opts *bind.CallOpts) (TournamentStanding, error) + CommitmentStanding(opts *bind.CallOpts, commitmentRoot [32]byte) (CommitmentStanding, error) + MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) + InnerResult(opts *bind.CallOpts) (InnerResult, error) + StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) BondValue(opts *bind.CallOpts) (*big.Int, error) - IsCommitmentJoined(opts *bind.CallOpts, commitmentRoot [32]byte) (bool, error) + BondRecovery(opts *bind.CallOpts) (BondRecovery, error) JoinTournament(opts *bind.TransactOpts, finalState [32]byte, proof [][32]byte, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) + TryRecoveringBond(opts *bind.TransactOpts) (*types.Transaction, error) } // DaveConsensusAdapter wraps access to the IDaveConsensus contract. type DaveConsensusAdapter interface { ParseEpochSealed(log types.Log) (*idaveconsensus.IDaveConsensusEpochSealed, error) - CanSettle(opts *bind.CallOpts) (CanSettleResult, error) - IsEpochSettled(opts *bind.CallOpts, epochNumber uint64) (bool, error) - Settle(opts *bind.TransactOpts, epochNumber *big.Int, - outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) + TournamentLevelCount(opts *bind.CallOpts) (uint64, error) + GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) + CanStageTournamentResult(opts *bind.CallOpts) (CanStageTournamentResult, error) + CanAcceptStagedTournamentResult(opts *bind.CallOpts) (CanAcceptStagedTournamentResult, error) + StageTournamentResult(opts *bind.TransactOpts, epochNumber uint64, + proof model.StateProof) (*types.Transaction, error) + AcceptStagedTournamentResult(opts *bind.TransactOpts, epochNumber uint64) (*types.Transaction, error) +} + +// CurrentSealedEpoch is the complete state of the current sealed epoch. +type CurrentSealedEpoch struct { + EpochNumber uint64 + InputIndexLowerBound uint64 + InputIndexUpperBound uint64 + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber uint64 + StagedPostEpochMachineStateHash common.Hash + StagedPostEpochOutputsMerkleRoot common.Hash +} + +// CanStageTournamentResult is the stage readiness view for the current sealed epoch. +type CanStageTournamentResult struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber uint64 + WinnerCommitment common.Hash + WinnerPostEpochMachineStateHash common.Hash } -// CanSettleResult holds the result of a CanSettle call. -type CanSettleResult struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte +// CanAcceptStagedTournamentResult is the accept readiness view for the current sealed epoch. +type CanAcceptStagedTournamentResult struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber uint64 + StagedPostEpochMachineStateHash common.Hash + StagedPostEpochOutputsMerkleRoot common.Hash } // AdapterFactory creates contract adapters from on-chain addresses. @@ -67,25 +190,22 @@ type TournamentEvents struct { MatchCreated []*itournament.ITournamentMatchCreated MatchDeleted []*itournament.ITournamentMatchDeleted NewInnerTournament []*itournament.ITournamentNewInnerTournament + LeafMatchSealed []*itournament.ITournamentLeafMatchSealed + PartialBondRefund []*itournament.ITournamentPartialBondRefund + BondRecovered []*itournament.ITournamentBondRecovered } type TournamentLevel uint64 const ( RootLevel TournamentLevel = iota - MiddleLevel - BottomLevel ) func (l TournamentLevel) String() string { switch l { case RootLevel: return "root" - case MiddleLevel: - return "middle" - case BottomLevel: - return "bottom" default: - return "unknown" + return "inner" } } From 591576390b7224194280513ab7a422e219acc153 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:18 -0300 Subject: [PATCH 33/41] feat(prt): build complete dispute observations Build complete root and child tournament observations without writing the database or sending transactions. Read event history and current contract views at the same block, then construct the repository batches. Validate descriptors, standing, structural event counts, and financial events. Preserve deletion evidence for matches whose contract storage was removed. Stop repeated reads of a clone only after a complete committed observation proves its views are immutable. Continue checking its children; a finished parent does not make its descendants immutable. Replace the old gathering and partial-save helpers with this final path. Tests cover projections, deleted matches, event completeness, retirement, and warning behavior. Publication and local claim decisions are separate responsibilities in the next commits. --- internal/prt/current_projection_test.go | 146 ++++ internal/prt/deleted_match_projection_test.go | 292 +++++++ internal/prt/event_batch_test.go | 190 +++++ internal/prt/observation_counts.go | 82 ++ internal/prt/observation_counts_test.go | 207 +++++ internal/prt/observation_projection.go | 272 ++++++ internal/prt/observation_retirement_test.go | 86 ++ internal/prt/observer_projection_test.go | 296 +++++++ internal/prt/observer_test.go | 367 ++++++++ internal/prt/prt.go | 793 ++++++++++-------- internal/prt/warnings.go | 31 + internal/prt/warnings_test.go | 107 +++ 12 files changed, 2518 insertions(+), 351 deletions(-) create mode 100644 internal/prt/current_projection_test.go create mode 100644 internal/prt/deleted_match_projection_test.go create mode 100644 internal/prt/event_batch_test.go create mode 100644 internal/prt/observation_counts.go create mode 100644 internal/prt/observation_counts_test.go create mode 100644 internal/prt/observation_projection.go create mode 100644 internal/prt/observation_retirement_test.go create mode 100644 internal/prt/observer_projection_test.go create mode 100644 internal/prt/observer_test.go create mode 100644 internal/prt/warnings.go create mode 100644 internal/prt/warnings_test.go diff --git a/internal/prt/current_projection_test.go b/internal/prt/current_projection_test.go new file mode 100644 index 000000000..7067e5547 --- /dev/null +++ b/internal/prt/current_projection_test.go @@ -0,0 +1,146 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestMatchProjectionPreservesCurrentPhaseAndFullWidth(t *testing.T) { + position := new(big.Int).Lsh(big.NewInt(1), 200) + cycle := new(big.Int).Add(position, big.NewInt(3)) + left, right, parent := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + for _, phase := range []MatchPhase{MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed, MatchPhaseUninitialized} { + t.Run(string(phase), func(t *testing.T) { + view := ObservedMatchSnapshot{Phase: phase, TimeoutOutcome: MatchTimeoutTwoWins, DeferredCharge: 7} + switch phase { + case MatchPhaseBisecting: + view.Bisecting = &BisectingMatch{RevealingParent: parent, WaitingLeft: left, WaitingRight: right, + SegmentStartPosition: new(big.Int).Set(position), SegmentStartCycle: new(big.Int).Set(cycle), + CurrentHeight: 0, Responder: CommitmentSideTwo} + case MatchPhaseReadyToSeal: + view.ReadyToSeal = &ReadyToSealMatch{RevealingParent: parent, WaitingLeft: left, WaitingRight: right, + SegmentStartPosition: new(big.Int).Set(position), SegmentStartCycle: new(big.Int).Set(cycle), + Responder: CommitmentSideOne} + case MatchPhaseSealed: + view.Sealed = &SealedMatch{AgreeState: parent, FinalStateOne: left, FinalStateTwo: right, + DivergencePosition: new(big.Int).Set(position), DivergenceCycle: new(big.Int).Set(cycle)} + case MatchPhaseUninitialized: + view.TimeoutOutcome, view.DeferredCharge = MatchTimeoutNone, 0 + } + projection, err := projectMatchSnapshot(view, 100, phase == MatchPhaseUninitialized) + require.NoError(t, err) + require.Equal(t, uint64(100), projection.AsOfBlock) + require.Equal(t, phase, projection.Phase) + require.Equal(t, view.TimeoutOutcome, projection.TimeoutOutcome) + require.Equal(t, view.DeferredCharge, projection.DeferredCharge) + switch phase { + case MatchPhaseBisecting, MatchPhaseReadyToSeal: + require.NotNil(t, projection.Bisection) + require.Nil(t, projection.Sealed) + require.Equal(t, parent, projection.Bisection.RevealingParent) + require.Equal(t, left, projection.Bisection.WaitingLeft) + require.Equal(t, right, projection.Bisection.WaitingRight) + require.Equal(t, position, projection.Bisection.SegmentStartPosition.ToBig()) + require.Equal(t, cycle, projection.Bisection.SegmentStartCycle.ToBig()) + if phase == MatchPhaseBisecting { + require.NotNil(t, projection.Bisection.CurrentHeight, "zero height is present in BISECTING") + require.Zero(t, *projection.Bisection.CurrentHeight) + require.Equal(t, CommitmentSideTwo, projection.Bisection.Responder) + view.Bisecting.SegmentStartPosition.SetUint64(0) + } else { + require.Nil(t, projection.Bisection.CurrentHeight) + require.Equal(t, CommitmentSideOne, projection.Bisection.Responder) + view.ReadyToSeal.SegmentStartPosition.SetUint64(0) + } + require.Equal(t, position, projection.Bisection.SegmentStartPosition.ToBig()) + case MatchPhaseSealed: + require.Nil(t, projection.Bisection) + require.Equal(t, parent, projection.Sealed.AgreeState) + require.Equal(t, left, projection.Sealed.FinalStateOne) + require.Equal(t, right, projection.Sealed.FinalStateTwo) + require.Equal(t, position, projection.Sealed.DivergencePosition.ToBig()) + require.Equal(t, cycle, projection.Sealed.DivergenceCycle.ToBig()) + view.Sealed.DivergencePosition.SetUint64(0) + require.Equal(t, position, projection.Sealed.DivergencePosition.ToBig()) + case MatchPhaseUninitialized: + require.Nil(t, projection.Bisection) + require.Nil(t, projection.Sealed) + } + }) + } +} + +func TestMatchProjectionRejectsInvalidPhasePayloads(t *testing.T) { + for _, view := range []ObservedMatchSnapshot{ + {Phase: unknownEnumValue}, + {Phase: MatchPhaseBisecting}, + {Phase: MatchPhaseReadyToSeal}, + {Phase: MatchPhaseSealed}, + {Phase: MatchPhaseBisecting, Bisecting: &BisectingMatch{SegmentStartPosition: big.NewInt(-1), SegmentStartCycle: new(big.Int)}}, + {Phase: MatchPhaseReadyToSeal, ReadyToSeal: &ReadyToSealMatch{SegmentStartPosition: new(big.Int), SegmentStartCycle: nil}}, + {Phase: MatchPhaseSealed, Sealed: &SealedMatch{ + DivergencePosition: new(big.Int).Lsh(big.NewInt(1), 256), DivergenceCycle: new(big.Int)}}, + {Phase: MatchPhaseBisecting, Bisecting: &BisectingMatch{}, Sealed: &SealedMatch{}}, + {Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutOneWins}, + } { + _, err := projectMatchSnapshot(view, 100, false) + require.Error(t, err) + } + _, err := projectMatchSnapshot(ObservedMatchSnapshot{Phase: MatchPhaseBisecting}, 100, true) + require.ErrorContains(t, err, "deleted match") + _, err = projectMatchSnapshot(ObservedMatchSnapshot{Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone}, 100, false) + require.ErrorContains(t, err, "no matching deletion") +} + +func TestInnerCurrentProjectionExpiresWithoutRestoringHistoricalWinner(t *testing.T) { + s, repo := newPRTServiceMock() + app, epoch := prtRevertTestApp(), checkpointEpoch(0, "0x100") + projection := &Tournament{Address: common.HexToAddress("0x101"), Level: 1, MaxLevel: 2} + adapter := &tournamentAdapterMock{} + candidate, parent, final := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + for _, block := range []uint64{100, 101, 102} { + opts := mock.MatchedBy(resultCallOptsAtBlock(block)) + standing := TournamentStanding{State: TournamentStandingInnerWinner, HasCandidate: true, Candidate: candidate, + FinalState: final, ParentCommitment: parent, FinishedAt: 90, WinnerExpiresAt: 102} + inner := InnerResult{Disposition: InnerTournamentWinner, ParentCommitment: parent, PausedAllowance: 102 - block} + if block == 102 { + standing.State, standing.FinalState, standing.ParentCommitment, standing.WinnerExpiresAt = + TournamentStandingInnerEliminableWinnerExpired, common.Hash{}, common.Hash{}, 0 + inner = InnerResult{Disposition: InnerTournamentEliminable} + } + adapter.On("Standing", opts).Return(standing, nil).Once() + adapter.On("InnerResult", opts).Return(inner, nil).Once() + adapter.On("BondRecovery", opts). + Return(canonicalBondRecovery(BondDispositionRecoverable, common.HexToAddress("0x777"), 0), nil).Once() + require.NoError(t, s.updateTournamentStanding(t.Context(), app, epoch, 1, adapter, projection, block)) + require.Equal(t, block, projection.Snapshot.AsOfBlock) + require.Equal(t, candidate, *projection.Snapshot.Candidate) + require.NotNil(t, projection.Snapshot.BondRecovery.Payment, "zero recovery amount is still present") + if block < 102 { + require.Equal(t, candidate, *projection.Snapshot.WinnerCommitment) + require.Equal(t, final, *projection.Snapshot.FinalStateHash) + require.Equal(t, parent, *projection.Snapshot.ParentCommitment) + require.Equal(t, uint64(102), projection.Snapshot.WinnerExpiresAt) + require.Equal(t, uint64(102-block), projection.Snapshot.InnerResult.PausedAllowance) + } else { + require.Nil(t, projection.Snapshot.WinnerCommitment) + require.Nil(t, projection.Snapshot.FinalStateHash) + require.Nil(t, projection.Snapshot.ParentCommitment) + require.Zero(t, projection.Snapshot.WinnerExpiresAt) + require.Equal(t, InnerTournamentEliminable, projection.Snapshot.InnerResult.Disposition) + require.Nil(t, projection.Snapshot.InnerResult.ParentCommitment) + require.Zero(t, projection.Snapshot.InnerResult.PausedAllowance) + } + } + require.Empty(t, repo.Calls) + adapter.AssertNotCalled(t, "CommitmentStanding", mock.Anything, mock.Anything) + adapter.AssertExpectations(t) +} diff --git a/internal/prt/deleted_match_projection_test.go b/internal/prt/deleted_match_projection_test.go new file mode 100644 index 000000000..e9b6a1b6f --- /dev/null +++ b/internal/prt/deleted_match_projection_test.go @@ -0,0 +1,292 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func certifiedDeletedMatch() *model.Match { + return &model.Match{ + IDHash: common.HexToHash("0xabc"), TournamentAddress: common.HexToAddress("0x100"), + CommitmentOne: common.HexToHash("0x11"), CommitmentTwo: common.HexToHash("0x22"), + LeftOfTwo: common.HexToHash("0x33"), BlockNumber: 20, TxHash: common.HexToHash("0x44"), + LogIndex: 2, EliminableAt: 30, Winner: model.WinnerCommitment_ONE, + DeletionReason: model.MatchDeletionReason_TIMEOUT, DeletionBlockNumber: 35, + DeletionTxHash: new(common.HexToHash("0x55")), DeletionLogIndex: new(uint64(3)), + Snapshot: model.MatchSnapshot{AsOfBlock: 40, Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone}, + CreatedAt: time.Unix(20, 0), UpdatedAt: time.Unix(40, 0), + } +} + +func matchDeletionEvent(match *model.Match) *itournament.ITournamentMatchDeleted { + return &itournament.ITournamentMatchDeleted{ + MatchIdHash: match.IDHash, One: match.CommitmentOne, Two: match.CommitmentTwo, Reason: 1, WinnerCommitment: 1, + Raw: types.Log{BlockNumber: match.DeletionBlockNumber, TxHash: *match.DeletionTxHash, Index: uint(*match.DeletionLogIndex)}, + } +} + +func TestTournamentEventBatchReusesCertifiedDeletionWithoutReadOrWrite(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + commitment := &model.Commitment{Commitment: stored.CommitmentOne, FinalStateHash: common.HexToHash("0x66")} + f := newEventBatchFixture(t, []*model.Commitment{commitment}, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + f.adapter.On("CommitmentStanding", mock.MatchedBy(resultCallOptsAtBlock(100)), [32]byte(commitment.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 7}, nil).Once() + + batch, err := f.project(&TournamentEvents{}) + require.NoError(t, err) + require.Empty(t, batch.Matches, "the unchanged match must not reach the repository write batch") + require.Equal(t, before, *stored, "keep the certified block, deletion facts, and timestamps") + require.Len(t, batch.Commitments, 1, "a deleted match does not make its commitments immutable") + require.Equal(t, uint64(100), batch.Commitments[0].Snapshot.AsOfBlock) + f.adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchReusesDeletionAtPublishedHead(t *testing.T) { + stored := certifiedDeletedMatch() + stored.DeletionBlockNumber, stored.Snapshot.AsOfBlock = 100, 100 + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 100 + + batch, err := f.project(&TournamentEvents{}) + require.NoError(t, err) + require.Empty(t, batch.Matches, "deletion, snapshot, cursor, and head can have the same block") + require.Equal(t, before, *stored) + f.adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchValidatesFirstDeletionAtPinnedBlock(t *testing.T) { + for _, test := range []struct { + name string + view ObservedMatchSnapshot + fail bool + }{ + {name: "deleted view", view: ObservedMatchSnapshot{ + Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone, + }}, + {name: "live view contradicts deletion", view: ObservedMatchSnapshot{Phase: model.MatchPhaseBisecting}, fail: true}, + } { + t.Run(test.name, func(t *testing.T) { + stored := certifiedDeletedMatch() + event := matchDeletionEvent(stored) + event.Raw.BlockNumber = 100 + stored.DeletionReason, stored.Winner = model.MatchDeletionReason_NOT_DELETED, model.WinnerCommitment_NONE + stored.DeletionBlockNumber, stored.DeletionTxHash, stored.DeletionLogIndex = 0, nil, nil + stored.Snapshot = model.MatchSnapshot{AsOfBlock: 40, Phase: model.MatchPhaseSealed, + Sealed: &model.MatchSealedSnapshot{}, TimeoutOutcome: model.MatchTimeoutNone} + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(test.view, nil).Once() + + batch, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + if test.fail { + require.ErrorContains(t, err, "deleted match has phase") + require.Nil(t, batch) + } else { + require.NoError(t, err) + require.Len(t, batch.Matches, 1) + require.Equal(t, uint64(100), batch.Matches[0].Snapshot.AsOfBlock) + require.Equal(t, model.MatchPhaseUninitialized, batch.Matches[0].Snapshot.Phase) + require.Equal(t, event.Raw.TxHash, *batch.Matches[0].DeletionTxHash) + } + require.Equal(t, before, *stored, "publication must not mutate the repository-owned row") + }) + } +} + +func TestTournamentEventBatchRefreshesUncertifiedDeletions(t *testing.T) { + for _, test := range []struct { + name string + change func(*model.Match, *model.Application) + }{ + {"deletion transaction is absent", func(v *model.Match, _ *model.Application) { v.DeletionTxHash = nil }}, + {"empty transaction hash", func(v *model.Match, _ *model.Application) { v.DeletionTxHash = new(common.Hash) }}, + {"missing log index", func(v *model.Match, _ *model.Application) { v.DeletionLogIndex = nil }}, + {"missing deletion block", func(v *model.Match, _ *model.Application) { v.DeletionBlockNumber = 0 }}, + {"not deleted", func(v *model.Match, _ *model.Application) { v.DeletionReason = model.MatchDeletionReason_NOT_DELETED }}, + {"deletion reason is unknown", func(v *model.Match, _ *model.Application) { v.DeletionReason = "UNKNOWN" }}, + {"deletion winner is unknown", func(v *model.Match, _ *model.Application) { v.Winner = "UNKNOWN" }}, + {"missing snapshot", func(v *model.Match, _ *model.Application) { v.Snapshot.AsOfBlock = 0 }}, + {"deletion after snapshot", func(v *model.Match, _ *model.Application) { v.DeletionBlockNumber = 41 }}, + {"snapshot after cursor", func(v *model.Match, _ *model.Application) { v.Snapshot.AsOfBlock = 51 }}, + {"head below published cursor", func(_ *model.Match, app *model.Application) { app.LastTournamentCheckBlock = 101 }}, + {"live phase", func(v *model.Match, _ *model.Application) { v.Snapshot.Phase = model.MatchPhaseBisecting }}, + {"retained timeout", func(v *model.Match, _ *model.Application) { v.Snapshot.TimeoutOutcome = model.MatchTimeoutOneWins }}, + {"retained charge", func(v *model.Match, _ *model.Application) { v.Snapshot.DeferredCharge = 1 }}, + {"retained bisection", func(v *model.Match, _ *model.Application) { v.Snapshot.Bisection = &model.MatchBisectionSnapshot{} }}, + {"retained seal", func(v *model.Match, _ *model.Application) { v.Snapshot.Sealed = &model.MatchSealedSnapshot{} }}, + } { + t.Run(test.name, func(t *testing.T) { + stored := certifiedDeletedMatch() + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + test.change(stored, f.app) + before := *stored + cause := errors.New("pinned snapshot unavailable") + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(ObservedMatchSnapshot{}, cause).Once() + + batch, err := f.project(&TournamentEvents{}) + require.ErrorIs(t, err, cause, "uncertified state must not bypass the pinned read") + require.Nil(t, batch) + require.Equal(t, before, *stored) + }) + } +} + +func TestTournamentEventBatchKeepsNewFactsForCertifiedDeletion(t *testing.T) { + const ( + matchCreationEventName = "creation" + matchAdvanceEventName = "advance" + leafSealEventName = "leaf seal" + matchDeletionEventName = "deletion" + ) + for _, eventName := range []string{matchCreationEventName, matchAdvanceEventName, leafSealEventName, matchDeletionEventName} { + t.Run(eventName, func(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + events := &TournamentEvents{} + switch eventName { + case matchCreationEventName: + events.MatchCreated = []*itournament.ITournamentMatchCreated{{ + MatchIdHash: stored.IDHash, One: stored.CommitmentOne, Two: stored.CommitmentTwo, + LeftOfTwo: stored.LeftOfTwo, EliminableAt: stored.EliminableAt, + Raw: types.Log{BlockNumber: stored.BlockNumber, TxHash: stored.TxHash, Index: uint(stored.LogIndex)}, + }} + case matchAdvanceEventName: + events.MatchAdvanced = []*itournament.ITournamentMatchAdvanced{{ + MatchIdHash: stored.IDHash, SegmentStartPosition: big.NewInt(8), + Raw: types.Log{BlockNumber: 30, TxHash: common.HexToHash("0x77"), Index: 1}, + }} + case leafSealEventName: + events.LeafMatchSealed = []*itournament.ITournamentLeafMatchSealed{{ + MatchIdHash: stored.IDHash, EliminableAt: 34, + Raw: types.Log{BlockNumber: 30, TxHash: common.HexToHash("0x77"), Index: 1}, + }} + case matchDeletionEventName: + events.MatchDeleted = []*itournament.ITournamentMatchDeleted{matchDeletionEvent(stored)} + } + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(ObservedMatchSnapshot{ + Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone, + }, nil).Once() + + batch, err := f.project(events) + require.NoError(t, err) + require.Len(t, batch.Matches, 1, "an incoming fact must not be hidden by snapshot reuse") + require.Equal(t, uint64(100), batch.Matches[0].Snapshot.AsOfBlock) + require.Equal(t, before, *stored) + if eventName == matchAdvanceEventName { + require.Len(t, batch.MatchAdvances, 1) + require.Equal(t, big.NewInt(8), batch.MatchAdvances[0].SegmentStartPosition.ToBig()) + } + if eventName == leafSealEventName { + require.NotNil(t, batch.Matches[0].LeafSeal) + require.Equal(t, uint64(34), batch.Matches[0].LeafSeal.EliminableAt) + } + }) + } +} + +func TestTournamentEventBatchRejectsConflictingCertifiedDeletion(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + event := matchDeletionEvent(stored) + event.Raw.Index++ + batch, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + require.ErrorContains(t, err, "conflicting deletion events") + require.Nil(t, batch) + require.Equal(t, before, *stored) + require.Empty(t, f.adapter.Calls) +} + +func TestDeletedParentMatchStillObservesLiveChild(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + stored := certifiedDeletedMatch() + before := *stored + parent, adapter := expectDeletedMatchTournament(t, f, epoch, *epoch.TournamentAddress, 0, []*model.Match{stored}) + child, _ := expectDeletedMatchTournament(t, f, epoch, common.HexToAddress("0x101"), 1, nil) + f.children(epoch, parent, child) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(t, batches, 2) + require.Equal(t, parent.Address, batches[0].Tournament.Address) + require.Empty(t, batches[0].Matches) + require.Equal(t, child.Address, batches[1].Tournament.Address) + require.Equal(t, model.TournamentStandingMatchesActive, batches[1].Tournament.Snapshot.Standing) + require.Equal(t, uint64(100), batches[1].Tournament.Snapshot.AsOfBlock) + }).Return(nil).Once() + + _, _, err := f.s.observeApplicationTournaments(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, before, *stored) + adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func expectDeletedMatchTournament( + t *testing.T, f *observerCheckpointFixture, epoch *model.Epoch, address common.Address, level uint64, matches []*model.Match, +) (*model.Tournament, *tournamentAdapterMock) { + t.Helper() + kind := model.TournamentKindNonLeaf + if level == 1 { + kind = model.TournamentKindLeaf + } + tournament := &model.Tournament{Address: address, Level: level, MaxLevel: 2, Kind: kind, StartInstant: 10} + adapter := &tournamentAdapterMock{} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(tournament, nil).Once() + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: level, Kind: kind, StartInstant: 10, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingMatchesActive}, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, TournamentLevel(level), model.TournamentStandingMatchesActive) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 51 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + counts := zeroStructuralEventCounts() + if len(matches) != 0 { + counts.MatchCreated.SetUint64(1) + counts.MatchDeleted.SetUint64(1) + } + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(50))).Return(counts, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(counts, nil).Once() + adapter.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(50))). + Return(canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0), nil).Once() + filterAddress := address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*model.Commitment{}, uint64(0), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return(matches, uint64(len(matches)), nil).Once() + t.Cleanup(func() { adapter.AssertExpectations(t) }) + return tournament, adapter +} diff --git a/internal/prt/event_batch_test.go b/internal/prt/event_batch_test.go new file mode 100644 index 000000000..bbf134dd5 --- /dev/null +++ b/internal/prt/event_batch_test.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type eventBatchFixture struct { + *observerCheckpointFixture + epoch *Epoch + tournament *Tournament + adapter *tournamentAdapterMock +} + +func newEventBatchFixture(t *testing.T, commitments []*Commitment, matches []*Match) *eventBatchFixture { + t.Helper() + s, repo := newPRTServiceMock() + base := &observerCheckpointFixture{t: t, s: s, repo: repo, app: prtRevertTestApp()} + epoch := checkpointEpoch(0, "0x100") + f := &eventBatchFixture{observerCheckpointFixture: base, epoch: epoch, + tournament: &Tournament{Address: *epoch.TournamentAddress}, adapter: &tournamentAdapterMock{}} + address := f.tournament.Address.Hex() + repo.On("ListCommitments", mock.Anything, base.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return(commitments, uint64(len(commitments)), nil).Once() + repo.On("ListMatches", mock.Anything, base.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return(matches, uint64(len(matches)), nil).Once() + t.Cleanup(func() { repo.AssertExpectations(t); f.adapter.AssertExpectations(t) }) + return f +} + +func (f *eventBatchFixture) project(events *TournamentEvents) (*repository.TournamentEventBatch, error) { + return f.s.tournamentEventBatch(f.t.Context(), f.app, f.epoch, f.tournament, f.adapter, events, 100) +} + +func TestTournamentEventBatchKeepsFactsAndCurrentViewsSeparate(t *testing.T) { + f := newEventBatchFixture(t, nil, nil) + one, two, matchID := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + finalOne, finalTwo := common.HexToHash("0x44"), common.HexToHash("0x55") + tx := common.HexToHash("0x900") + logAt := func(index uint) types.Log { return types.Log{BlockNumber: 100, TxHash: tx, Index: index} } + large := new(big.Int).Lsh(big.NewInt(1), 200) + events := &TournamentEvents{ + CommitmentJoined: []*itournament.ITournamentCommitmentJoined{ + {Commitment: one, FinalStateHash: finalOne, Submitter: common.HexToAddress("0xa"), Raw: logAt(0)}, + {Commitment: two, FinalStateHash: finalTwo, Submitter: common.HexToAddress("0xb"), Raw: logAt(1)}, + }, + MatchCreated: []*itournament.ITournamentMatchCreated{{ + MatchIdHash: matchID, One: one, Two: two, LeftOfTwo: finalTwo, EliminableAt: 120, Raw: logAt(2), + }}, + MatchAdvanced: []*itournament.ITournamentMatchAdvanced{ + {MatchIdHash: matchID, OtherParent: finalOne, LeftNode: one, SegmentStartPosition: large, EliminableAt: 121, Raw: logAt(3)}, + {MatchIdHash: matchID, OtherParent: finalOne, LeftNode: two, + SegmentStartPosition: big.NewInt(8), EliminableAt: 122, Raw: logAt(4)}, + }, + LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{{MatchIdHash: matchID, EliminableAt: 123, Raw: logAt(5)}}, + MatchDeleted: []*itournament.ITournamentMatchDeleted{{MatchIdHash: matchID, One: one, Two: two, Reason: 1, Raw: logAt(6)}}, + PartialBondRefund: []*itournament.ITournamentPartialBondRefund{{ + Recipient: common.HexToAddress("0xc"), Value: large, Success: false, Raw: logAt(7), + }}, + BondRecovered: []*itournament.ITournamentBondRecovered{{ + Commitment: one, Claimer: common.HexToAddress("0xa"), Payment: big.NewInt(0), Burned: big.NewInt(7), Raw: logAt(8), + }}, + } + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.adapter.On("CommitmentStanding", opts, [32]byte(one)).Return(CommitmentStanding{ + Joined: true, FinalState: finalOne, ClockRunning: true, ClockDeadline: 110, ClockAllowance: 30, + }, nil).Once() + f.adapter.On("CommitmentStanding", opts, [32]byte(two)).Return(CommitmentStanding{ + Joined: true, FinalState: finalTwo, ClockAllowance: 20, + }, nil).Once() + f.adapter.On("MatchSnapshot", opts, [32]byte(one), [32]byte(two)).Return(ObservedMatchSnapshot{ + Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, + }, nil).Once() + batch, err := f.project(events) + require.NoError(t, err) + require.Len(t, batch.Commitments, 2) + require.Equal(t, common.HexToAddress("0xa"), batch.Commitments[0].SubmitterAddress) + require.Zero(t, batch.Commitments[0].Snapshot.Claimer, "recovery clears the current claimer, not the join submitter") + require.True(t, batch.Commitments[0].Snapshot.ClockRunning, "raw retained clocks are not a liveness classification") + require.Equal(t, uint64(100), batch.Commitments[0].Snapshot.AsOfBlock) + require.Len(t, batch.MatchAdvances, 2, "the same parent hash can occur in distinct events") + require.Equal(t, uint64(3), batch.MatchAdvances[0].LogIndex) + require.Equal(t, uint64(4), batch.MatchAdvances[1].LogIndex) + require.Equal(t, large, batch.MatchAdvances[0].SegmentStartPosition.ToBig()) + require.Len(t, batch.Matches, 1) + match := batch.Matches[0] + require.Equal(t, uint64(120), match.EliminableAt, "creation data must not become a later deadline") + require.Equal(t, uint64(123), match.LeafSeal.EliminableAt) + require.Equal(t, uint64(5), match.LeafSeal.LogIndex) + require.Equal(t, tx, *match.DeletionTxHash) + require.Equal(t, uint64(6), *match.DeletionLogIndex) + require.Equal(t, MatchDeletionReason_TIMEOUT, match.DeletionReason) + require.Equal(t, MatchPhaseUninitialized, match.Snapshot.Phase) + require.Nil(t, match.Snapshot.Bisection) + require.Nil(t, match.Snapshot.Sealed) + require.Len(t, batch.BondEvents, 2) + require.False(t, batch.BondEvents[0].Refund.Success) + require.Nil(t, batch.BondEvents[0].Recovery) + require.Nil(t, batch.BondEvents[1].Refund) + require.Zero(t, batch.BondEvents[1].Recovery.Payment.ToBig().Sign()) + large.SetUint64(0) + events.MatchDeleted[0].Raw.TxHash = common.Hash{} + require.Equal(t, 201, batch.MatchAdvances[0].SegmentStartPosition.ToBig().BitLen()) + require.Equal(t, 201, batch.BondEvents[0].Refund.Value.ToBig().BitLen()) + require.Equal(t, tx, *match.DeletionTxHash) + f.repo.AssertNotCalled(t, "StoreTournamentEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchRejectsUnknownOrContradictoryDeletion(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + name string + reason, winner uint8 + badPair bool + }{ + {name: "unknown reason", reason: 255}, {name: "unknown winner", winner: 3}, {name: "different pair", badPair: true}, + } { + t.Run(test.name, func(t *testing.T) { + stored := &Match{IDHash: common.HexToHash("0x33"), CommitmentOne: one, CommitmentTwo: two, + DeletionReason: MatchDeletionReason_NOT_DELETED} + before := *stored + f := newEventBatchFixture(t, nil, []*Match{stored}) + event := &itournament.ITournamentMatchDeleted{MatchIdHash: stored.IDHash, One: one, Two: two, + Reason: test.reason, WinnerCommitment: test.winner, Raw: types.Log{BlockNumber: 100}} + if test.badPair { + event.Two = one + } + _, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + require.Error(t, err) + require.Equal(t, before, *stored) + require.Empty(t, f.adapter.Calls) + }) + } +} + +func TestTournamentEventBatchReadFailuresDoNotMutateStoredRows(t *testing.T) { + for _, failed := range []string{"commitment", "match"} { + t.Run(failed, func(t *testing.T) { + commitment := &Commitment{Commitment: common.HexToHash("0x11"), FinalStateHash: common.HexToHash("0x22")} + match := &Match{IDHash: common.HexToHash("0x33"), CommitmentOne: commitment.Commitment} + beforeCommitment, beforeMatch := *commitment, *match + f := newEventBatchFixture(t, []*Commitment{commitment}, []*Match{match}) + cause := errors.New("snapshot unavailable") + var commitmentError error + if failed == "commitment" { + commitmentError = cause + } + f.adapter.On("CommitmentStanding", mock.Anything, [32]byte(commitment.Commitment)).Return( + CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 7}, commitmentError).Once() + if commitmentError == nil { + f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(match.CommitmentOne), [32]byte(match.CommitmentTwo)). + Return(ObservedMatchSnapshot{}, cause).Once() + } + _, err := f.project(&TournamentEvents{}) + require.ErrorIs(t, err, cause) + require.Equal(t, beforeCommitment, *commitment) + require.Equal(t, beforeMatch, *match) + }) + } +} + +func TestTournamentCreationEventChecksCloneStartAndKeepsIdentity(t *testing.T) { + matchID := common.HexToHash("0x11") + child := &Tournament{Address: common.HexToAddress("0x100"), ParentMatchIDHash: &matchID, StartInstant: 50} + event := &itournament.ITournamentNewInnerTournament{MatchIdHash: matchID, ChildTournament: child.Address, + Raw: types.Log{BlockNumber: 50, TxHash: common.HexToHash("0x22"), Index: 4}} + require.NoError(t, applyTournamentCreationEvent(child, event)) + require.Equal(t, uint64(4), child.CreationEvent.LogIndex) + before := *child.CreationEvent + require.NoError(t, applyTournamentCreationEvent(child, event), "an exact repeated fact is harmless") + event.Raw.Index++ + require.ErrorContains(t, applyTournamentCreationEvent(child, event), "conflicting creation events") + require.Equal(t, before, *child.CreationEvent) + event.Raw.BlockNumber++ + require.ErrorContains(t, applyTournamentCreationEvent(child, event), "descriptor does not match") +} diff --git a/internal/prt/observation_counts.go b/internal/prt/observation_counts.go new file mode 100644 index 000000000..ba52fd828 --- /dev/null +++ b/internal/prt/observation_counts.go @@ -0,0 +1,82 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" +) + +const ( + tournamentEventCommitmentJoined = "CommitmentJoined" + tournamentEventMatchCreated = "MatchCreated" + tournamentEventMatchAdvanced = "MatchAdvanced" + tournamentEventLeafMatchSealed = "LeafMatchSealed" + tournamentEventMatchDeleted = "MatchDeleted" + tournamentEventNewInnerTournament = "NewInnerTournament" +) + +func zeroStructuralEventCounts() StructuralEventCounts { + return StructuralEventCounts{ + CommitmentJoined: new(big.Int), MatchCreated: new(big.Int), MatchAdvanced: new(big.Int), + LeafMatchSealed: new(big.Int), MatchDeleted: new(big.Int), NewInnerTournament: new(big.Int), + } +} + +// Each successful refundable action emits one advance, leaf seal, deletion, +// or child creation, followed by one refund event even if payment fails. +// Successful bond recovery is a one-shot transition and has its own event. +func validateTournamentFinancialEvents(before, after model.BondDisposition, events *TournamentEvents) error { + expectedRefunds := len(events.MatchAdvanced) + len(events.LeafMatchSealed) + len(events.MatchDeleted) + len(events.NewInnerTournament) + if len(events.PartialBondRefund) != expectedRefunds { + return fmt.Errorf("PartialBondRefund event count mismatch: expected %d, fetched %d", expectedRefunds, len(events.PartialBondRefund)) + } + if before == model.BondDispositionRecovered && after != model.BondDispositionRecovered { + return fmt.Errorf("bond recovery disposition regressed from RECOVERED to %s", after) + } + expectedRecoveries := 0 + if before != model.BondDispositionRecovered && after == model.BondDispositionRecovered { + expectedRecoveries = 1 + } + if len(events.BondRecovered) != expectedRecoveries { + return fmt.Errorf("BondRecovered event count mismatch: expected %d, fetched %d", expectedRecoveries, len(events.BondRecovered)) + } + return nil +} + +// validateTournamentEventCounts checks each independent structural stream. +// A mismatch is an observation failure, not proof of local data corruption. +func validateTournamentEventCounts(before, after StructuralEventCounts, events *TournamentEvents) error { + for _, stream := range []struct { + name string + before, after *big.Int + fetched int + }{ + {tournamentEventCommitmentJoined, before.CommitmentJoined, after.CommitmentJoined, len(events.CommitmentJoined)}, + {tournamentEventMatchCreated, before.MatchCreated, after.MatchCreated, len(events.MatchCreated)}, + {tournamentEventMatchAdvanced, before.MatchAdvanced, after.MatchAdvanced, len(events.MatchAdvanced)}, + {tournamentEventLeafMatchSealed, before.LeafMatchSealed, after.LeafMatchSealed, len(events.LeafMatchSealed)}, + {tournamentEventMatchDeleted, before.MatchDeleted, after.MatchDeleted, len(events.MatchDeleted)}, + {tournamentEventNewInnerTournament, before.NewInnerTournament, after.NewInnerTournament, len(events.NewInnerTournament)}, + } { + previous, err := tournamentUint256(stream.name+" previous count", stream.before) + if err != nil { + return err + } + current, err := tournamentUint256(stream.name+" current count", stream.after) + if err != nil { + return err + } + expected := new(big.Int).Sub(current, previous) + if expected.Sign() < 0 { + return fmt.Errorf("%s count decreased from %s to %s", stream.name, previous, current) + } + if expected.Cmp(big.NewInt(int64(stream.fetched))) != 0 { + return fmt.Errorf("%s event count mismatch: expected %s, fetched %d", stream.name, expected, stream.fetched) + } + } + return nil +} diff --git a/internal/prt/observation_counts_test.go b/internal/prt/observation_counts_test.go new file mode 100644 index 000000000..22b2e4bda --- /dev/null +++ b/internal/prt/observation_counts_test.go @@ -0,0 +1,207 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func structuralCountsForEvents(events *TournamentEvents) StructuralEventCounts { + return StructuralEventCounts{ + CommitmentJoined: new(big.Int).SetUint64(uint64(len(events.CommitmentJoined))), + MatchCreated: new(big.Int).SetUint64(uint64(len(events.MatchCreated))), + MatchAdvanced: new(big.Int).SetUint64(uint64(len(events.MatchAdvanced))), + LeafMatchSealed: new(big.Int).SetUint64(uint64(len(events.LeafMatchSealed))), + MatchDeleted: new(big.Int).SetUint64(uint64(len(events.MatchDeleted))), + NewInnerTournament: new(big.Int).SetUint64(uint64(len(events.NewInnerTournament))), + } +} + +func TestTournamentObservationRejectsIncompleteFinancialEvents(t *testing.T) { + for _, omission := range []string{"refund", "recovery", "previous recovery view"} { + t.Run(omission, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + previousOpts := mock.MatchedBy(resultCallOptsAtBlock(50)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + address := *epoch.TournamentAddress + projection := &model.Tournament{Address: address, MaxLevel: 1, Kind: model.TournamentKindLeaf, StartInstant: 10} + before := *projection + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(projection, nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{BaseCycle: new(big.Int), Kind: model.TournamentKindLeaf, + StartInstant: 10}, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingRootWinner, + HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: 90}, nil).Once() + disposition := model.BondDispositionRecoverable + if omission == "recovery" { + disposition = model.BondDispositionRecovered + } + adapter.On("BondRecovery", opts).Return(canonicalBondRecovery(disposition, common.HexToAddress("0x777"), 0), nil).Once() + events := &TournamentEvents{} + if omission == "refund" { + events.MatchDeleted = []*itournament.ITournamentMatchDeleted{{}} + } + adapter.On("RetrieveAllEvents", mock.Anything).Return(events, nil).Once() + adapter.On("StructuralEventCounts", previousOpts).Return(zeroStructuralEventCounts(), nil).Once() + if omission == "previous recovery view" { + adapter.On("BondRecovery", previousOpts).Return(BondRecovery{}, fmt.Errorf("previous recovery view unavailable")).Once() + } else { + adapter.On("BondRecovery", previousOpts). + Return(canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0), nil).Once() + adapter.On("StructuralEventCounts", opts).Return(structuralCountsForEvents(events), nil).Once() + } + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.Error(t, err) + require.Equal(t, before, *projection) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, uint8(1), f.s.observationFailures[f.app.ID].failedHeads) + f.repo.AssertNotCalled(t, "StoreTournamentEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + adapter.AssertExpectations(t) + }) + } +} + +func TestTournamentObservationPublishesLateRecovery(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.Status = model.EpochStatus_ClaimAccepted + f.epochs(epoch) + opts, previousOpts := mock.MatchedBy(resultCallOptsAtBlock(100)), mock.MatchedBy(resultCallOptsAtBlock(50)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + address := *epoch.TournamentAddress + projection := &model.Tournament{Address: address, MaxLevel: 1, Kind: model.TournamentKindLeaf, StartInstant: 10, + Snapshot: model.TournamentSnapshot{AsOfBlock: 50, FinishedAtBlock: 40, Standing: model.TournamentStandingRootWinner, + BondRecovery: model.TournamentBondRecovery{Disposition: model.BondDispositionRecoverable}}} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(projection, nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{BaseCycle: new(big.Int), Kind: model.TournamentKindLeaf, + StartInstant: 10}, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingRootWinner, + HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: 40}, nil).Once() + adapter.On("BondRecovery", opts).Return(canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), nil).Once() + adapter.On("BondRecovery", previousOpts). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, common.HexToAddress("0x777"), 0), nil).Once() + events := &TournamentEvents{BondRecovered: []*itournament.ITournamentBondRecovered{{ + Commitment: *epoch.Commitment, Claimer: common.HexToAddress("0x777"), Payment: new(big.Int), Burned: big.NewInt(8), + }}} + adapter.On("RetrieveAllEvents", mock.Anything).Return(events, nil).Once() + adapter.On("StructuralEventCounts", previousOpts).Return(zeroStructuralEventCounts(), nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, address) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && len(batches[0].BondEvents) == 1 && + batches[0].Tournament.Snapshot.BondRecovery.Disposition == model.BondDispositionRecovered && + batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, uint64(50), projection.Snapshot.AsOfBlock) + adapter.AssertExpectations(t) +} + +func TestTournamentFinancialEventsRequireEachRefund(t *testing.T) { + for _, test := range []struct { + name string + events TournamentEvents + }{ + {"advance", TournamentEvents{MatchAdvanced: []*itournament.ITournamentMatchAdvanced{{}}}}, + {"leaf seal", TournamentEvents{LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{{}}}}, + {"deletion", TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{{}}}}, + {"child creation", TournamentEvents{NewInnerTournament: []*itournament.ITournamentNewInnerTournament{{}}}}, + } { + t.Run(test.name, func(t *testing.T) { + require.ErrorContains(t, validateTournamentFinancialEvents(model.BondDispositionTournamentRunning, + model.BondDispositionTournamentRunning, &test.events), "PartialBondRefund event count mismatch") + for _, paid := range []bool{false, true} { + test.events.PartialBondRefund = []*itournament.ITournamentPartialBondRefund{{Value: big.NewInt(0), Success: paid}} + require.NoError(t, validateTournamentFinancialEvents(model.BondDispositionTournamentRunning, + model.BondDispositionTournamentRunning, &test.events)) + } + test.events.PartialBondRefund = append(test.events.PartialBondRefund, test.events.PartialBondRefund[0]) + require.ErrorContains(t, validateTournamentFinancialEvents(model.BondDispositionTournamentRunning, + model.BondDispositionTournamentRunning, &test.events), "PartialBondRefund event count mismatch") + }) + } +} + +func TestTournamentFinancialEventsRequireOneRecoveryTransition(t *testing.T) { + for _, before := range []model.BondDisposition{model.BondDispositionTournamentRunning, model.BondDispositionRecoverable} { + t.Run(string(before), func(t *testing.T) { + events := &TournamentEvents{} + require.ErrorContains(t, validateTournamentFinancialEvents(before, model.BondDispositionRecovered, events), + "BondRecovered event count mismatch") + events.BondRecovered = []*itournament.ITournamentBondRecovered{{Payment: new(big.Int), Burned: new(big.Int)}} + require.NoError(t, validateTournamentFinancialEvents(before, model.BondDispositionRecovered, events)) + events.BondRecovered = append(events.BondRecovered, events.BondRecovered[0]) + require.ErrorContains(t, validateTournamentFinancialEvents(before, model.BondDispositionRecovered, events), + "BondRecovered event count mismatch") + }) + } + for _, disposition := range []model.BondDisposition{model.BondDispositionTournamentRunning, model.BondDispositionNoWinner, + model.BondDispositionRecoverable, model.BondDispositionRecovered} { + // Failed payment remains recoverable; an already recovered retry is a no-op. + require.NoError(t, validateTournamentFinancialEvents(disposition, disposition, &TournamentEvents{})) + require.Error(t, validateTournamentFinancialEvents(disposition, disposition, + &TournamentEvents{BondRecovered: []*itournament.ITournamentBondRecovered{{}}})) + } + require.ErrorContains(t, validateTournamentFinancialEvents(model.BondDispositionRecovered, model.BondDispositionRecoverable, + &TournamentEvents{}), "regressed") +} + +func TestTournamentEventCountsCheckEveryStructuralStream(t *testing.T) { + for _, test := range []struct { + name string + events TournamentEvents + }{ + {tournamentEventCommitmentJoined, TournamentEvents{CommitmentJoined: []*itournament.ITournamentCommitmentJoined{{}}}}, + {tournamentEventMatchCreated, TournamentEvents{MatchCreated: []*itournament.ITournamentMatchCreated{{}}}}, + {tournamentEventMatchAdvanced, TournamentEvents{MatchAdvanced: []*itournament.ITournamentMatchAdvanced{{}}}}, + {tournamentEventLeafMatchSealed, TournamentEvents{LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{{}}}}, + {tournamentEventMatchDeleted, TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{{}}}}, + {tournamentEventNewInnerTournament, TournamentEvents{NewInnerTournament: []*itournament.ITournamentNewInnerTournament{{}}}}, + } { + t.Run(test.name, func(t *testing.T) { + before, after := zeroStructuralEventCounts(), structuralCountsForEvents(&test.events) + require.NoError(t, validateTournamentEventCounts(before, after, &test.events)) + err := validateTournamentEventCounts(before, after, &TournamentEvents{}) + require.ErrorContains(t, err, test.name+" event count mismatch") + err = validateTournamentEventCounts(before, before, &test.events) + require.ErrorContains(t, err, test.name+" event count mismatch") + }) + } +} + +func TestTournamentEventCountsKeepFullWidthAndRejectInvalidCounters(t *testing.T) { + before, after := zeroStructuralEventCounts(), zeroStructuralEventCounts() + before.MatchAdvanced.Lsh(big.NewInt(1), 200) + after.MatchAdvanced.Add(before.MatchAdvanced, big.NewInt(1)) + beforeCopy, afterCopy := new(big.Int).Set(before.MatchAdvanced), new(big.Int).Set(after.MatchAdvanced) + events := &TournamentEvents{MatchAdvanced: []*itournament.ITournamentMatchAdvanced{{}}} + require.NoError(t, validateTournamentEventCounts(before, after, events)) + require.Equal(t, beforeCopy, before.MatchAdvanced) + require.Equal(t, afterCopy, after.MatchAdvanced) + for _, invalid := range []*big.Int{nil, big.NewInt(-1), new(big.Int).Lsh(big.NewInt(1), 256)} { + after.MatchAdvanced = invalid + require.Error(t, validateTournamentEventCounts(before, after, events)) + } + after.MatchAdvanced = new(big.Int).Sub(before.MatchAdvanced, big.NewInt(1)) + require.ErrorContains(t, validateTournamentEventCounts(before, after, events), "count decreased") +} diff --git a/internal/prt/observation_projection.go b/internal/prt/observation_projection.go new file mode 100644 index 000000000..a1e42f1a4 --- /dev/null +++ b/internal/prt/observation_projection.go @@ -0,0 +1,272 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + "slices" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +// tournamentEventBatch folds immutable event facts into copies of the stored +// participants, then refreshes mutable views at the same pinned block. +func (s *Service) tournamentEventBatch( + ctx context.Context, app *Application, epoch *Epoch, tournament *Tournament, + adapter TournamentAdapter, events *TournamentEvents, block uint64, +) (*repository.TournamentEventBatch, error) { + address := tournament.Address.Hex() + commitments, _, err := s.repository.ListCommitments(ctx, app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false) + if err != nil { + return nil, fmt.Errorf("loading tournament commitments: %w", err) + } + matches, _, err := s.repository.ListMatches(ctx, app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false) + if err != nil { + return nil, fmt.Errorf("loading tournament matches: %w", err) + } + commitmentByHash := make(map[common.Hash]*Commitment, len(commitments)+len(events.CommitmentJoined)) + for _, stored := range commitments { + projection := *stored + commitmentByHash[stored.Commitment] = &projection + } + matchByHash := make(map[common.Hash]*Match, len(matches)+len(events.MatchCreated)) + unchangedDeletedMatches := make(map[common.Hash]bool, len(matches)) + for _, stored := range matches { + projection := *stored + matchByHash[stored.IDHash] = &projection + if deletedMatchObservationComplete(stored, app.LastTournamentCheckBlock, block) { + unchangedDeletedMatches[stored.IDHash] = true + } + } + batch := &repository.TournamentEventBatch{Tournament: tournament} + for _, event := range events.CommitmentJoined { + joined := &Commitment{ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Commitment: event.Commitment, FinalStateHash: event.FinalStateHash, SubmitterAddress: event.Submitter, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index)} + if previous := commitmentByHash[joined.Commitment]; previous != nil { + if previous.FinalStateHash != joined.FinalStateHash || previous.SubmitterAddress != joined.SubmitterAddress || + previous.BlockNumber != joined.BlockNumber || previous.TxHash != joined.TxHash || previous.LogIndex != joined.LogIndex { + return nil, fmt.Errorf("commitment %s has conflicting join events", joined.Commitment) + } + } else { + commitmentByHash[joined.Commitment] = joined + } + } + for _, event := range events.MatchCreated { + delete(unchangedDeletedMatches, event.MatchIdHash) + created := &Match{ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + IDHash: event.MatchIdHash, CommitmentOne: event.One, CommitmentTwo: event.Two, LeftOfTwo: event.LeftOfTwo, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + EliminableAt: event.EliminableAt, Winner: WinnerCommitment_NONE, DeletionReason: MatchDeletionReason_NOT_DELETED} + if previous := matchByHash[created.IDHash]; previous != nil { + if previous.CommitmentOne != created.CommitmentOne || previous.CommitmentTwo != created.CommitmentTwo || + previous.LeftOfTwo != created.LeftOfTwo || previous.BlockNumber != created.BlockNumber || + previous.TxHash != created.TxHash || previous.LogIndex != created.LogIndex || + previous.EliminableAt != created.EliminableAt { + return nil, fmt.Errorf("match %s has conflicting creation events", created.IDHash) + } + } else { + matchByHash[created.IDHash] = created + } + } + for _, event := range events.MatchAdvanced { + delete(unchangedDeletedMatches, event.MatchIdHash) + if matchByHash[event.MatchIdHash] == nil { + return nil, fmt.Errorf("match advance has no creation event for %x", event.MatchIdHash) + } + position, err := Uint256FromBig(event.SegmentStartPosition) + if err != nil { + return nil, fmt.Errorf("match advance position: %w", err) + } + batch.MatchAdvances = append(batch.MatchAdvances, &MatchAdvanced{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + IDHash: event.MatchIdHash, OtherParent: event.OtherParent, LeftNode: event.LeftNode, + SegmentStartPosition: position, EliminableAt: event.EliminableAt, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + }) + } + for _, event := range events.LeafMatchSealed { + delete(unchangedDeletedMatches, event.MatchIdHash) + match := matchByHash[event.MatchIdHash] + if match == nil { + return nil, fmt.Errorf("leaf seal has no creation event for %x", event.MatchIdHash) + } + seal := &LeafMatchSeal{EliminableAt: event.EliminableAt, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index)} + if match.LeafSeal != nil && *match.LeafSeal != *seal { + return nil, fmt.Errorf("match %s has conflicting leaf seal events", match.IDHash) + } + match.LeafSeal = seal + } + for _, event := range events.MatchDeleted { + delete(unchangedDeletedMatches, event.MatchIdHash) + match := matchByHash[event.MatchIdHash] + if match == nil || match.CommitmentOne != event.One || match.CommitmentTwo != event.Two { + return nil, fmt.Errorf("match deletion does not match its creation pair for %x", event.MatchIdHash) + } + reason, err := MatchDeletionReasonFromUint8(event.Reason) + if err != nil { + return nil, err + } + winner, err := WinnerCommitmentFromUint8(event.WinnerCommitment) + if err != nil { + return nil, err + } + if match.DeletionTxHash != nil && (*match.DeletionTxHash != event.Raw.TxHash || + match.DeletionLogIndex == nil || *match.DeletionLogIndex != uint64(event.Raw.Index) || + match.DeletionBlockNumber != event.Raw.BlockNumber || match.DeletionReason != reason || match.Winner != winner) { + return nil, fmt.Errorf("match %s has conflicting deletion events", match.IDHash) + } + match.DeletionReason, match.Winner = reason, winner + match.DeletionBlockNumber, match.DeletionTxHash = event.Raw.BlockNumber, new(event.Raw.TxHash) + match.DeletionLogIndex = new(uint64(event.Raw.Index)) + } + if err := appendTournamentBondEvents(batch, app, epoch, events); err != nil { + return nil, err + } + for _, commitment := range commitmentByHash { + standing, err := adapter.CommitmentStanding(pinnedCallOpts(ctx, block), commitment.Commitment) + if err != nil { + return nil, fmt.Errorf("reading commitment %s standing: %w", commitment.Commitment, err) + } + if !standing.Joined || standing.FinalState != commitment.FinalStateHash { + return nil, fmt.Errorf("commitment %s standing does not match its join event", commitment.Commitment) + } + commitment.Snapshot = CommitmentSnapshot{AsOfBlock: block, Claimer: standing.Claimer, + ClockRunning: standing.ClockRunning, ClockDeadline: standing.ClockDeadline, ClockAllowance: standing.ClockAllowance} + batch.Commitments = append(batch.Commitments, commitment) + } + for _, match := range matchByHash { + if unchangedDeletedMatches[match.IDHash] { + // Keep the stored certification block. There is no new match view to publish. + continue + } + view, err := adapter.MatchSnapshot(pinnedCallOpts(ctx, block), match.CommitmentOne, match.CommitmentTwo) + if err != nil { + return nil, fmt.Errorf("reading match %s snapshot: %w", match.IDHash, err) + } + match.Snapshot, err = projectMatchSnapshot(view, block, match.DeletionTxHash != nil) + if err != nil { + return nil, fmt.Errorf("match %s: %w", match.IDHash, err) + } + batch.Matches = append(batch.Matches, match) + } + // Stable ordering makes the batch deterministic without changing event chronology. + slices.SortFunc(batch.Commitments, func(a, b *Commitment) int { return a.Commitment.Cmp(b.Commitment) }) + slices.SortFunc(batch.Matches, func(a, b *Match) int { return a.IDHash.Cmp(b.IDHash) }) + return batch, nil +} + +// Deleted matches have no mutable contract state. Reuse only a snapshot that +// was validated after deletion and committed within the published observation. +func deletedMatchObservationComplete(match *Match, cursor, head uint64) bool { + if match.DeletionTxHash == nil || *match.DeletionTxHash == (common.Hash{}) || match.DeletionLogIndex == nil || + match.DeletionBlockNumber == 0 || match.DeletionBlockNumber > match.Snapshot.AsOfBlock || + match.Snapshot.AsOfBlock > cursor || cursor > head || + match.DeletionReason == MatchDeletionReason_NOT_DELETED || !slices.Contains(MatchDeletionReasonAllValues, match.DeletionReason) || + !slices.Contains(WinnerCommitmentAllValues, match.Winner) { + return false + } + return match.Snapshot == (MatchSnapshot{AsOfBlock: match.Snapshot.AsOfBlock, + Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone}) +} + +func appendTournamentBondEvents(batch *repository.TournamentEventBatch, app *Application, epoch *Epoch, events *TournamentEvents) error { + for _, event := range events.PartialBondRefund { + value, err := Uint256FromBig(event.Value) + if err != nil { + return fmt.Errorf("partial bond refund value: %w", err) + } + batch.BondEvents = append(batch.BondEvents, &BondEvent{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: batch.Tournament.Address, + Type: BondEventPartialRefund, BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + Refund: &PartialBondRefund{Recipient: event.Recipient, Value: value, Success: event.Success}, + }) + } + for _, event := range events.BondRecovered { + payment, err := Uint256FromBig(event.Payment) + if err != nil { + return fmt.Errorf("bond recovery payment: %w", err) + } + burned, err := Uint256FromBig(event.Burned) + if err != nil { + return fmt.Errorf("bond recovery burn: %w", err) + } + batch.BondEvents = append(batch.BondEvents, &BondEvent{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: batch.Tournament.Address, + Type: BondEventRecovered, BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + Recovery: &BondRecovered{Commitment: event.Commitment, Claimer: event.Claimer, Payment: payment, Burned: burned}, + }) + } + return nil +} + +func projectMatchSnapshot(view ObservedMatchSnapshot, block uint64, deleted bool) (MatchSnapshot, error) { + result := MatchSnapshot{AsOfBlock: block, Phase: view.Phase, + TimeoutOutcome: view.TimeoutOutcome, DeferredCharge: view.DeferredCharge} + if deleted && view.Phase != MatchPhaseUninitialized { + return MatchSnapshot{}, fmt.Errorf("deleted match has phase %s", view.Phase) + } + switch view.Phase { + case MatchPhaseBisecting: + if view.Bisecting == nil || view.ReadyToSeal != nil || view.Sealed != nil { + return MatchSnapshot{}, fmt.Errorf("invalid bisecting match payload") + } + v := view.Bisecting + position, err := Uint256FromBig(v.SegmentStartPosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.SegmentStartCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Bisection = &MatchBisectionSnapshot{RevealingParent: v.RevealingParent, WaitingLeft: v.WaitingLeft, + WaitingRight: v.WaitingRight, SegmentStartPosition: position, SegmentStartCycle: cycle, + CurrentHeight: new(v.CurrentHeight), Responder: v.Responder} + case MatchPhaseReadyToSeal: + if view.ReadyToSeal == nil || view.Bisecting != nil || view.Sealed != nil { + return MatchSnapshot{}, fmt.Errorf("invalid ready-to-seal match payload") + } + v := view.ReadyToSeal + position, err := Uint256FromBig(v.SegmentStartPosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.SegmentStartCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Bisection = &MatchBisectionSnapshot{RevealingParent: v.RevealingParent, WaitingLeft: v.WaitingLeft, + WaitingRight: v.WaitingRight, SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: v.Responder} + case MatchPhaseSealed: + if view.Sealed == nil || view.Bisecting != nil || view.ReadyToSeal != nil { + return MatchSnapshot{}, fmt.Errorf("invalid sealed match payload") + } + v := view.Sealed + position, err := Uint256FromBig(v.DivergencePosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.DivergenceCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Sealed = &MatchSealedSnapshot{AgreeState: v.AgreeState, DivergencePosition: position, DivergenceCycle: cycle, + FinalStateOne: v.FinalStateOne, FinalStateTwo: v.FinalStateTwo} + case MatchPhaseUninitialized: + if !deleted || view.Bisecting != nil || view.ReadyToSeal != nil || view.Sealed != nil || + view.TimeoutOutcome != MatchTimeoutNone || view.DeferredCharge != 0 { + return MatchSnapshot{}, fmt.Errorf("uninitialized match has no matching deletion or has live payload") + } + default: + return MatchSnapshot{}, fmt.Errorf("unknown match phase %s", view.Phase) + } + return result, nil +} diff --git a/internal/prt/observation_retirement_test.go b/internal/prt/observation_retirement_test.go new file mode 100644 index 000000000..4d015583b --- /dev/null +++ b/internal/prt/observation_retirement_test.go @@ -0,0 +1,86 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentRetirementRequiresImmutableCommittedSnapshot(t *testing.T) { + base := Tournament{Snapshot: TournamentSnapshot{AsOfBlock: 50, FinishedAtBlock: 40, + Standing: TournamentStandingRootWinner, BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}}} + for _, test := range []struct { + name string + change func(*Tournament) + retired bool + }{ + {"recovered root", func(*Tournament) {}, true}, + {"failed root", func(v *Tournament) { + v.Snapshot.Standing, v.Snapshot.BondRecovery.Disposition = TournamentStandingRootFailed, BondDispositionNoWinner + }, true}, + {"unfinished", func(v *Tournament) { v.Snapshot.FinishedAtBlock = 0 }, false}, + {"finish not covered", func(v *Tournament) { v.Snapshot.FinishedAtBlock = 51 }, false}, + {"snapshot not committed", func(v *Tournament) { v.Snapshot.AsOfBlock = 51 }, false}, + {"recoverable bond", func(v *Tournament) { v.Snapshot.BondRecovery.Disposition = BondDispositionRecoverable }, false}, + {"inner winner still expires", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerWinner + }, false}, + {"expired winner recovered", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerEliminableWinnerExpired + }, true}, + {"missing child creation", func(v *Tournament) { + v.Level, v.Snapshot.Standing = 1, TournamentStandingInnerEliminableNoWinner + }, false}, + {"child no winner", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerEliminableNoWinner + v.Snapshot.BondRecovery.Disposition = BondDispositionNoWinner + }, true}, + } { + t.Run(test.name, func(t *testing.T) { + value := base + test.change(&value) + require.Equal(t, test.retired, tournamentObservationComplete(&value, 50, 100)) + }) + } + require.False(t, tournamentObservationComplete(&base, 50, 49), "a later snapshot cannot decide retirement at an older head") +} + +func TestRetiredParentStillObservesMutableChildren(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.Status = EpochStatus_ClaimAccepted + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := &Tournament{Address: *epoch.TournamentAddress, MaxLevel: 2, Snapshot: TournamentSnapshot{ + AsOfBlock: 50, FinishedAtBlock: 40, Standing: TournamentStandingRootWinner, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}, + }} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), parent.Address.Hex()).Return(parent, nil).Once() + child := f.tournament(epoch, common.HexToAddress("0x101"), 1, 2, 45, 100, &TournamentEvents{}, nil) + retiredChild := &Tournament{Address: common.HexToAddress("0x102"), Level: 1, MaxLevel: 2, + CreationEvent: &TournamentCreationEvent{BlockNumber: 10}, Snapshot: TournamentSnapshot{ + AsOfBlock: 50, FinishedAtBlock: 45, Standing: TournamentStandingInnerEliminableNoWinner, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionNoWinner}, + }} + f.children(epoch, parent, child, retiredChild) + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), retiredChild.Address.Hex()).Return(retiredChild, nil).Once() + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Address == child.Address && + batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(50), parent.Snapshot.AsOfBlock) + require.Equal(t, uint64(50), retiredChild.Snapshot.AsOfBlock) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", parent.Address) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", retiredChild.Address) +} diff --git a/internal/prt/observer_projection_test.go b/internal/prt/observer_projection_test.go new file mode 100644 index 000000000..b2e01969a --- /dev/null +++ b/internal/prt/observer_projection_test.go @@ -0,0 +1,296 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "encoding/json" + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + unknownEnumValue = "UNKNOWN" + projectionCreateCase = "create" + projectionUpdateCase = "update" + unknownStandingCase = "unknown standing" +) + +func TestTournamentProjectionPreservesWinnerPresence(t *testing.T) { + for _, test := range []struct { + name string + state model.TournamentStandingState + level TournamentLevel + winner bool + }{ + {name: "failed root", state: model.TournamentStandingRootFailed}, + {name: "root result exists", state: model.TournamentStandingRootWinner, winner: true}, + {name: "inner without winner", state: model.TournamentStandingInnerEliminableNoWinner, level: 1}, + {name: "inner result exists", state: model.TournamentStandingInnerWinner, level: 1, winner: true}, + {name: "expired inner winner", state: model.TournamentStandingInnerEliminableWinnerExpired, level: 1}, + } { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(test.name+"/"+operation, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo := newPRTServiceMock() + descriptor := TournamentDescriptor{BaseCycle: big.NewInt(0), Level: uint64(test.level), Height: 7, + Kind: model.TournamentKindNonLeaf} + hasCandidate := test.winner || test.state == model.TournamentStandingInnerEliminableWinnerExpired + standing := TournamentStanding{State: test.state, FinishedAt: 9, HasCandidate: hasCandidate} + if hasCandidate { + standing.Candidate = *epoch.Commitment + } + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(descriptor, nil).Once() + adapter.On("Standing", opts).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, test.level, test.state) + var stored *model.Tournament + if update { + tournament := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, + Level: uint64(test.level), Height: descriptor.Height, + Kind: model.TournamentKindNonLeaf, Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + require.NoError(t, service.refreshTournament(t.Context(), app, epoch, test.level, adapter, tournament, 10)) + stored = tournament + } else { + tournament, err := service.readTournament(t.Context(), app, epoch, test.level, + nil, nil, *epoch.TournamentAddress, adapter, 3, 10) + require.NoError(t, err) + require.NotNil(t, tournament) + stored = tournament + } + require.NotNil(t, stored) + require.Equal(t, uint64(9), stored.Snapshot.FinishedAtBlock) + data, err := json.Marshal(stored) + require.NoError(t, err) + var projection map[string]any + require.NoError(t, json.Unmarshal(data, &projection)) + projection = projection["snapshot"].(map[string]any) + require.Equal(t, uint64(10), stored.Snapshot.AsOfBlock) + require.Equal(t, test.state, stored.Snapshot.Standing) + if hasCandidate { + require.Equal(t, epoch.Commitment, stored.Snapshot.Candidate) + } else { + require.Nil(t, stored.Snapshot.Candidate) + } + if test.winner { + require.Equal(t, epoch.Commitment, stored.Snapshot.WinnerCommitment) + require.NotNil(t, stored.Snapshot.FinalStateHash, "a zero final-state hash is a value, not absence") + require.Zero(t, *stored.Snapshot.FinalStateHash) + require.Equal(t, epoch.Commitment.Hex(), projection["winner_commitment"]) + require.Equal(t, (common.Hash{}).Hex(), projection["final_state_hash"]) + } else { + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + require.Nil(t, projection["winner_commitment"]) + require.Nil(t, projection["final_state_hash"]) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls, "projection reads must not write tournament state") + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + }) + } + } +} + +func TestTournamentProjectionClearsNonTerminalResult(t *testing.T) { + for _, test := range []struct { + name string + state model.TournamentStandingState + }{ + {name: "active matches", state: model.TournamentStandingMatchesActive}, + {name: "awaiting closure", state: model.TournamentStandingAwaitingClosure}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Log2Step: 20, Height: 7, + Kind: model.TournamentKindNonLeaf, Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: test.state}, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, test.state) + + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10)) + require.Nil(t, projection.Snapshot.WinnerCommitment) + require.Nil(t, projection.Snapshot.FinalStateHash) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } +} + +func TestTournamentProjectionRejectsChangedStoredGeometry(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*model.Tournament) + }{ + {name: "level", mutate: func(p *model.Tournament) { p.Level++ }}, + {name: "stride", mutate: func(p *model.Tournament) { p.Log2Step++ }}, + {name: "height", mutate: func(p *model.Tournament) { p.Height++ }}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Log2Step: 20, Height: 7} + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(10))).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + test.mutate(projection) + before := *projection + + err := s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10) + require.ErrorContains(t, err, "descriptor does not match stored geometry") + require.Equal(t, before, *projection) + require.Empty(t, repo.Calls) + adapter.AssertNotCalled(t, "Standing", mock.Anything) + adapter.AssertExpectations(t) + }) + } +} + +func TestTournamentProjectionLeavesReadErrorsUnchanged(t *testing.T) { + for _, test := range []struct { + name string + standing TournamentStanding + readErr error + }{ + {name: "standing RPC error", readErr: errors.New("standing RPC unavailable")}, + {name: unknownStandingCase, standing: TournamentStanding{State: unknownEnumValue}}, + {name: "future result", standing: TournamentStanding{State: model.TournamentStandingRootWinner, FinishedAt: 11}}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, + Log2Step: 20, Height: 7, Kind: model.TournamentKindNonLeaf, + Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + before := *projection + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, + Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + adapter.On("Standing", opts).Return(test.standing, test.readErr).Once() + + err := s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10) + require.Error(t, err) + if test.readErr != nil { + require.ErrorIs(t, err, test.readErr) + } + require.Equal(t, before, *projection) + require.Same(t, before.Snapshot.WinnerCommitment, projection.Snapshot.WinnerCommitment) + require.Same(t, before.Snapshot.FinalStateHash, projection.Snapshot.FinalStateHash) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } +} + +func TestJoinChecksSupportedRootGeometry(t *testing.T) { + for _, height := range []uint64{model.Log2EpochComputationHashLeafCount, 7} { + t.Run(new(big.Int).SetUint64(height).String(), func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + service, repo := newPRTServiceMock() + service.submissionTimeout = time.Second + service.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: common.HexToAddress("0x600")}) + factory := &adapterFactoryMock{} + service.adapterFactory = factory + adapter := &tournamentAdapterMock{} + factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index, + epoch.TournamentAddress.Hex(), epoch.Commitment.Hex()).Return(nil, nil).Once() + opts := mock.MatchedBy(func(opts *bind.CallOpts) bool { return resultCallOptsAtBlock(20)(opts) }) + adapter.On("CommitmentStanding", opts, [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{Height: height}, nil).Once() + if height == model.Log2EpochComputationHashLeafCount { + adapter.On("BondValue", opts).Return(big.NewInt(0), nil).Once() + adapter.On("JoinTournament", mock.Anything, [32]byte(*epoch.MachineHash), + mock.Anything, mock.Anything, mock.Anything).Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + joined, err := service.reactToTournament(t.Context(), app, epoch, 20) + require.NoError(t, err) + require.False(t, joined) + require.Equal(t, tournamentActionJoin, service.pendingTransactions[app.ID].Action) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } else { + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(reasonContains("commitment height 48", "factory and node versions"))).Return(nil).Once() + joined, err := service.reactToTournament(t.Context(), app, epoch, 20) + require.NoError(t, err) + require.False(t, joined) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + adapter.AssertNotCalled(t, "JoinTournament", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + adapter.AssertExpectations(t) + factory.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestRootWinnerMismatchDoesNotChangeLocalHealthDuringObservation(t *testing.T) { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(operation, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo := newPRTServiceMock() + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, + }, nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{ + State: model.TournamentStandingRootWinner, FinishedAt: 9, HasCandidate: true, Candidate: common.HexToHash("0xbad"), + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, model.TournamentStandingRootWinner) + if update { + err := service.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, + &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Kind: model.TournamentKindNonLeaf}, 10) + require.NoError(t, err) + } else { + tournament, err := service.readTournament(t.Context(), app, epoch, RootLevel, + nil, nil, *epoch.TournamentAddress, adapter, 3, 10) + require.NoError(t, err) + require.Equal(t, common.HexToHash("0xbad"), *tournament.Snapshot.WinnerCommitment) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/observer_test.go b/internal/prt/observer_test.go new file mode 100644 index 000000000..037344724 --- /dev/null +++ b/internal/prt/observer_test.go @@ -0,0 +1,367 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentDescriptorFromBinding_MapsFieldsAndOwnsBaseCycle(t *testing.T) { + baseCycle := big.NewInt(123) + binding := itournament.ITournamentTournamentDescriptor{ + BaseCycle: baseCycle, + Log2Stride: 20, + Height: 48, + Level: 2, + Kind: 0, + } + + descriptor, err := tournamentDescriptorFromBinding(binding) + require.NoError(t, err) + require.Equal(t, int64(123), descriptor.BaseCycle.Int64()) + require.Equal(t, binding.Log2Stride, descriptor.Log2Stride) + require.Equal(t, binding.Height, descriptor.Height) + require.Equal(t, binding.Level, descriptor.Level) + require.Equal(t, model.TournamentKindLeaf, descriptor.Kind) + + baseCycle.SetInt64(456) + require.Equal(t, int64(123), descriptor.BaseCycle.Int64(), "the DTO must own its base-cycle value") + descriptor.BaseCycle.SetInt64(789) + require.Equal(t, int64(456), binding.BaseCycle.Int64(), "the binding must not share the DTO base-cycle value") + + _, err = tournamentDescriptorFromBinding(itournament.ITournamentTournamentDescriptor{}) + require.ErrorContains(t, err, "base cycle is nil") +} + +func TestTournamentStandingFromBinding_MapsAllFields(t *testing.T) { + for _, flags := range [][2]bool{{true, false}, {false, true}} { + binding := itournament.ITournamentTournamentStandingView{ + Standing: 4, + AcceptsJoins: flags[0], HasCandidate: flags[1], + Candidate: common.HexToHash("0x21"), FinalState: common.HexToHash("0x22"), FinishedAt: 101, + } + standing, err := tournamentStandingFromBinding(binding) + require.NoError(t, err) + require.Equal(t, TournamentStanding{ + State: model.TournamentStandingInnerWinner, AcceptsJoins: flags[0], HasCandidate: flags[1], + Candidate: common.HexToHash("0x21"), FinalState: common.HexToHash("0x22"), FinishedAt: 101, + }, standing) + } +} + +func TestCommitmentStandingFromBinding_MapsAllFields(t *testing.T) { + binding := itournament.ITournamentCommitmentStandingView{ + Joined: true, + FinalState: common.HexToHash("0x31"), + Claimer: common.HexToAddress("0x32"), + } + + standing := commitmentStandingFromBinding(binding) + + require.Equal(t, binding.Joined, standing.Joined) + require.Equal(t, common.Hash(binding.FinalState), standing.FinalState) + require.Equal(t, binding.Claimer, standing.Claimer) +} + +func TestValidateTournamentDescriptor_LevelCountAndKind(t *testing.T) { + valid := func(level uint64, kind model.TournamentKind) TournamentDescriptor { + return TournamentDescriptor{ + BaseCycle: big.NewInt(0), + Level: level, + Kind: kind, + } + } + tests := []struct { + name string + descriptor TournamentDescriptor + expectedLevel TournamentLevel + levelCount uint64 + wantError string + }{ + {name: "root of three levels", descriptor: valid(0, model.TournamentKindNonLeaf), levelCount: 3}, + {name: "dynamic last level", descriptor: valid(3, model.TournamentKindLeaf), expectedLevel: 3, levelCount: 4}, + {name: "zero level count", descriptor: valid(0, model.TournamentKindLeaf), wantError: "level count is zero"}, + { + name: "nil base cycle", + descriptor: TournamentDescriptor{Level: 0, Kind: model.TournamentKindNonLeaf}, + levelCount: 2, + wantError: "invalid base cycle", + }, + { + name: "negative base cycle", + descriptor: TournamentDescriptor{BaseCycle: big.NewInt(-1), Level: 0, Kind: model.TournamentKindNonLeaf}, + levelCount: 2, + wantError: "invalid base cycle", + }, + { + name: "unexpected level", + descriptor: valid(1, model.TournamentKindNonLeaf), + expectedLevel: 2, + levelCount: 3, + wantError: "does not match expected level", + }, + { + name: "level outside count", + descriptor: valid(2, model.TournamentKindLeaf), + expectedLevel: 2, + levelCount: 2, + wantError: "outside level count", + }, + { + name: "unknown kind", + descriptor: valid(0, model.TournamentKind("UNKNOWN")), + levelCount: 2, + wantError: "unknown kind", + }, + { + name: "leaf before last level", + descriptor: valid(0, model.TournamentKindLeaf), + levelCount: 3, + wantError: "expected NON_LEAF", + }, + { + name: "non-leaf at last level", + descriptor: valid(2, model.TournamentKindNonLeaf), + expectedLevel: 2, + levelCount: 3, + wantError: "expected LEAF", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateTournamentDescriptor(test.descriptor, test.expectedLevel, test.levelCount) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantError) + }) + } +} + +func TestValidateTournamentStanding_AllStates(t *testing.T) { + candidate := common.HexToHash("0x41") + tests := []struct { + name string + descriptor TournamentDescriptor + standing TournamentStanding + terminal bool + }{ + { + name: "matches active", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{State: model.TournamentStandingMatchesActive}, + }, + { + name: "matches active and accepts joins", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingMatchesActive, AcceptsJoins: true, + }, + }, + { + name: "matches active with candidate", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingMatchesActive, HasCandidate: true, Candidate: candidate, + }, + }, + { + name: "awaiting closure", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, + }, + }, + { + name: "root winner", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingRootWinner, HasCandidate: true, Candidate: candidate, FinishedAt: 9, + }, + terminal: true, + }, + { + name: "root failed", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{State: model.TournamentStandingRootFailed, FinishedAt: 9}, + terminal: true, + }, + { + name: "inner winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{ + State: model.TournamentStandingInnerWinner, HasCandidate: true, Candidate: candidate, + FinishedAt: 9, + }, + terminal: true, + }, + { + name: "inner eliminable without winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{State: model.TournamentStandingInnerEliminableNoWinner, FinishedAt: 9}, + terminal: true, + }, + { + name: "inner eliminable with expired winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{ + State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: candidate, FinishedAt: 9, + }, + terminal: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NoError(t, validateTournamentStanding(test.standing, 10)) + require.Equal(t, test.terminal, isTerminalTournamentStanding(test.standing.State)) + if test.terminal { + require.NotZero(t, test.standing.FinishedAt) + } else { + require.Zero(t, test.standing.FinishedAt) + } + }) + } +} + +func TestValidateTournamentStanding_RejectsInvalidStoredBoundary(t *testing.T) { + for _, test := range []struct { + name string + standing TournamentStanding + wantError string + }{ + {name: unknownStandingCase, standing: TournamentStanding{State: unknownEnumValue}, wantError: unknownStandingCase}, + {name: "terminal without finished block", standing: TournamentStanding{State: model.TournamentStandingRootFailed}, + wantError: "zero finished block"}, + {name: "finished after selected head", standing: TournamentStanding{State: model.TournamentStandingRootWinner, FinishedAt: 11}, + wantError: "after observed block"}, + } { + t.Run(test.name, func(t *testing.T) { + require.ErrorContains(t, validateTournamentStanding(test.standing, 10), test.wantError) + }) + } +} + +func TestReadTournament_PinsDescriptorAndStandingReads(t *testing.T) { + const mostRecentBlock uint64 = 100 + app := &model.Application{ID: 1, Name: "app", IApplicationAddress: common.HexToAddress("0x91")} + epoch := &model.Epoch{Index: 3} + tournamentAddress := common.HexToAddress("0x92") + descriptor := TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: uint64(RootLevel), Kind: model.TournamentKindNonLeaf, + } + standing := TournamentStanding{State: model.TournamentStandingMatchesActive} + pinned := mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts != nil && opts.Context != nil && opts.BlockNumber != nil && + opts.BlockNumber.Uint64() == mostRecentBlock + }) + + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", pinned).Return(descriptor, nil).Once() + adapter.On("Standing", pinned).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, pinned, RootLevel, standing.State) + s, repo := newPRTServiceMock() + + tournament, err := s.readTournament( + context.Background(), app, epoch, RootLevel, nil, nil, tournamentAddress, adapter, 2, mostRecentBlock, + ) + require.NoError(t, err) + require.Equal(t, tournamentAddress, tournament.Address) + require.Equal(t, uint64(2), tournament.MaxLevel) + require.Equal(t, uint64(RootLevel), tournament.Level) + require.Empty(t, repo.Calls, "reading a projection must not write it") + repo.AssertExpectations(t) + adapter.AssertExpectations(t) +} + +func TestGatherTournamentData_UsesDynamicLastLevel(t *testing.T) { + for _, test := range []struct { + name string + level TournamentLevel + wantChildScan bool + }{ + {name: "last level from four-level factory", level: 3}, + {name: "level before last", level: 2, wantChildScan: true}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, app: prtRevertTestApp(), factory: &adapterFactoryMock{}} + s.adapterFactory = f.factory + epoch := checkpointEpoch(3, "0x92") + tournament := f.tournament(epoch, *epoch.TournamentAddress, uint64(test.level), 4, 100, 100, &TournamentEvents{}, nil) + if test.wantChildScan { + f.children(epoch, tournament) + } + batches, err := s.gatherTournamentData(t.Context(), f.app, epoch, test.level, nil, nil, + *epoch.TournamentAddress, 4, 100) + require.NoError(t, err) + require.Len(t, batches, 1) + require.Equal(t, uint64(test.level), batches[0].Tournament.Level) + repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + }) + } +} + +func TestValidateJoinedCommitment_FinalState(t *testing.T) { + machineHash := common.HexToHash("0xa1") + t.Run("missing local machine hash", func(t *testing.T) { + s, repo := newPRTServiceMock() + app := &model.Application{ID: 1, Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3} + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "has no machine hash") + })).Return(nil).Once() + + err := s.validateJoinedCommitment( + context.Background(), app, epoch, CommitmentStanding{Joined: true}, + ) + require.ErrorContains(t, err, "has no machine hash") + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) + repo.AssertExpectations(t) + }) + + t.Run("match", func(t *testing.T) { + s, repo := newPRTServiceMock() + app := &model.Application{ID: 1, Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + + require.NoError(t, s.validateJoinedCommitment( + context.Background(), app, epoch, CommitmentStanding{Joined: true, FinalState: machineHash}, + )) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertExpectations(t) + }) + + t.Run("latest mismatch does not change application status", func(t *testing.T) { + s, repo := newPRTServiceMock() + app := &model.Application{ID: 1, Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + onchain := common.HexToHash("0xa2") + + err := s.validateJoinedCommitment( + context.Background(), app, epoch, CommitmentStanding{Joined: true, FinalState: onchain}, + ) + require.ErrorContains(t, err, "inconsistent final state") + require.ErrorContains(t, err, machineHash.String()) + require.ErrorContains(t, err, onchain.String()) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) +} diff --git a/internal/prt/prt.go b/internal/prt/prt.go index 9521726e0..d76c0957c 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -128,115 +128,7 @@ func (s *Service) setApplicationFailed(ctx context.Context, app *Application, re return appstatus.SetFailedf(ctx, s.Logger, s.repository, app, reasonFmt, args...) } -func (s *Service) saveTournamentEvents(ctx context.Context, app *Application, epoch *Epoch, - tournamentAddress common.Address, events *TournamentEvents, lastBlock uint64) error { - commitments := make([]*Commitment, 0, len(events.CommitmentJoined)) - for _, ev := range events.CommitmentJoined { - c := Commitment{ - ApplicationID: app.ID, - EpochIndex: epoch.Index, - TournamentAddress: tournamentAddress, - Commitment: ev.Commitment, - FinalStateHash: ev.FinalStateHash, - SubmitterAddress: ev.Submitter, - BlockNumber: ev.Raw.BlockNumber, - TxHash: ev.Raw.TxHash, - } - s.Logger.Info("Found CommitmentJoined event", - "application", app.Name, - "epoch_index", epoch.Index, - "tournament", tournamentAddress.Hex(), - "commitment", c.Commitment.String()) - commitments = append(commitments, &c) - } - - matches := make([]*Match, 0, len(events.MatchCreated)) - for _, ev := range events.MatchCreated { - m := Match{ - ApplicationID: app.ID, - EpochIndex: epoch.Index, - TournamentAddress: tournamentAddress, - IDHash: ev.MatchIdHash, - CommitmentOne: ev.One, - CommitmentTwo: ev.Two, - LeftOfTwo: ev.LeftOfTwo, - BlockNumber: ev.Raw.BlockNumber, - TxHash: ev.Raw.TxHash, - Winner: WinnerCommitment_NONE, - DeletionReason: MatchDeletionReason_NOT_DELETED, - DeletionBlockNumber: 0, - DeletionTxHash: common.Hash{}, - } - s.Logger.Info("Found MatchCreated event", - "application", app.Name, - "epoch_index", epoch.Index, - "tournament", tournamentAddress.Hex(), - "id_hash", m.IDHash.String(), - "one", m.CommitmentOne.String(), - "two", m.CommitmentTwo.String(), - "leftOfTwo", m.LeftOfTwo.String()) - matches = append(matches, &m) - } - - matchAdvanced := make([]*MatchAdvanced, 0, len(events.MatchAdvanced)) - for _, ev := range events.MatchAdvanced { - m := &MatchAdvanced{ - ApplicationID: app.ID, - EpochIndex: epoch.Index, - TournamentAddress: tournamentAddress, - IDHash: ev.MatchIdHash, - OtherParent: ev.OtherParent, - LeftNode: ev.LeftNode, - BlockNumber: ev.Raw.BlockNumber, - TxHash: ev.Raw.TxHash, - } - s.Logger.Info("Found MatchAdvanced event", - "application", app.Name, - "epoch_index", epoch.Index, - "tournament", tournamentAddress.Hex(), - "id_hash", m.IDHash.String(), - "other_parent", m.OtherParent.String(), - "left_node", m.LeftNode.String()) - matchAdvanced = append(matchAdvanced, m) - } - - matchDeleted := make([]*Match, 0, len(events.MatchDeleted)) - for _, ev := range events.MatchDeleted { - m := Match{ - ApplicationID: app.ID, - EpochIndex: epoch.Index, - TournamentAddress: tournamentAddress, - IDHash: ev.MatchIdHash, - CommitmentOne: ev.One, - CommitmentTwo: ev.Two, - Winner: WinnerCommitmentFromUint8(ev.WinnerCommitment), - DeletionReason: MatchDeletionReasonFromUint8(ev.Reason), - DeletionBlockNumber: ev.Raw.BlockNumber, - DeletionTxHash: ev.Raw.TxHash, - } - s.Logger.Info("Found MatchDeleted event", - "application", app.Name, - "epoch_index", epoch.Index, - "tournament", tournamentAddress.Hex(), - "id_hash", ((common.Hash)(ev.MatchIdHash)).String(), - "one", ((common.Hash)(ev.One)).String(), - "two", ((common.Hash)(ev.Two)).String(), - "winner", m.Winner.String(), - "reason", m.DeletionReason.String(), - ) - matchDeleted = append(matchDeleted, &m) - } - - err := s.repository.StoreTournamentEvents(ctx, app.ID, commitments, matches, matchAdvanced, matchDeleted, lastBlock) - if err != nil { - s.Logger.Error("failed to save tournament events", "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err - } - return nil -} - -func (s *Service) createTournament( +func (s *Service) readTournament( ctx context.Context, app *Application, epoch *Epoch, @@ -244,67 +136,27 @@ func (s *Service) createTournament( parentMatchIDHash *common.Hash, parentTournamentAddress *common.Address, tournamentAddress common.Address, + adapter TournamentAdapter, + levelCount uint64, + mostRecentBlock uint64, ) (*Tournament, error) { - adapter, err := s.adapterFactory.CreateTournamentAdapter(tournamentAddress) + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + descriptor, err := adapter.Descriptor(callOpts) if err != nil { - s.Logger.Error("failed to create tournament adapter", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament descriptor", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) return nil, err } - - constants, err := adapter.Constants(nil) - if err != nil { - s.Logger.Error("failed to fetch tournament constants", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return nil, err + if err := validateTournamentDescriptor(descriptor, level, levelCount); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) } - - var winnerCommitmentPtr *common.Hash - var finalStatePtr *common.Hash - finishedAtBlock := uint64(0) - if epoch.ClaimTransactionHash != nil { - finished, timeFinished, err := adapter.TimeFinished(nil) - if err != nil { - s.Logger.Error("failed to fetch tournament finished at time", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return nil, err - } - if !finished { - err := fmt.Errorf("epoch %d: tournament %s should be finished but is not", - epoch.Index, tournamentAddress.String()) - s.Logger.Error("tournament should be finished", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return nil, err - } - finishedAtBlock = timeFinished - - _, winnerCommitment, finalState, err := adapter.Result(nil) - if err != nil { - s.Logger.Error("failed to fetch tournament result", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return nil, err - } - - // root tournament with no winner. - if level == RootLevel && winnerCommitment == [32]byte{} { - return nil, s.setApplicationDiverged(ctx, app, - "Epoch %d root tournament %s has finished without winners.", - epoch.Index, tournamentAddress.String()) - } - - if level == RootLevel && *epoch.Commitment != winnerCommitment { - return nil, s.setApplicationDiverged(ctx, app, - "Epoch %d has inconsistent commitment between off-chain (%s) and on-chain (%s).", - epoch.Index, epoch.Commitment.String(), hexutil.Encode(winnerCommitment[:])) - } - winnerCommitmentPtr = new(common.Hash) - *winnerCommitmentPtr = winnerCommitment - - finalStatePtr = new(common.Hash) - *finalStatePtr = finalState - } else { - s.Logger.Info("Found open tournament", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + if descriptor.StartInstant > mostRecentBlock { + return nil, fmt.Errorf("tournament %s starts at block %d after observed block %d", + tournamentAddress, descriptor.StartInstant, mostRecentBlock) + } + baseCycle, err := Uint256FromBig(descriptor.BaseCycle) + if err != nil { + return nil, fmt.Errorf("tournament %s base cycle: %w", tournamentAddress, err) } t := &Tournament{ @@ -313,26 +165,94 @@ func (s *Service) createTournament( Address: tournamentAddress, ParentMatchIDHash: parentMatchIDHash, ParentTournamentAddress: parentTournamentAddress, - MaxLevel: constants.MaxLevel, - Level: constants.Level, - Log2Step: constants.Log2step, - Height: constants.Height, - WinnerCommitment: winnerCommitmentPtr, - FinalStateHash: finalStatePtr, - FinishedAtBlock: finishedAtBlock, + MaxLevel: levelCount, + Level: descriptor.Level, + Log2Step: descriptor.Log2Stride, + Height: descriptor.Height, + InitialHash: descriptor.InitialHash, + BaseCycle: baseCycle, + Kind: descriptor.Kind, + StartInstant: descriptor.StartInstant, + Allowance: descriptor.Allowance, + } + if err := s.updateTournamentStanding(ctx, app, epoch, level, adapter, t, mostRecentBlock); err != nil { + return nil, err } - - err = s.repository.CreateTournament(ctx, app.IApplicationAddress.Hex(), t) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to create tournament in database", err, - "level", level, "application", app.Name, + if t.Snapshot.FinishedAtBlock == 0 { + s.Logger.Info("Found open tournament", "level", level, "application", app.Name, "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) - return nil, err } return t, nil } -func (s *Service) updateTournamentIfFinished( +func (s *Service) logFailedRootTournament(app *Application, epochIndex uint64, tournament common.Address) { + s.Logger.Warn("Root tournament finished without a winner; this consensus cannot advance. "+ + "Ask the application guardian to consider foreclosure", + "application", app.Name, "epoch_index", epochIndex, "tournament", tournament) +} + +func validateTournamentDescriptor(descriptor TournamentDescriptor, expectedLevel TournamentLevel, levelCount uint64) error { + if levelCount == 0 { + return errors.New("tournament level count is zero") + } + if descriptor.BaseCycle == nil || descriptor.BaseCycle.Sign() < 0 { + return errors.New("tournament descriptor has invalid base cycle") + } + if descriptor.Level != uint64(expectedLevel) { + return fmt.Errorf("tournament descriptor level %d does not match expected level %d", + descriptor.Level, expectedLevel) + } + if descriptor.Level >= levelCount { + return fmt.Errorf("tournament descriptor level %d is outside level count %d", + descriptor.Level, levelCount) + } + if descriptor.Kind != TournamentKindLeaf && descriptor.Kind != TournamentKindNonLeaf { + return fmt.Errorf("tournament descriptor has unknown kind %s", descriptor.Kind) + } + expectedKind := TournamentKindNonLeaf + if descriptor.Level+1 == levelCount { + expectedKind = TournamentKindLeaf + } + if descriptor.Kind != expectedKind { + return fmt.Errorf("tournament descriptor level %d has kind %s, expected %s", + descriptor.Level, descriptor.Kind, expectedKind) + } + return nil +} + +func validateTournamentStanding(standing TournamentStanding, mostRecentBlock uint64) error { + switch standing.State { + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, TournamentStandingRootFailed, TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + default: + return fmt.Errorf("tournament has unknown standing %s", standing.State) + } + if isTerminalTournamentStanding(standing.State) { + if standing.FinishedAt == 0 { + return errors.New("terminal tournament has zero finished block") + } + if standing.FinishedAt > mostRecentBlock { + return fmt.Errorf("tournament finished at block %d after observed block %d", + standing.FinishedAt, mostRecentBlock) + } + } + return nil +} + +func isTerminalTournamentStanding(state TournamentStandingState) bool { + switch state { + case TournamentStandingRootWinner, TournamentStandingRootFailed, TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + return true + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure: + return false + default: + return false + } +} + +func (s *Service) refreshTournament( ctx context.Context, app *Application, epoch *Epoch, @@ -341,47 +261,107 @@ func (s *Service) updateTournamentIfFinished( t *Tournament, mostRecentBlock uint64, ) error { - callOpts := &bind.CallOpts{ - Context: ctx, - BlockNumber: new(big.Int).SetUint64(mostRecentBlock), - } + callOpts := pinnedCallOpts(ctx, mostRecentBlock) - finished, timeFinished, err := adapter.TimeFinished(callOpts) + descriptor, err := adapter.Descriptor(callOpts) if err != nil { - s.Logger.Error("failed to fetch tournament finished at time", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", t.Address.String(), "error", err) + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament descriptor", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", t.Address.String()) return err } - if !finished { - return nil + if err := validateTournamentDescriptor(descriptor, level, t.MaxLevel); err != nil { + return fmt.Errorf("tournament %s: %w", t.Address, err) + } + if t.Level != descriptor.Level || t.Log2Step != descriptor.Log2Stride || t.Height != descriptor.Height { + return fmt.Errorf("tournament %s descriptor does not match stored geometry", t.Address) + } + if t.InitialHash != descriptor.InitialHash || t.BaseCycle.ToBig().Cmp(descriptor.BaseCycle) != 0 || + t.Kind != descriptor.Kind || t.StartInstant != descriptor.StartInstant || t.Allowance != descriptor.Allowance { + return fmt.Errorf("tournament %s descriptor does not match stored configuration", t.Address) } - t.FinishedAtBlock = timeFinished + return s.updateTournamentStanding(ctx, app, epoch, level, adapter, t, mostRecentBlock) +} - _, winnerCommitment, finalState, err := adapter.Result(callOpts) +// updateTournamentStanding applies the current chain view. A prior winner can +// expire, so a finished tournament must still be refreshed. +func (s *Service) updateTournamentStanding( + ctx context.Context, + app *Application, + epoch *Epoch, + level TournamentLevel, + adapter TournamentAdapter, + t *Tournament, + mostRecentBlock uint64, +) error { + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + standing, err := adapter.Standing(callOpts) if err != nil { - s.Logger.Error("failed to fetch tournament result", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", t.Address.String(), "error", err) + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament standing", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", t.Address.String()) return err } - - // root tournament with no winner. - if level == RootLevel && winnerCommitment == [32]byte{} { - return s.setApplicationDiverged(ctx, app, - "Epoch %d root tournament %s has finished without winners.", - epoch.Index, t.Address.String()) + if err := validateTournamentStanding(standing, mostRecentBlock); err != nil { + return fmt.Errorf("tournament %s: %w", t.Address, err) } - - if level == RootLevel && *epoch.Commitment != winnerCommitment { - return s.setApplicationDiverged(ctx, app, "Epoch %d has inconsistent commitment between off-chain (%s) and on-chain (%s)", - epoch.Index, epoch.Commitment.String(), hexutil.Encode(winnerCommitment[:])) + if standing.State == TournamentStandingMatchesActive { + s.warnUnsupportedDispute(app, epoch.Index, t.Address) } - t.WinnerCommitment = new(common.Hash) - *t.WinnerCommitment = winnerCommitment - - t.FinalStateHash = new(common.Hash) - *t.FinalStateHash = finalState - - return s.repository.UpdateTournament(ctx, app.IApplicationAddress.Hex(), t) + if level == RootLevel && standing.State == TournamentStandingRootFailed { + s.logFailedRootTournament(app, epoch.Index, t.Address) + } + snapshot := TournamentSnapshot{ + AsOfBlock: mostRecentBlock, Standing: standing.State, AcceptsJoins: standing.AcceptsJoins, + FinishedAtBlock: standing.FinishedAt, + } + if standing.HasCandidate { + snapshot.Candidate = new(standing.Candidate) + } + if standing.State == TournamentStandingRootWinner || standing.State == TournamentStandingInnerWinner { + snapshot.WinnerCommitment = new(standing.Candidate) + snapshot.FinalStateHash = new(standing.FinalState) + } + if standing.State == TournamentStandingInnerWinner { + snapshot.ParentCommitment = new(standing.ParentCommitment) + snapshot.WinnerExpiresAt = standing.WinnerExpiresAt + } + if level != RootLevel { + result, err := adapter.InnerResult(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch inner tournament result", err, + "application", app.Name, "tournament", t.Address) + return err + } + snapshot.InnerResult = &TournamentInnerResult{Disposition: result.Disposition} + switch result.Disposition { + case InnerTournamentWinner: + snapshot.InnerResult.ParentCommitment = new(result.ParentCommitment) + snapshot.InnerResult.PausedAllowance = result.PausedAllowance + case InnerTournamentUnsettled, InnerTournamentEliminable: + default: + return fmt.Errorf("tournament %s has unknown inner disposition %s", t.Address, result.Disposition) + } + } + recovery, err := adapter.BondRecovery(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament bond recovery", err, + "application", app.Name, "tournament", t.Address) + return err + } + snapshot.BondRecovery.Disposition = recovery.Disposition + switch recovery.Disposition { + case BondDispositionRecoverable: + payment, err := Uint256FromBig(recovery.Payment) + if err != nil { + return fmt.Errorf("tournament %s bond payment: %w", t.Address, err) + } + snapshot.BondRecovery.Claimer = new(recovery.Claimer) + snapshot.BondRecovery.Payment = &payment + case BondDispositionTournamentRunning, BondDispositionNoWinner, BondDispositionRecovered: + default: + return fmt.Errorf("tournament %s has unknown bond disposition %s", t.Address, recovery.Disposition) + } + t.Snapshot = snapshot + return nil } func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentBlock uint64) error { @@ -490,148 +470,6 @@ func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentB return nil } -func (s *Service) fetchTournamentData( - ctx context.Context, - app *Application, - epoch *Epoch, - level TournamentLevel, - parentMatchIDHash *common.Hash, - parentTournamentAddress *common.Address, - tournamentAddress common.Address, - mostRecentBlock uint64, -) error { - s.Logger.Debug("Fetching tournament data", "level", level, "application", app.Name, "tournament", tournamentAddress.String()) - - adapter, err := s.adapterFactory.CreateTournamentAdapter(tournamentAddress) - if err != nil { - s.Logger.Error("failed to create tournament adapter", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err - } - - t, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), tournamentAddress.Hex()) - if err != nil { - s.Logger.Error("failed to load tournament from database", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err - } - if t == nil { - t, err = s.createTournament(ctx, app, epoch, level, - parentMatchIDHash, parentTournamentAddress, tournamentAddress) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to create new tournament", err, - "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) - return err - } - } else if t.FinishedAtBlock == 0 { - err = s.updateTournamentIfFinished(ctx, app, epoch, level, adapter, t, mostRecentBlock) - if err != nil { - s.Logger.Error("failed to check if tournament was finished", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err - } - if t.FinishedAtBlock != 0 { - s.Logger.Info("Found finished tournament", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", t.Address.String()) - } - } - - nextSearchBlock := max(epoch.LastBlock, app.LastTournamentCheckBlock+1) - var endBlock uint64 - if t.FinishedAtBlock != 0 { - if nextSearchBlock > t.FinishedAtBlock { - s.Logger.Debug("No new blocks to search for tournament events", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), - "finished_at_block", t.FinishedAtBlock, "next_search_block", nextSearchBlock) - return nil - } - endBlock = t.FinishedAtBlock - } else { - endBlock = mostRecentBlock - } - - s.Logger.Debug("Searching for tournament events", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), - "next_search_block", nextSearchBlock, "end_block", endBlock) - opts := &bind.FilterOpts{ - Context: ctx, - Start: nextSearchBlock, - End: &endBlock, - } - - events, err := adapter.RetrieveAllEvents(opts) - if err != nil { - s.Logger.Error("failed to retrieve all events from tournament", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err - } - - s.Logger.Debug("Retrieved events for tournament", "level", level, "address", t.Address.String(), - "epoch", epoch.Index, - "commitmentJoined", len(events.CommitmentJoined), - "matchCreated", len(events.MatchCreated), - "matchAdvanced", len(events.MatchAdvanced), - "matchDeleted", len(events.MatchDeleted), - "newInnerTournament", len(events.NewInnerTournament)) - - err = s.saveTournamentEvents(ctx, app, epoch, tournamentAddress, events, endBlock) - if err != nil { - s.Logger.Error("failed to save events for tournament", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", t.Address.String(), "error", err) - return err - } - - if level == BottomLevel { - return nil // no inner tournaments - } - - nextLevel := level + 1 - innerTournaments, _, err := getAllSubTournaments(ctx, s.repository, app.Name, epoch.Index, &tournamentAddress, level+1) - if err != nil { - s.Logger.Error("failed to list inner tournaments", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err - } - - for _, i := range innerTournaments { - s.Logger.Debug("Fetching data for previous open tournament", "level", nextLevel, - "parent_match_id_hash", i.ParentMatchIDHash.String(), - "parent_tournament_address", i.ParentTournamentAddress.String(), - "address", i.Address.String()) - - if i.FinishedAtBlock != 0 { - s.Logger.Debug("Skipping finished inner tournament", "address", i.Address.String()) - continue // already finished - } - - err = s.fetchTournamentData(ctx, app, epoch, nextLevel, i.ParentMatchIDHash, &tournamentAddress, i.Address, mostRecentBlock) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to fetch tournament data", err, - "level", nextLevel, "application", app.Name, - "tournament", i.Address.String()) - return err - } - } - - for _, newInner := range events.NewInnerTournament { - hashID := (common.Hash)(newInner.MatchIdHash) - childAddress := newInner.ChildTournament - - s.Logger.Info("NewInnerTournament event", "id_hash", hashID.String(), "tournament_address", childAddress.String()) - - err = s.fetchTournamentData(ctx, app, epoch, nextLevel, &hashID, &tournamentAddress, childAddress, mostRecentBlock) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to fetch tournament data", err, - "level", nextLevel, "application", app.Name, - "tournament", childAddress.String()) - return err - } - } - - return nil -} - func (s *Service) trySettle(ctx context.Context, app *Application, mostRecentBlock uint64) error { if _, exist := s.currentEpochIndex[app.ID]; !exist { s.currentEpochIndex[app.ID] = 0 @@ -872,6 +710,259 @@ func (s *Service) handleSettleRevert(ctx context.Context, app *Application, epoc return err } +// gatherTournamentData reads a complete subtree without storing projections, +// events, or cursors. The returned batches have parents before their children. +func (s *Service) gatherTournamentData( + ctx context.Context, + app *Application, + epoch *Epoch, + level TournamentLevel, + parentMatchIDHash *common.Hash, + parentTournamentAddress *common.Address, + tournamentAddress common.Address, + levelCount uint64, + mostRecentBlock uint64, +) ([]*repository.TournamentEventBatch, error) { + s.Logger.Debug("Fetching tournament data", "level", level, "application", app.Name, "tournament", tournamentAddress.String()) + + t, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), tournamentAddress.Hex()) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to load tournament from database", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err + } + created := t == nil + if !created { + if t.MaxLevel != levelCount || t.Level != uint64(level) { + return nil, fmt.Errorf("tournament %s database geometry does not match observed hierarchy", tournamentAddress) + } + if tournamentObservationComplete(t, app.LastTournamentCheckBlock, mostRecentBlock) { + // Retirement belongs to this clone only. Its children can still + // expire or recover bonds after the parent stops changing. + return s.gatherInnerTournamentData(ctx, app, epoch, t, &TournamentEvents{}, mostRecentBlock) + } + } + adapter, err := s.adapterFactory.CreateTournamentAdapter(tournamentAddress) + if err != nil { + s.Logger.Error("failed to create tournament adapter", "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) + return nil, err + } + if created { + t, err = s.readTournament(ctx, app, epoch, level, + parentMatchIDHash, parentTournamentAddress, tournamentAddress, adapter, levelCount, mostRecentBlock) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to create new tournament", err, + "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err + } + } + if !created { + // Do not mutate a repository-owned projection before the window commits. + projection := *t + t = &projection + previousFinish := t.Snapshot.FinishedAtBlock + err = s.refreshTournament(ctx, app, epoch, level, adapter, t, mostRecentBlock) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to check if tournament was finished", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err + } + if previousFinish == 0 && t.Snapshot.FinishedAtBlock != 0 { + s.Logger.Info("Found finished tournament", "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", t.Address.String()) + } + } + + nextSearchBlock := max(t.StartInstant, app.LastTournamentCheckBlock+1) + if created { + // First observation must include the clone's history even if its + // creation is below this application's existing checkpoint. + nextSearchBlock = t.StartInstant + } + endBlock := mostRecentBlock + events := &TournamentEvents{} + if nextSearchBlock <= endBlock { + s.Logger.Debug("Searching for tournament events", "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), + "next_search_block", nextSearchBlock, "end_block", endBlock) + opts := &bind.FilterOpts{Context: ctx, Start: nextSearchBlock, End: &endBlock} + events, err = adapter.RetrieveAllEvents(opts) + if err != nil { + return nil, fmt.Errorf("retrieving tournament %s events: %w", tournamentAddress, err) + } + if events == nil { + return nil, fmt.Errorf("tournament %s returned nil events", tournamentAddress) + } + } + + s.Logger.Debug("Retrieved events for tournament", "level", level, "address", t.Address.String(), + "epoch", epoch.Index, + "commitmentJoined", len(events.CommitmentJoined), + "matchCreated", len(events.MatchCreated), + "matchAdvanced", len(events.MatchAdvanced), + "matchDeleted", len(events.MatchDeleted), + "newInnerTournament", len(events.NewInnerTournament)) + + previousCounts := zeroStructuralEventCounts() + previousBondDisposition := BondDispositionTournamentRunning + if !created && nextSearchBlock > t.StartInstant { + previousOpts := pinnedCallOpts(ctx, nextSearchBlock-1) + previousCounts, err = adapter.StructuralEventCounts(previousOpts) + if err != nil { + return nil, fmt.Errorf("reading previous tournament event counts: %w", err) + } + previousBond, err := adapter.BondRecovery(previousOpts) + if err != nil { + return nil, fmt.Errorf("reading previous tournament bond recovery: %w", err) + } + previousBondDisposition = previousBond.Disposition + } + currentCounts, err := adapter.StructuralEventCounts(pinnedCallOpts(ctx, endBlock)) + if err != nil { + return nil, fmt.Errorf("reading current tournament event counts: %w", err) + } + if err := validateTournamentEventCounts(previousCounts, currentCounts, events); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) + } + if err := validateTournamentFinancialEvents(previousBondDisposition, t.Snapshot.BondRecovery.Disposition, events); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) + } + batch, err := s.tournamentEventBatch(ctx, app, epoch, t, adapter, events, endBlock) + if err != nil { + return nil, fmt.Errorf("projecting tournament %s events: %w", tournamentAddress, err) + } + children, err := s.gatherInnerTournamentData(ctx, app, epoch, t, events, mostRecentBlock) + if err != nil { + return nil, err + } + batches := make([]*repository.TournamentEventBatch, 0, 1+len(children)) + batches = append(batches, batch) + return append(batches, children...), nil +} + +// Finished clones reject joins and all match mutations. Once their winner can +// no longer expire and their bond cannot be recovered, their current views and +// events are immutable. Require a committed complete observation first. +func tournamentObservationComplete(t *Tournament, cursor, head uint64) bool { + snapshot := t.Snapshot + if snapshot.FinishedAtBlock == 0 || snapshot.FinishedAtBlock > snapshot.AsOfBlock || + snapshot.AsOfBlock > cursor || snapshot.AsOfBlock > head || + (t.Level != uint64(RootLevel) && t.CreationEvent == nil) { + return false + } + switch snapshot.Standing { + case TournamentStandingRootWinner, TournamentStandingRootFailed, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + return snapshot.BondRecovery.Disposition == BondDispositionNoWinner || + snapshot.BondRecovery.Disposition == BondDispositionRecovered + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, TournamentStandingInnerWinner: + return false + default: + return false + } +} + +func (s *Service) gatherInnerTournamentData( + ctx context.Context, app *Application, epoch *Epoch, t *Tournament, events *TournamentEvents, mostRecentBlock uint64, +) ([]*repository.TournamentEventBatch, error) { + var batches []*repository.TournamentEventBatch + + if t.Level+1 >= t.MaxLevel { + return batches, nil // no inner tournaments + } + + level, levelCount, tournamentAddress := TournamentLevel(t.Level), t.MaxLevel, t.Address + nextLevel := level + 1 + innerTournaments, _, err := getAllSubTournaments(ctx, s.repository, app.Name, epoch.Index, &tournamentAddress, level+1) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to list inner tournaments", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err + } + + seen := make(map[common.Address]*Tournament, len(innerTournaments)+len(events.NewInnerTournament)) + for _, i := range innerTournaments { + if i.ParentMatchIDHash == nil || i.ParentTournamentAddress == nil || *i.ParentTournamentAddress != tournamentAddress { + return nil, fmt.Errorf("child tournament %s has an invalid parent link", i.Address) + } + s.Logger.Debug("Fetching data for previous open tournament", "level", nextLevel, + "parent_match_id_hash", i.ParentMatchIDHash.String(), + "parent_tournament_address", i.ParentTournamentAddress.String(), + "address", i.Address.String()) + + childBatches, err := s.gatherTournamentData(ctx, app, epoch, nextLevel, i.ParentMatchIDHash, + &tournamentAddress, i.Address, levelCount, mostRecentBlock) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament data", err, + "level", nextLevel, "application", app.Name, + "tournament", i.Address.String()) + return nil, err + } + batches = append(batches, childBatches...) + storedChild := *i + seen[i.Address] = &storedChild + for _, childBatch := range childBatches { + if childBatch.Tournament.Address == i.Address { + seen[i.Address] = childBatch.Tournament + break + } + } + } + + for _, newInner := range events.NewInnerTournament { + hashID := (common.Hash)(newInner.MatchIdHash) + childAddress := newInner.ChildTournament + if child := seen[childAddress]; child != nil { + // A retired child already has this immutable creation tuple. Do + // not modify its repository-owned projection during validation. + projection := *child + if err := applyTournamentCreationEvent(&projection, newInner); err != nil { + return nil, err + } + child.CreationEvent = projection.CreationEvent + continue + } + + s.Logger.Info("NewInnerTournament event", "id_hash", hashID.String(), "tournament_address", childAddress.String()) + + childBatches, err := s.gatherTournamentData(ctx, app, epoch, nextLevel, &hashID, + &tournamentAddress, childAddress, levelCount, mostRecentBlock) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament data", err, + "level", nextLevel, "application", app.Name, + "tournament", childAddress.String()) + return nil, err + } + if len(childBatches) == 0 || childBatches[0].Tournament.Address != childAddress { + return nil, fmt.Errorf("new child tournament %s has no initial observation", childAddress) + } + if err := applyTournamentCreationEvent(childBatches[0].Tournament, newInner); err != nil { + return nil, err + } + batches = append(batches, childBatches...) + seen[childAddress] = childBatches[0].Tournament + } + + return batches, nil +} + +func applyTournamentCreationEvent(child *Tournament, event *itournament.ITournamentNewInnerTournament) error { + if child.Address != event.ChildTournament || child.ParentMatchIDHash == nil || *child.ParentMatchIDHash != event.MatchIdHash || + child.StartInstant != event.Raw.BlockNumber { + return fmt.Errorf("child tournament %s descriptor does not match its creation event", child.Address) + } + creation := &TournamentCreationEvent{ + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + } + if child.CreationEvent != nil && *child.CreationEvent != *creation { + return fmt.Errorf("child tournament %s has conflicting creation events", child.Address) + } + child.CreationEvent = creation + return nil +} + func (s *Service) reactToTournament(ctx context.Context, app *Application, mostRecentBlock uint64) error { currentEpochIndex, exist := s.currentEpochIndex[app.ID] if !exist { diff --git a/internal/prt/warnings.go b/internal/prt/warnings.go new file mode 100644 index 000000000..0afb70414 --- /dev/null +++ b/internal/prt/warnings.go @@ -0,0 +1,31 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +func (s *Service) warnUnsupportedDispute(app *Application, epochIndex uint64, tournament common.Address) { + if _, warned := s.disputeWarnings[tournament]; warned { + return + } + s.disputeWarnings[tournament] = struct{}{} + s.Logger.Warn("This node release sends no dispute moves; the commitment can lose by timeout", + "application", app.Name, "epoch_index", epochIndex, "tournament", tournament) +} + +func (s *Service) warnZeroStagingPeriod(app *Application) { + if app.ClaimStagingPeriod != 0 { + return + } + if _, warned := s.zeroStagingWarnings[app.ID]; warned { + return + } + s.zeroStagingWarnings[app.ID] = struct{}{} + s.Logger.Warn("Application has no claim staging delay; without sentries, a staged result can be accepted immediately", + "application", app.Name, "address", app.IApplicationAddress, + "claim_staging_period", app.ClaimStagingPeriod) +} diff --git a/internal/prt/warnings_test.go b/internal/prt/warnings_test.go new file mode 100644 index 000000000..d221ae3f9 --- /dev/null +++ b/internal/prt/warnings_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "log/slog" + "math/big" + "strings" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentObservationWarnsOnlyForActiveDisputes(t *testing.T) { + for _, test := range []struct { + name string + state TournamentStandingState + warn bool + }{ + {name: "active matches", state: TournamentStandingMatchesActive, warn: true}, + {name: "uncontested wait", state: TournamentStandingAwaitingClosure}, + } { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(test.name+"/"+operation, func(t *testing.T) { + s, repo := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := prtRevertTestApp() + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + tournament := &Tournament{Address: *epoch.TournamentAddress, MaxLevel: 2, Height: 48, Kind: TournamentKindNonLeaf} + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Height: 48, Kind: TournamentKindNonLeaf, + }, nil).Twice() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{State: test.state}, nil).Twice() + for range 2 { + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, test.state) + if update { + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, tournament, 20)) + } else { + _, err := s.readTournament(t.Context(), app, epoch, RootLevel, nil, nil, tournament.Address, adapter, 2, 20) + require.NoError(t, err) + } + } + wantWarnings := 0 + if test.warn { + wantWarnings = 1 + require.Contains(t, output.String(), "sends no dispute moves") + require.Contains(t, output.String(), "commitment can lose by timeout") + require.Contains(t, output.String(), "application="+app.Name) + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), tournament.Address.Hex()) + } + require.Equal(t, wantWarnings, strings.Count(output.String(), "level=WARN")) + require.Equal(t, ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } + } +} + +func TestStoredInnerTournamentWarningsFollowCurrentStanding(t *testing.T) { + for _, test := range []struct { + name string + state TournamentStandingState + finish uint64 + wantWarnings int + }{ + {name: "active after restart", state: TournamentStandingMatchesActive, wantWarnings: 2}, + {name: "finished history", state: TournamentStandingInnerEliminableWinnerExpired, finish: 10}, + } { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + inner := &Tournament{Address: *epoch.TournamentAddress, Level: 1, MaxLevel: 2, Kind: TournamentKindLeaf} + var output bytes.Buffer + for range 2 { + s, repo := newPRTServiceMock() + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + adapter := &tournamentAdapterMock{} + for range 2 { + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: 1, Kind: TournamentKindLeaf, + }, nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{ + State: test.state, HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: test.finish, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, 1, test.state) + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, 1, adapter, inner, 20)) + } + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + } + require.Equal(t, test.wantWarnings, strings.Count(output.String(), "sends no dispute moves")) + require.Equal(t, ApplicationStatus_OK, app.Status) + }) + } +} From 82c3c6859d651b50bfdabbeaffc46e68d2dde8be Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:19 -0300 Subject: [PATCH 34/41] feat(prt): publish complete dispute windows Observe tournaments for every enabled PRT application, independently of local claim readiness or local machine health. Bound root discovery by the completed sealed-epoch scan and the configured observation block. After foreclosure, continue observing the already-discovered tournaments. Gather all root and child batches before calling the atomic repository publisher. Advance the in-memory cursor only after that call succeeds. A failed window must not expose partial dispute data or a new cursor. Report repeated observation failures through readiness as configured heads advance. Do not turn observation failure into local claim corruption. Include checkpoint, passive-observation, and health tests. This operation does not reconcile claims or submit transactions. --- internal/prt/observation_health.go | 89 +++++ internal/prt/observation_health_test.go | 298 +++++++++++++++ internal/prt/observer_checkpoint_test.go | 445 +++++++++++++++++++++++ internal/prt/passive_observer_test.go | 115 ++++++ internal/prt/prt.go | 119 +++++- 5 files changed, 1057 insertions(+), 9 deletions(-) create mode 100644 internal/prt/observation_health.go create mode 100644 internal/prt/observation_health_test.go create mode 100644 internal/prt/observer_checkpoint_test.go create mode 100644 internal/prt/passive_observer_test.go diff --git a/internal/prt/observation_health.go b/internal/prt/observation_health.go new file mode 100644 index 000000000..d6d62953e --- /dev/null +++ b/internal/prt/observation_health.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/model" +) + +// This is an observation-health threshold, not a transaction retry budget. +// Only failed atomic event windows at increasing configured heads count. +const tournamentObservationFailureThreshold = 5 + +type tournamentObservationFailure struct { + fromBlock uint64 + lastHead uint64 + failedHeads uint8 +} + +func (s *Service) Ready() bool { + s.observationHealthMu.RLock() + defer s.observationHealthMu.RUnlock() + for _, failure := range s.observationFailures { + if failure.failedHeads >= tournamentObservationFailureThreshold { + return false + } + } + return true +} + +func (s *Service) recordTournamentObservationFailure( + ctx context.Context, app *model.Application, head, windowEnd uint64, err error, +) { + if errors.Is(ctx.Err(), context.Canceled) && errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + return + } + + s.observationHealthMu.Lock() + failure, exists := s.observationFailures[app.ID] + if exists && head <= failure.lastHead { + s.observationHealthMu.Unlock() + return + } + if !exists { + // The caller starts an observation only when its end exceeds this + // cursor, so the addition cannot overflow. + failure.fromBlock = app.LastTournamentCheckBlock + 1 + } + failure.lastHead = head + reachedThreshold := failure.failedHeads == tournamentObservationFailureThreshold-1 + if failure.failedHeads < tournamentObservationFailureThreshold { + failure.failedHeads++ + } + s.observationFailures[app.ID] = failure + s.observationHealthMu.Unlock() + + if reachedThreshold { + s.Logger.Error("PRT tournament observation is stalled; readiness is degraded; observation will retry", + "application", app.Name, "address", app.IApplicationAddress, + "operation", "tournament_event_window", "from_block", failure.fromBlock, + "through_block", windowEnd, "configured_head", head, "failed_heads", failure.failedHeads, + "error", err) + } +} + +// Only successful atomic publication or loss of application eligibility clears +// a failure. A normal wait does not prove that the failed window can be read. +func (s *Service) clearTournamentObservationFailure(appID int64) { + s.observationHealthMu.Lock() + defer s.observationHealthMu.Unlock() + delete(s.observationFailures, appID) +} + +func (s *Service) pruneTournamentObservationFailures(apps []*model.Application) { + eligible := make(map[int64]struct{}, len(apps)) + for _, app := range apps { + eligible[app.ID] = struct{}{} + } + s.observationHealthMu.Lock() + defer s.observationHealthMu.Unlock() + for appID := range s.observationFailures { + if _, exists := eligible[appID]; !exists { + delete(s.observationFailures, appID) + } + } +} diff --git a/internal/prt/observation_health_test.go b/internal/prt/observation_health_test.go new file mode 100644 index 000000000..434120ac0 --- /dev/null +++ b/internal/prt/observation_health_test.go @@ -0,0 +1,298 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "sync" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func failTournamentObservation(ctx context.Context, f *observerCheckpointFixture, epoch *model.Epoch, head uint64, cause error) { + f.t.Helper() + f.epochs(epoch) + windowEnd := min(head, f.app.LastEpochCheckBlock) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(windowEnd))). + Return(uint64(0), cause).Once() + deferActions, err := f.s.checkEpochs(ctx, f.app, head) + require.ErrorIs(f.t, err, cause) + require.True(f.t, deferActions) + require.Equal(f.t, uint64(50), f.app.LastTournamentCheckBlock) + require.Equal(f.t, model.ApplicationStatus_OK, f.app.Status) +} + +func TestTournamentObservationHealthCountsIncreasingHeads(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + for i, test := range []struct { + head uint64 + count uint8 + }{ + {100, 1}, {100, 1}, {99, 1}, {101, 2}, {102, 3}, {103, 4}, {104, 5}, {105, 5}, + } { + // Neither a new error message nor a changing head resets the failure. + failTournamentObservation(t.Context(), f, epoch, test.head, fmt.Errorf("provider response %d at head %d", i, test.head)) + require.Equal(t, test.count, f.s.observationFailures[f.app.ID].failedHeads) + require.Equal(t, test.count < tournamentObservationFailureThreshold, f.s.Ready()) + } + require.Equal(t, uint64(51), f.s.observationFailures[f.app.ID].fromBlock) + require.Equal(t, 1, strings.Count(output.String(), "readiness is degraded")) + require.Contains(t, output.String(), "application="+f.app.Name) + require.Contains(t, output.String(), "operation=tournament_event_window") + require.Contains(t, output.String(), "from_block=51") + require.Contains(t, output.String(), "configured_head=104") + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentObservationHealthClearsOnlyAfterPublication(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + for head := uint64(100); head < 105; head++ { + failTournamentObservation(t.Context(), f, epoch, head, errors.New("observation unavailable")) + } + require.False(t, f.s.Ready()) + + // A head with no new observation window does not prove recovery. + f.epochs() + _, err := f.s.checkEpochs(t.Context(), f.app, 105) + require.NoError(t, err) + require.False(t, f.s.Ready()) + require.Equal(t, uint8(5), f.s.observationFailures[f.app.ID].failedHeads) + + // An unrelated query failure and an empty result cannot clear it either. + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), errors.New("epoch query unavailable")).Once() + _, err = f.s.checkEpochs(t.Context(), f.app, 106) + require.ErrorContains(t, err, "epoch query unavailable") + f.epochs() + _, err = f.s.checkEpochs(t.Context(), f.app, 107) + require.NoError(t, err) + require.False(t, f.s.Ready()) + + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + f.unaccepted(epoch, 90) + _, err = f.s.checkEpochs(t.Context(), f.app, 108) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.True(t, f.s.Ready()) + require.Empty(t, f.s.observationFailures) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentObservationHealthIncludesFailedPublication(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + cause := errors.New("no match found for update") + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(cause).Once() + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.ErrorIs(t, err, cause) + require.Equal(t, uint8(1), f.s.observationFailures[f.app.ID].failedHeads) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) +} + +func TestTournamentObservationHealthIgnoresAcceptanceFailure(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Once() + tournament := f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + cause := errors.New("acceptance write unavailable") + f.acceptance(epoch, tournament, cause) + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.ErrorIs(t, err, cause) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Empty(t, f.s.observationFailures) + require.True(t, f.s.Ready()) +} + +func TestTournamentObservationHealthKeepsHealthyApplicationsRunning(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = true + epoch := checkpointEpoch(0, "0x100") + healthy := prtRevertTestApp() + healthy.ID++ + healthy.Name = "healthy-app" + healthy.IApplicationAddress = common.HexToAddress("0x999") + healthy.IConsensusAddress = common.HexToAddress("0x998") + healthy.LastEpochCheckBlock = 200 + unprepared := &model.Epoch{Status: model.EpochStatus_Closed, LastBlock: 201} + f.factory.On("CreateDaveConsensusAdapter", healthy.IConsensusAddress).Return(&daveConsensusAdapterMock{}, nil) + f.s.pendingTransactions[healthy.ID] = pendingTournamentTransaction{Hash: common.HexToHash("0x777")} + f.client.On("TransactionByHash", mock.Anything, common.HexToHash("0x777")).Return((*types.Transaction)(nil), true, nil) + for head := uint64(100); head < 106; head++ { + f.repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{f.app, healthy}, uint64(2), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil).Once() + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(0), errors.New("invalid descriptor")).Once() + f.repo.On("ListEpochs", mock.Anything, healthy.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{unprepared}, uint64(1), nil).Once() + f.repo.On("StoreTournamentEvents", mock.Anything, healthy.ID, mock.Anything, head).Return(nil).Once() + f.client.On("BlockNumber", mock.Anything).Return(head, nil).Once() + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.ErrorContains(t, err, f.app.IApplicationAddress.Hex()) + require.Equal(t, head, healthy.LastTournamentCheckBlock) + } + require.False(t, f.s.Ready()) + require.Len(t, f.s.observationFailures, 1) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.ApplicationStatus_OK, healthy.Status) +} + +func TestTournamentObservationHealthIgnoresPendingAndMissingTransactions(t *testing.T) { + for _, pending := range []bool{true, false} { + t.Run(fmt.Sprintf("pending=%t", pending), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = true + f.app.LastEpochCheckBlock = 200 + epoch := &model.Epoch{Status: model.EpochStatus_Closed, LastBlock: 201} + hash := common.HexToHash("0x777") + f.s.pendingTransactions[f.app.ID] = pendingTournamentTransaction{Action: tournamentActionJoin, Hash: hash} + var lookupError error + if !pending { + lookupError = ethereum.NotFound + } + for head := uint64(100); head < 106; head++ { + f.repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil).Once() + f.epochs(epoch) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, head).Return(nil).Once() + f.client.On("BlockNumber", mock.Anything).Return(head, nil).Once() + f.client.On("TransactionByHash", mock.Anything, hash). + Return((*types.Transaction)(nil), pending, lookupError).Once() + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + if pending { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, ethereum.NotFound) + } + require.True(t, f.s.Ready()) + require.Empty(t, f.s.observationFailures) + } + require.Contains(t, f.s.pendingTransactions, f.app.ID) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + }) + } +} + +func TestTournamentObservationHealthPrunesIneligibleApplications(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + for head := uint64(100); head < 105; head++ { + failTournamentObservation(t.Context(), f, epoch, head, errors.New("observation unavailable")) + } + f.repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), errors.New("application list unavailable")).Once() + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.ErrorContains(t, err, "application list unavailable") + require.False(t, f.s.Ready(), "a failed eligibility query cannot clear a failure") + + // Only disabled or removed PRT applications lose observer eligibility. + f.repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(filter repository.ApplicationFilter) bool { + return filter.Enabled != nil && *filter.Enabled && + len(filter.Statuses) == 0 && + filter.ConsensusType != nil && *filter.ConsensusType == model.Consensus_PRT + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + reschedule, err = f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.True(t, f.s.Ready()) + require.Empty(t, f.s.observationFailures) +} + +func TestTournamentObservationHealthIgnoresShutdownCancellation(t *testing.T) { + for _, test := range []struct { + name string + stopping bool + cause error + counted bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {"shutdown joined deadline", true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + failTournamentObservation(ctx, f, checkpointEpoch(0, "0x100"), 100, test.cause) + require.Equal(t, test.counted, len(f.s.observationFailures) == 1) + }) + } +} + +func TestTournamentObservationReadinessConcurrentWithUpdates(t *testing.T) { + s, _ := newPRTServiceMock() + app := prtRevertTestApp() + cause := errors.New("observation unavailable") + var readers sync.WaitGroup + readers.Go(func() { + for range 1000 { + s.Ready() + } + }) + for head := uint64(1); head <= 1000; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, cause) + s.clearTournamentObservationFailure(app.ID) + } + readers.Wait() + require.True(t, s.Ready()) +} + +func TestTournamentObservationHealthIncludesLocalFailureStates(t *testing.T) { + for _, status := range []model.ApplicationStatus{ + model.ApplicationStatus_OK, model.ApplicationStatus_Failed, + model.ApplicationStatus_Corrupted, model.ApplicationStatus_Diverged, + } { + t.Run(status.String(), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.Status = status + for head := uint64(1); head <= tournamentObservationFailureThreshold; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, errors.New("window unavailable")) + } + s.pruneTournamentObservationFailures([]*model.Application{app}) + require.False(t, s.Ready()) + require.Equal(t, status, app.Status) + require.Empty(t, repo.Calls) + }) + } +} diff --git a/internal/prt/observer_checkpoint_test.go b/internal/prt/observer_checkpoint_test.go new file mode 100644 index 000000000..de01cc2c0 --- /dev/null +++ b/internal/prt/observer_checkpoint_test.go @@ -0,0 +1,445 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "strings" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type observerCheckpointFixture struct { + t *testing.T + s *Service + repo *prtRepositoryMock + factory *adapterFactoryMock + consensus *daveConsensusAdapterMock + client *ethClientMock + app *Application +} + +func newObserverCheckpointFixture(t *testing.T) *observerCheckpointFixture { + t.Helper() + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, factory: &adapterFactoryMock{}, + consensus: &daveConsensusAdapterMock{}, client: ðClientMock{}, app: prtRevertTestApp()} + f.app.LastTournamentCheckBlock = 50 + f.app.LastEpochCheckBlock = 100 + s.adapterFactory, s.client = f.factory, f.client + f.factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Return(f.consensus, nil) + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.client.AssertExpectations(t) + }) + return f +} + +func (f *observerCheckpointFixture) epochs(epochs ...*Epoch) { + f.t.Helper() + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return(epochs, uint64(len(epochs)), nil).Once() +} + +func checkpointEpoch(index uint64, address string) *Epoch { + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + epoch.Index = index + epoch.LastBlock = 10 + epoch.TournamentAddress = new(common.HexToAddress(address)) + return epoch +} + +func (f *observerCheckpointFixture) tournament( + epoch *Epoch, address common.Address, level, levels, finish, scanEnd uint64, events *TournamentEvents, scanError error, +) *Tournament { + f.t.Helper() + tournament := &Tournament{ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: address, + Level: level, MaxLevel: levels, StartInstant: epoch.LastBlock, Snapshot: TournamentSnapshot{FinishedAtBlock: finish}} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(tournament, nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + kind := TournamentKindNonLeaf + if level+1 == levels { + kind = TournamentKindLeaf + } + tournament.Kind = kind + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(scanEnd))). + Return(TournamentDescriptor{BaseCycle: big.NewInt(0), Level: level, Kind: kind, StartInstant: epoch.LastBlock}, nil).Once() + candidate := common.HexToHash("0x777") + if epoch.Commitment != nil { + candidate = *epoch.Commitment + } + state := TournamentStandingRootWinner + if level != uint64(RootLevel) { + state = TournamentStandingInnerWinner + } + finishedAt := finish + if finishedAt == 0 { + finishedAt = scanEnd + } + adapter.On("Standing", mock.MatchedBy(resultCallOptsAtBlock(scanEnd))).Return(TournamentStanding{ + State: state, HasCandidate: true, Candidate: candidate, FinishedAt: finishedAt, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.MatchedBy(resultCallOptsAtBlock(scanEnd)), TournamentLevel(level), state) + end := scanEnd + start := max(epoch.LastBlock, f.app.LastTournamentCheckBlock+1) + if start <= end { + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts != nil && opts.Context != nil && opts.Start == start && opts.End != nil && *opts.End == end + })).Return(events, scanError).Once() + } + if scanError == nil { + if start > tournament.StartInstant { + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(start-1))). + Return(zeroStructuralEventCounts(), nil).Once() + adapter.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(start-1))). + Return(canonicalBondRecovery(BondDispositionTournamentRunning, common.Address{}, 0), nil).Once() + } + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(end))). + Return(structuralCountsForEvents(events), nil).Once() + f.emptyParticipants(epoch, address) + } + f.t.Cleanup(func() { adapter.AssertExpectations(f.t) }) + return tournament +} + +func expectTournamentAuxiliaryReads(adapter *tournamentAdapterMock, opts any, level TournamentLevel, state TournamentStandingState) { + disposition := BondDispositionTournamentRunning + if isTerminalTournamentStanding(state) { + disposition = BondDispositionRecoverable + if state == TournamentStandingRootFailed || state == TournamentStandingInnerEliminableNoWinner { + disposition = BondDispositionNoWinner + } + } + if level != RootLevel { + result := InnerResult{Disposition: InnerTournamentUnsettled} + switch state { + case TournamentStandingInnerWinner: + result = InnerResult{Disposition: InnerTournamentWinner, PausedAllowance: 1} + case TournamentStandingInnerEliminableWinnerExpired, TournamentStandingInnerEliminableNoWinner: + result.Disposition = InnerTournamentEliminable + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, TournamentStandingRootFailed: + } + adapter.On("InnerResult", opts).Return(result, nil).Once() + } + adapter.On("BondRecovery", opts).Return(canonicalBondRecovery(disposition, common.HexToAddress("0x777"), 0), nil).Once() +} + +func (f *observerCheckpointFixture) emptyParticipants(epoch *Epoch, address common.Address) { + f.t.Helper() + filterAddress := address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*Commitment{}, uint64(0), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*Match{}, uint64(0), nil).Once() +} + +func (f *observerCheckpointFixture) children(epoch *Epoch, parent *Tournament, children ...*Tournament) { + f.t.Helper() + for _, child := range children { + child.ParentTournamentAddress = &parent.Address + child.ParentMatchIDHash = new(common.HexToHash("0xabc")) + } + f.repo.On("ListTournaments", mock.Anything, f.app.Name, repository.TournamentFilter{ + EpochIndex: &epoch.Index, ParentTournamentAddress: &parent.Address, Level: new(parent.Level + 1), + }, repository.Pagination{}, false).Return(children, uint64(len(children)), nil).Once() +} + +func (f *observerCheckpointFixture) acceptance(epoch *Epoch, tournament *Tournament, writeError error) { + f.t.Helper() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Once() + log := types.Log{Address: f.app.IConsensusAddress, TxHash: *epoch.ClaimTransactionHash, BlockNumber: 100} + receipt := &types.Receipt{TxHash: *epoch.ClaimTransactionHash, BlockNumber: big.NewInt(100), + Status: types.ReceiptStatusSuccessful, Logs: []*types.Log{&log}} + f.client.On("TransactionReceipt", mock.Anything, *epoch.ClaimTransactionHash).Return(receipt, nil).Once() + f.consensus.On("ParseEpochSealed", log).Return(&idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: new(big.Int).SetUint64(epoch.Index + 1), InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, Raw: log, + }, nil).Once() + f.repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, f.app.ID, epoch.Index, epoch.ClaimTransactionHash). + Run(func(mock.Arguments) { require.Equal(f.t, uint64(100), f.app.LastTournamentCheckBlock) }).Return(writeError).Once() +} + +func (f *observerCheckpointFixture) unaccepted(epoch *Epoch, finish uint64) { + f.t.Helper() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return( + &Tournament{Address: *epoch.TournamentAddress, Snapshot: TournamentSnapshot{ + FinishedAtBlock: finish, WinnerCommitment: epoch.Commitment, + }}, nil).Once() +} + +func TestObserverCheckpointCommitsAllRootsAndChildren(t *testing.T) { + f := newObserverCheckpointFixture(t) + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + first.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(first, second) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + childAddress := common.HexToAddress("0x101") + childEpoch := *first + childEpoch.LastBlock = 60 // The child is created inside the parent scan window, 51..100. + parentEvents := &TournamentEvents{NewInnerTournament: []*itournament.ITournamentNewInnerTournament{{ + ChildTournament: childAddress, MatchIdHash: common.HexToHash("0xabc"), + Raw: types.Log{BlockNumber: childEpoch.LastBlock}, + }}, PartialBondRefund: []*itournament.ITournamentPartialBondRefund{{Value: new(big.Int)}}} + parent := f.tournament(first, *first.TournamentAddress, 0, 2, 90, 100, parentEvents, nil) + child := f.tournament(&childEpoch, childAddress, 1, 2, 88, 100, &TournamentEvents{}, nil) + f.children(first, parent, child) + secondRoot := f.tournament(second, *second.TournamentAddress, 0, 2, 95, 100, &TournamentEvents{}, nil) + lastChild := f.tournament(second, common.HexToAddress("0x201"), 1, 2, 89, 100, &TournamentEvents{}, nil) + f.children(second, secondRoot, lastChild) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + if len(batches) != 4 { + return false + } + for i, expected := range []*Tournament{parent, child, secondRoot, lastChild} { + if batches[i].Tournament.Address != expected.Address { + return false + } + } + return true + }), uint64(100)).Return(nil).Once() + f.acceptance(first, parent, nil) + f.unaccepted(second, 95) + deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) +} + +func TestObserverCheckpointKeepsFinishedSubtreesCurrent(t *testing.T) { + for _, finish := range []uint64{0, 49, 50, 51} { + t.Run(new(big.Int).SetUint64(finish).String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := f.tournament(epoch, *epoch.TournamentAddress, 0, 2, finish, 100, &TournamentEvents{}, nil) + child := f.tournament(epoch, common.HexToAddress("0x101"), 1, 2, finish, 100, &TournamentEvents{}, nil) + f.children(epoch, parent, child) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 2 + }), uint64(100)).Return(nil).Once() + f.unaccepted(epoch, finish) + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + finishedLogs := strings.Count(output.String(), "Found finished tournament") + if finish == 0 { + require.Equal(t, 2, finishedLogs, "each newly finished clone reports its transition") + } else { + require.Zero(t, finishedLogs, "a current refresh must not repeat the finish announcement") + } + }) + } +} + +func TestGatherTournamentDataKeepsNewFinishedProjectionBelowCursor(t *testing.T) { + // This checks the gather helper's contract. Normal atomic publication does + // not advance a cursor without the corresponding tournament projection. + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 50 + epoch := checkpointEpoch(0, "0x100") + address := *epoch.TournamentAddress + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), address.Hex()). + Return((*Tournament)(nil), nil).Once() + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: TournamentKindLeaf, StartInstant: epoch.LastBlock, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: TournamentStandingRootWinner, HasCandidate: true, Candidate: *epoch.Commitment, + FinalState: *epoch.MachineHash, FinishedAt: 49, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, TournamentStandingRootWinner) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == epoch.LastBlock && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, app: app} + f.emptyParticipants(epoch, address) + factory := &adapterFactoryMock{} + factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + s.adapterFactory = factory + + batches, err := s.gatherTournamentData(t.Context(), app, epoch, RootLevel, nil, nil, address, 1, 100) + require.NoError(t, err) + require.Len(t, batches, 1, "a new projection must reach the caller even when no event scan is needed") + require.Equal(t, address, batches[0].Tournament.Address) + require.Equal(t, uint64(49), batches[0].Tournament.Snapshot.FinishedAtBlock) + require.Equal(t, epoch.Commitment, batches[0].Tournament.Snapshot.WinnerCommitment) + require.Equal(t, epoch.MachineHash, batches[0].Tournament.Snapshot.FinalStateHash) + require.Equal(t, uint64(50), app.LastTournamentCheckBlock) + require.Len(t, repo.Calls, 3, "gather reads projections but must not publish them or their cursor") + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + factory.AssertExpectations(t) +} + +func TestObserverCheckpointRetriesWholeWindowAfterFailure(t *testing.T) { + const transactionFailure = "transaction" + for _, fault := range []string{"child RPC", "last root RPC", transactionFailure} { + t.Run(fault, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + failure := errors.New(fault) + for attempt := range 2 { + f.epochs(first, second) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := f.tournament(first, *first.TournamentAddress, 0, 2, 0, 100, &TournamentEvents{}, nil) + var childError, rootError, writeError error + if attempt == 0 { + switch fault { + case "child RPC": + childError = failure + case "last root RPC": + rootError = failure + case transactionFailure: + writeError = failure + } + } + child := f.tournament(first, common.HexToAddress("0x101"), 1, 2, 90, 100, &TournamentEvents{}, childError) + f.children(first, parent, child) + if childError == nil { + lastRoot := f.tournament(second, *second.TournamentAddress, 0, 2, 95, 100, &TournamentEvents{}, rootError) + if rootError == nil { + f.children(second, lastRoot) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)). + Return(writeError).Once() + } + } + if attempt == 1 { + f.unaccepted(first, 100) + } + deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.Zero(t, parent.Snapshot.FinishedAtBlock, "gather must not mutate repository-owned projections") + if attempt == 0 { + require.ErrorIs(t, err, failure) + require.True(t, deferActions) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + if fault != transactionFailure { + f.repo.AssertNotCalled(t, "StoreTournamentEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + } else { + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + } + } + }) + } +} + +func TestObserverCheckpointObservesUnpreparedRootsWithinIngestionFloor(t *testing.T) { + for _, status := range []EpochStatus{EpochStatus_Closed, EpochStatus_InputsProcessed} { + t.Run(status.String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + ready, unprepared := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + unprepared.Status, unprepared.LastBlock = status, 80 + unprepared.Commitment, unprepared.MachineHash, unprepared.TxBufferDataBlock = nil, nil, nil + f.epochs(ready, unprepared) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(ready, *ready.TournamentAddress, 0, 1, 0, 100, &TournamentEvents{}, nil) + f.tournament(unprepared, *unprepared.TournamentAddress, 0, 1, 0, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + f.unaccepted(ready, 100) + _, err := f.s.checkEpochs(t.Context(), f.app, 120) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }) + } + t.Run("ingestion head limits prepared roots", func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 0, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + f.unaccepted(epoch, 100) + _, err := f.s.checkEpochs(t.Context(), f.app, 120) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }) +} + +func TestObserverCheckpointRetriesAcceptanceWithoutNewWindow(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + tournament := f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + failure := errors.New("acceptance write failed") + f.acceptance(epoch, tournament, failure) + deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.ErrorIs(t, err, failure) + require.True(t, deferActions) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.epochs(epoch) + f.acceptance(epoch, tournament, nil) + deferActions, err = f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.False(t, deferActions) + f.repo.AssertNumberOfCalls(t, "StoreTournamentEvents", 1) + f.consensus.AssertNumberOfCalls(t, "TournamentLevelCount", 1) +} + +func TestObserverCheckpointIncludesUnpreparedCreationBlock(t *testing.T) { + for _, creation := range []uint64{0, 80} { + t.Run(new(big.Int).SetUint64(creation).String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastTournamentCheckBlock = 0 + ready, unprepared := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + ready.LastBlock = creation + unprepared.Status, unprepared.LastBlock = EpochStatus_Closed, creation + unprepared.Commitment, unprepared.MachineHash, unprepared.TxBufferDataBlock = nil, nil, nil + f.epochs(ready, unprepared) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(ready, *ready.TournamentAddress, 0, 1, 100, 100, &TournamentEvents{}, nil) + f.tournament(unprepared, *unprepared.TournamentAddress, 0, 1, 100, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { return len(batches) == 2 }), uint64(100)). + Return(nil).Once() + f.unaccepted(ready, 100) + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }) + } +} + +func TestObserverCheckpointDoesNotMoveBackward(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastTournamentCheckBlock = 100 + f.epochs(checkpointEpoch(0, "0x100")) + _, err := f.s.checkEpochs(t.Context(), f.app, 90) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.repo.AssertNotCalled(t, "StoreTournamentEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "TournamentLevelCount", mock.Anything) +} diff --git a/internal/prt/passive_observer_test.go b/internal/prt/passive_observer_test.go new file mode 100644 index 000000000..3420329cf --- /dev/null +++ b/internal/prt/passive_observer_test.go @@ -0,0 +1,115 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestPassiveObservationIncludesLocalFailuresAndTerminalEpochs(t *testing.T) { + for _, status := range []ApplicationStatus{ + ApplicationStatus_OK, ApplicationStatus_Failed, ApplicationStatus_Corrupted, ApplicationStatus_Diverged, + } { + for _, epochStatus := range []EpochStatus{ + EpochStatus_Closed, EpochStatus_InputsProcessed, EpochStatus_ClaimAccepted, EpochStatus_ClaimForeclosed, + } { + t.Run(status.String()+"/"+epochStatus.String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = DefaultBlock_Finalized + f.app.Status = status + // These applications must remain passive even with signing enabled. + f.s.submissionEnabled = status != ApplicationStatus_OK + epoch := checkpointEpoch(0, "0x100") + snapshot := resultTestSnapshot(epoch, false) + epoch.Status = epochStatus + epoch.Commitment, epoch.MachineHash, epoch.TxBufferDataBlock = nil, nil, nil + f.repo.On("ListApplications", mock.Anything, repository.ApplicationFilter{ + Enabled: new(true), ConsensusType: new(Consensus_PRT), + }, repository.Pagination{}, false).Return([]*Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + if status == ApplicationStatus_OK { + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() + } + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, status, f.app.Status) + require.Empty(t, f.s.pendingTransactions) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } + } +} + +func TestLosingRootPublishesBeforeLocalDivergence(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + foreign := common.HexToHash("0xbad") + address := *epoch.TournamentAddress + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return((*Tournament)(nil), nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: TournamentKindLeaf, StartInstant: 10, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: TournamentStandingRootWinner, HasCandidate: true, Candidate: foreign, FinishedAt: 90, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, TournamentStandingRootWinner) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + // A newly found clone includes its full history, even below the app cursor. + return opts.Start == 10 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, address) + var published *Tournament + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(t, batches, 1) + published = batches[0].Tournament + require.Equal(t, foreign, *published.Snapshot.WinnerCommitment) + require.Equal(t, ApplicationStatus_OK, f.app.Status) + }).Return(nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()). + Return(&Tournament{Snapshot: TournamentSnapshot{FinishedAtBlock: 90, WinnerCommitment: &foreign}}, nil).Once() + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, ApplicationStatus_Diverged, mock.Anything). + Run(func(mock.Arguments) { + require.NotNil(t, published) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }).Return(nil).Once() + deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.ErrorContains(t, err, "inconsistent commitment") + require.True(t, deferActions) + require.Equal(t, ApplicationStatus_Diverged, f.app.Status) + require.Empty(t, f.s.pendingTransactions) + adapter.AssertExpectations(t) +} diff --git a/internal/prt/prt.go b/internal/prt/prt.go index d76c0957c..d012aad54 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -85,23 +85,45 @@ func (f *DefaultAdapterFactory) CreateDaveConsensusAdapter(addr common.Address) return NewDaveConsensusAdapter(addr, f.client) } -func getAllRunningApplications(ctx context.Context, r prtRepository) ([]*Application, uint64, error) { - return r.ListApplications(ctx, prtTickApplicationsFilter(), repository.Pagination{}, false) -} - -func prtTickApplicationsFilter() repository.ApplicationFilter { - return repository.ApplicationFilter{ +func getObservableApplications(ctx context.Context, r prtRepository) ([]*Application, uint64, error) { + filter := repository.ApplicationFilter{ Enabled: new(true), - Statuses: []ApplicationStatus{ApplicationStatus_OK}, ConsensusType: new(Consensus_PRT), } + return r.ListApplications(ctx, filter, repository.Pagination{}, false) } -func getAllClaimComputedEpochs(ctx context.Context, r prtRepository, nameOrAddress string) ([]*Epoch, uint64, error) { - f := repository.EpochFilter{Status: []EpochStatus{EpochStatus_ClaimComputed}} +func getTournamentObservationEpochs(ctx context.Context, r prtRepository, nameOrAddress string) ([]*Epoch, uint64, error) { + f := repository.EpochFilter{HasTournament: new(true)} return r.ListEpochs(ctx, nameOrAddress, f, repository.Pagination{}, false) } +// getDefaultBlockNumber selects the block used for stored chain state. Live +// transaction checks use a separate latest block and cannot advance this view. +func (s *Service) getDefaultBlockNumber(ctx context.Context) (uint64, error) { + var tag rpc.BlockNumber + switch s.defaultBlock { + case DefaultBlock_Pending: + tag = rpc.PendingBlockNumber + case DefaultBlock_Latest: + tag = rpc.LatestBlockNumber + case DefaultBlock_Finalized: + tag = rpc.FinalizedBlockNumber + case DefaultBlock_Safe: + tag = rpc.SafeBlockNumber + default: + return 0, fmt.Errorf("default block %v not supported", s.defaultBlock) + } + header, err := s.client.HeaderByNumber(ctx, big.NewInt(tag.Int64())) + if err != nil { + return 0, fmt.Errorf("fetching %s block header: %w", tag, err) + } + if header == nil { + return 0, fmt.Errorf("returned %s block header is nil", tag) + } + return checkedUint64(header.Number, "configured block number") +} + func getAllSubTournaments( ctx context.Context, r prtRepository, @@ -710,6 +732,85 @@ func (s *Service) handleSettleRevert(ctx context.Context, app *Application, epoc return err } +// observeApplicationTournaments indexes chain facts without consulting local +// claim readiness or changing application health. The returned roots are also +// used by the separate local claim reconciliation after publication. +func (s *Service) observeApplicationTournaments( + ctx context.Context, app *Application, mostRecentBlock uint64, +) ([]*Epoch, DaveConsensusAdapter, error) { + epochs, _, err := getTournamentObservationEpochs(ctx, s.repository, app.Name) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to list epochs", err, "application", app.Name) + return nil, nil, err + } + if len(epochs) == 0 { + s.Logger.Debug("No tournament roots to observe", "application", app.Name) + return epochs, nil, nil + } + + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + s.Logger.Error("failed to bind dave consensus contract", "application", app.Name, + "consensus_address", app.IConsensusAddress.String(), "error", err) + return nil, nil, err + } + windowEnd := min(mostRecentBlock, app.LastEpochCheckBlock) + if app.ForecloseBlock != 0 && app.LastEpochCheckBlock >= app.ForecloseBlock { + // Foreclosure ends root creation, not the existing tournament trees. + // Once every pre-foreclosure root is known, their events remain live. + windowEnd = mostRecentBlock + } + + if windowEnd > app.LastTournamentCheckBlock { + if err := s.observeTournamentWindow(ctx, app, epochs, consensus, windowEnd); err != nil { + s.recordTournamentObservationFailure(ctx, app, mostRecentBlock, windowEnd, err) + return nil, nil, err + } + s.clearTournamentObservationFailure(app.ID) + } + + return epochs, consensus, nil +} + +// observeTournamentWindow publishes projections, events, and their cursor in +// one transaction. Acceptance reconciliation and transactions are not part of +// this operation or its readiness signal. +func (s *Service) observeTournamentWindow( + ctx context.Context, app *Application, epochs []*Epoch, consensus DaveConsensusAdapter, windowEnd uint64, +) error { + var roots []*Epoch + for _, epoch := range epochs { + if epoch.TournamentAddress != nil && epoch.LastBlock <= windowEnd { + roots = append(roots, epoch) + } + } + var levelCount uint64 + if len(roots) > 0 { + var err error + levelCount, err = consensus.TournamentLevelCount(pinnedCallOpts(ctx, windowEnd)) + if err != nil { + return fmt.Errorf("fetching tournament level count: %w", err) + } + if levelCount == 0 { + return errors.New("tournament level count is zero") + } + } + var batches []*repository.TournamentEventBatch + for _, epoch := range roots { + rootBatches, err := s.gatherTournamentData(ctx, app, epoch, RootLevel, nil, nil, + *epoch.TournamentAddress, levelCount, windowEnd) + if err != nil { + return fmt.Errorf("gathering epoch %d tournament events: %w", epoch.Index, err) + } + batches = append(batches, rootBatches...) + } + if err := s.repository.StoreTournamentEvents(ctx, app.ID, batches, windowEnd); err != nil { + return fmt.Errorf("storing application tournament event window: %w", err) + } + app.LastTournamentCheckBlock = windowEnd + return nil +} + // gatherTournamentData reads a complete subtree without storing projections, // events, or cursors. The returned batches have parents before their children. func (s *Service) gatherTournamentData( From 82c255b5b3203c87c541675c08db0f202cee1a78 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:19 -0300 Subject: [PATCH 35/41] feat(prt): reconcile published claim state Compare local claims with chain state only after the relevant dispute window has been published. Validate the pinned consensus snapshot and record the on-chain staging block for matching local epochs. Compare the local commitment with the published root-tournament winner. For acceptance, verify the observed transaction and its EpochSealed log against the local machine root and outputs root. An acceptance newer than the published window must wait. Publish the full dispute evidence before a losing local claim marks the app DIVERGED. Keep the final root-bond queue calls in their claim-reconciliation callers; the recovery implementation follows separately. Boundary, lag, and result-projection tests cover the confirmed-state rules. This code does not choose or broadcast a new tournament action. --- internal/prt/consensus.go | 197 +++++++++++++++ internal/prt/prt.go | 121 +++++++++ .../prt/result_observation_boundary_test.go | 236 ++++++++++++++++++ internal/prt/result_observation_lag_test.go | 60 +++++ internal/prt/result_projection_test.go | 97 +++++++ 5 files changed, 711 insertions(+) create mode 100644 internal/prt/consensus.go create mode 100644 internal/prt/result_observation_boundary_test.go create mode 100644 internal/prt/result_observation_lag_test.go create mode 100644 internal/prt/result_projection_test.go diff --git a/internal/prt/consensus.go b/internal/prt/consensus.go new file mode 100644 index 000000000..f3e74acde --- /dev/null +++ b/internal/prt/consensus.go @@ -0,0 +1,197 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +type daveConsensusSnapshot struct { + sealed CurrentSealedEpoch + stage CanStageTournamentResult + accept CanAcceptStagedTournamentResult +} + +func readDaveConsensusSnapshot( + ctx context.Context, + consensus DaveConsensusAdapter, + mostRecentBlock uint64, +) (daveConsensusSnapshot, error) { + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + sealed, err := consensus.GetCurrentSealedEpoch(callOpts) + if err != nil { + return daveConsensusSnapshot{}, fmt.Errorf("reading current sealed epoch: %w", err) + } + stage, err := consensus.CanStageTournamentResult(callOpts) + if err != nil { + return daveConsensusSnapshot{}, fmt.Errorf("reading tournament stage readiness: %w", err) + } + accept, err := consensus.CanAcceptStagedTournamentResult(callOpts) + if err != nil { + return daveConsensusSnapshot{}, fmt.Errorf("reading tournament accept readiness: %w", err) + } + snapshot := daveConsensusSnapshot{sealed: sealed, stage: stage, accept: accept} + if err := validateDaveConsensusSnapshot(snapshot, mostRecentBlock); err != nil { + return daveConsensusSnapshot{}, err + } + return snapshot, nil +} + +func validateDaveConsensusSnapshot(snapshot daveConsensusSnapshot, mostRecentBlock uint64) error { + sealed := snapshot.sealed + stage := snapshot.stage + accept := snapshot.accept + if sealed.EpochNumber != stage.EpochNumber || sealed.EpochNumber != accept.EpochNumber { + return fmt.Errorf("inconsistent DaveConsensus epoch numbers: sealed=%d stage=%d accept=%d", + sealed.EpochNumber, stage.EpochNumber, accept.EpochNumber) + } + if sealed.IsTournamentResultStaged != stage.IsTournamentResultStaged || + sealed.IsTournamentResultStaged != accept.IsTournamentResultStaged { + return fmt.Errorf("inconsistent DaveConsensus staged flags: sealed=%t stage=%t accept=%t", + sealed.IsTournamentResultStaged, + stage.IsTournamentResultStaged, + accept.IsTournamentResultStaged) + } + if sealed.InputIndexLowerBound > sealed.InputIndexUpperBound { + return fmt.Errorf("sealed epoch %d has input lower bound %d above upper bound %d", + sealed.EpochNumber, sealed.InputIndexLowerBound, sealed.InputIndexUpperBound) + } + if sealed.Tournament == (common.Address{}) { + return fmt.Errorf("sealed epoch %d has zero tournament address", sealed.EpochNumber) + } + + if sealed.IsTournamentResultStaged { + if sealed.StagingBlockNumber == 0 || sealed.StagingBlockNumber > mostRecentBlock { + return fmt.Errorf("sealed epoch %d has invalid staging block %d at observed block %d", + sealed.EpochNumber, sealed.StagingBlockNumber, mostRecentBlock) + } + if sealed.StagedPostEpochMachineStateHash != accept.StagedPostEpochMachineStateHash { + return errors.New("staged machine state differs between sealed and accept views") + } + if sealed.StagedPostEpochOutputsMerkleRoot != accept.StagedPostEpochOutputsMerkleRoot { + return errors.New("staged outputs root differs between sealed and accept views") + } + if stage.WinnerPostEpochMachineStateHash != sealed.StagedPostEpochMachineStateHash { + return errors.New("staged machine state differs from the tournament winner") + } + } + return nil +} + +func pendingTournamentResult(epoch *model.Epoch) bool { + return epoch != nil && (epoch.Status == model.EpochStatus_ClaimComputed || epoch.Status == model.EpochStatus_ClaimStaged) +} + +// recordTournamentResult consumes a snapshot at a published tournament block +// that is no newer than the configured head. +func (s *Service) recordTournamentResult( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + snapshot daveConsensusSnapshot, +) error { + if err := s.validateResultEpoch(ctx, app, epoch, snapshot.sealed.EpochNumber); err != nil { + return err + } + if epoch.InputIndexLowerBound != snapshot.sealed.InputIndexLowerBound || + epoch.InputIndexUpperBound != snapshot.sealed.InputIndexUpperBound { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has input bounds inconsistent with DaveConsensus: local=[%d,%d] on-chain=[%d,%d]", + epoch.Index, epoch.InputIndexLowerBound, epoch.InputIndexUpperBound, + snapshot.sealed.InputIndexLowerBound, snapshot.sealed.InputIndexUpperBound) + } + if err := matchConsensusSnapshotToEpoch(epoch, snapshot); err != nil { + return s.setApplicationDiverged(ctx, app, "%v", err) + } + if snapshot.stage.IsTournamentFailed { + s.logFailedRootTournament(app, epoch.Index, snapshot.sealed.Tournament) + return nil + } + if !snapshot.sealed.IsTournamentResultStaged { + return nil + } + if epoch.Status == model.EpochStatus_ClaimStaged { + if epoch.StagedAtBlock == nil || *epoch.StagedAtBlock != snapshot.sealed.StagingBlockNumber { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has staging block inconsistent with DaveConsensus", epoch.Index) + } + return nil + } + if err := s.repository.UpdateEpochReconciledStaged(ctx, app.ID, epoch.Index, snapshot.sealed.StagingBlockNumber); err != nil { + return fmt.Errorf("recording staged tournament result for epoch %d: %w", epoch.Index, err) + } + epoch.Status = model.EpochStatus_ClaimStaged + epoch.StagedAtBlock = new(snapshot.sealed.StagingBlockNumber) + s.Logger.Info("Recorded staged tournament result", + "application", app.Name, "epoch_index", epoch.Index, "staging_block", snapshot.sealed.StagingBlockNumber) + return nil +} + +func (s *Service) validateResultEpoch(ctx context.Context, app *model.Application, epoch *model.Epoch, index uint64) error { + if epoch.TournamentAddress == nil || epoch.Commitment == nil || epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has missing required fields for tournament result processing", epoch.Index) + } + if epoch.Index != index { + return s.setApplicationCorrupted(ctx, app, + "loaded epoch %d for current sealed epoch %d", epoch.Index, index) + } + return nil +} + +func matchConsensusSnapshotToEpoch(epoch *model.Epoch, snapshot daveConsensusSnapshot) error { + if epoch.InputIndexLowerBound != snapshot.sealed.InputIndexLowerBound || + epoch.InputIndexUpperBound != snapshot.sealed.InputIndexUpperBound { + return fmt.Errorf( + "epoch %d has input bounds inconsistent with DaveConsensus: local=[%d,%d] on-chain=[%d,%d]", + epoch.Index, + epoch.InputIndexLowerBound, + epoch.InputIndexUpperBound, + snapshot.sealed.InputIndexLowerBound, + snapshot.sealed.InputIndexUpperBound) + } + if *epoch.TournamentAddress != snapshot.sealed.Tournament { + return fmt.Errorf( + "epoch %d has inconsistent root tournament between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.TournamentAddress, snapshot.sealed.Tournament) + } + if snapshot.stage.IsTournamentFailed { + return nil + } + if !snapshot.stage.IsFinished { + return nil + } + if *epoch.Commitment != snapshot.stage.WinnerCommitment { + return fmt.Errorf( + "epoch %d has inconsistent commitment between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.Commitment, snapshot.stage.WinnerCommitment) + } + if *epoch.MachineHash != snapshot.stage.WinnerPostEpochMachineStateHash { + return fmt.Errorf( + "epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash, snapshot.stage.WinnerPostEpochMachineStateHash) + } + if snapshot.sealed.IsTournamentResultStaged { + if *epoch.MachineHash != snapshot.sealed.StagedPostEpochMachineStateHash { + return fmt.Errorf( + "epoch %d has inconsistent staged machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash, snapshot.sealed.StagedPostEpochMachineStateHash) + } + if *epoch.TxBufferDataBlock != snapshot.sealed.StagedPostEpochOutputsMerkleRoot { + return fmt.Errorf( + "epoch %d has inconsistent staged outputs root between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.TxBufferDataBlock, snapshot.sealed.StagedPostEpochOutputsMerkleRoot) + } + } + return nil +} diff --git a/internal/prt/prt.go b/internal/prt/prt.go index d012aad54..a8ba3d5e1 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -811,6 +811,127 @@ func (s *Service) observeTournamentWindow( return nil } +func (s *Service) reconcileAcceptedEpochs( + ctx context.Context, app *Application, epochs []*Epoch, consensus DaveConsensusAdapter, mostRecentBlock uint64, +) (bool, error) { + if mostRecentBlock < app.LastTournamentCheckBlock { + // A stored projection from a later head cannot decide local status at + // this older configured head. Keep it and wait for the head to catch up. + return true, nil + } + for _, epoch := range epochs { + if epoch.Status != EpochStatus_ClaimComputed && epoch.Status != EpochStatus_ClaimStaged { + continue + } + if epoch.TournamentAddress == nil || epoch.Commitment == nil || + epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { + return true, s.setApplicationCorrupted(ctx, app, + "epoch %d has missing required fields for pending claim processing", epoch.Index) + } + + // Compare only after the complete root and descendant window is durable. + // A losing local claim must not hide the on-chain winner from clients. + tournament, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()) + if err != nil { + return true, fmt.Errorf("loading epoch %d observed tournament: %w", epoch.Index, err) + } + if tournament == nil || tournament.Snapshot.FinishedAtBlock == 0 || + tournament.Snapshot.FinishedAtBlock > app.LastTournamentCheckBlock { + return false, nil + } + if tournament.Snapshot.WinnerCommitment != nil && *tournament.Snapshot.WinnerCommitment != *epoch.Commitment { + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent commitment between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.Commitment, tournament.Snapshot.WinnerCommitment) + } + if epoch.ClaimTransactionHash == nil { // no accepting EpochSealed event observed yet + break + } + + receipt, err := s.client.TransactionReceipt(ctx, *epoch.ClaimTransactionHash) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch transaction receipt for epoch", err, "application", app.Name, + "epoch", epoch.Index, "tx", epoch.ClaimTransactionHash) + return true, err + } + if receipt == nil { + return true, fmt.Errorf("epoch %d: acceptance transaction receipt is nil", epoch.Index) + } + if receipt.TxHash != *epoch.ClaimTransactionHash { + return true, fmt.Errorf("epoch %d: acceptance receipt transaction hash %s differs from observed hash %s", + epoch.Index, receipt.TxHash, epoch.ClaimTransactionHash) + } + receiptBlock, err := checkedUint64(receipt.BlockNumber, "acceptance receipt block") + if err != nil { + return true, fmt.Errorf("epoch %d: %w", epoch.Index, err) + } + observedBlock := min(mostRecentBlock, app.LastTournamentCheckBlock) + if receiptBlock > observedBlock { + s.Logger.Debug("Acceptance transaction is newer than the published tournament window", + "application", app.Name, + "epoch", epoch.Index, + "tx", epoch.ClaimTransactionHash, + "receipt_block", receiptBlock, + "snapshot_block", observedBlock) + return true, nil + } + + if receipt.Status != types.ReceiptStatusSuccessful { + return true, fmt.Errorf("epoch %d: EpochSealed transaction hash points to failed transaction", epoch.Index) + } + + var event *idaveconsensus.IDaveConsensusEpochSealed + expectedEventEpoch := new(big.Int).SetUint64(epoch.Index) + expectedEventEpoch.Add(expectedEventEpoch, common.Big1) + for _, vLog := range receipt.Logs { + if vLog == nil || vLog.Address != app.IConsensusAddress || + vLog.TxHash != *epoch.ClaimTransactionHash || vLog.BlockNumber != receiptBlock { + continue + } + candidate, parseErr := consensus.ParseEpochSealed(*vLog) + if parseErr != nil || candidate == nil || candidate.EpochNumber == nil || + candidate.EpochNumber.Cmp(expectedEventEpoch) != 0 { + continue // Skip logs that don't match + } + event = candidate + break + } + if event == nil { + return true, fmt.Errorf("epoch %d: failed to find EpochSealed event in receipt logs", epoch.Index) + } + + if *epoch.MachineHash != event.InitialMachineStateHash { + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash.String(), hexutil.Encode(event.InitialMachineStateHash[:])) + } + if *epoch.TxBufferDataBlock != event.OutputsMerkleRoot { + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent claim hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.TxBufferDataBlock.String(), hexutil.Encode(event.OutputsMerkleRoot[:])) + } + + s.Logger.Info("Found finalized epoch. OutputsMerkleRoot matched. Setting claim as accepted", + "application", app.Name, + "epoch", epoch.Index, + "event_block_number", event.Raw.BlockNumber, + "outputs_merkle_root", fmt.Sprintf("%x", event.OutputsMerkleRoot), + "tx", epoch.ClaimTransactionHash, + ) + + if s.submissionEnabled { + s.queueRootBondRecovery(app.ID, epoch.Index, *epoch.TournamentAddress) + } + err = s.repository.UpdateEpochWithAcceptedClaim(ctx, app.ID, epoch.Index, epoch.ClaimTransactionHash) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to update epoch status to claim accepted", err, + "application", app.Name, "epoch", epoch.Index) + return true, err + } + } + return false, nil +} + // gatherTournamentData reads a complete subtree without storing projections, // events, or cursors. The returned batches have parents before their children. func (s *Service) gatherTournamentData( diff --git a/internal/prt/result_observation_boundary_test.go b/internal/prt/result_observation_boundary_test.go new file mode 100644 index 000000000..d21077c3f --- /dev/null +++ b/internal/prt/result_observation_boundary_test.go @@ -0,0 +1,236 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestResultWritesWaitForTournamentObservation(t *testing.T) { + for _, staged := range []bool{false, true} { + name := "opponent wins" + if staged { + name = "matching result is staged" + } + t.Run(name, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastEpochCheckBlock = 99 + epoch := checkpointEpoch(0, "0x100") + winner := *epoch.Commitment + if !staged { + winner = common.HexToHash("0xbad") + } + tournament := resultBoundaryTournament(f, epoch, winner) + snapshot := resultTestSnapshot(epoch, staged) + snapshot.stage.WinnerCommitment = winner + observed := resultTestSnapshot(epoch, false) + observed.stage.IsFinished = false + observed.stage.WinnerCommitment = common.Hash{} + observed.stage.WinnerPostEpochMachineStateHash = common.Hash{} + if staged { + snapshot.sealed.StagingBlockNumber = 100 + } + var writeCoverage []uint64 + if staged { + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(100)). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, f.app.LastTournamentCheckBlock) }).Return(nil).Once() + } else { + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, f.app.LastTournamentCheckBlock) }).Return(nil).Once() + } + f.expectResultBoundaryWindow(epoch, tournament, 99) + f.expectResultBoundarySnapshot(epoch, observed, 99) + + require.NoError(t, f.s.validateApplication(t.Context(), f.app, 100)) + require.Equal(t, uint64(99), f.app.LastTournamentCheckBlock, "the older window must still publish successfully") + require.Equal(t, uint64(99), tournament.Snapshot.AsOfBlock) + require.Equal(t, model.TournamentStandingAwaitingClosure, tournament.Snapshot.Standing) + require.Empty(t, writeCoverage, "a newer configured result must not change persistent local state") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Nil(t, epoch.StagedAtBlock) + + // No-work at the same observation boundary is not evidence that the + // configured result has become durable. + f.epochs(epoch) + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), tournament.Address.Hex()). + Return(tournament, nil).Once() + f.expectResultBoundarySnapshot(epoch, observed, 99) + require.NoError(t, f.s.validateApplication(t.Context(), f.app, 100)) + require.Empty(t, writeCoverage) + + f.app.LastEpochCheckBlock = 100 + f.expectResultBoundaryWindow(epoch, tournament, 100) + if staged { + f.expectResultBoundarySnapshot(epoch, snapshot, 100) + } + err := f.s.validateApplication(t.Context(), f.app, 100) + if staged { + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(100)), epoch.StagedAtBlock) + } else { + require.ErrorContains(t, err, "Epoch 0 has inconsistent commitment") + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) + } + require.Equal(t, []uint64{100}, writeCoverage, "the complete result window must precede the local write") + require.Equal(t, uint64(100), tournament.Snapshot.AsOfBlock) + require.Equal(t, &winner, tournament.Snapshot.WinnerCommitment) + require.Empty(t, f.s.pendingTransactions, "reader mode must remain passive") + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + }) + } +} + +func resultBoundaryTournament(f *observerCheckpointFixture, epoch *model.Epoch, winner common.Hash) *model.Tournament { + return &model.Tournament{ + ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + MaxLevel: 1, Height: 1, Kind: model.TournamentKindLeaf, StartInstant: epoch.LastBlock, Allowance: 90, + Snapshot: model.TournamentSnapshot{AsOfBlock: f.app.LastTournamentCheckBlock, + Standing: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, Candidate: &winner, + BondRecovery: model.TournamentBondRecovery{Disposition: model.BondDispositionTournamentRunning}}, + } +} + +func (f *observerCheckpointFixture) expectResultBoundarySnapshot(epoch *model.Epoch, snapshot daveConsensusSnapshot, head uint64) { + f.t.Helper() + opts := mock.MatchedBy(resultCallOptsAtBlock(head)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() +} + +func (f *observerCheckpointFixture) expectResultBoundaryWindow(epoch *model.Epoch, tournament *model.Tournament, head uint64) { + f.t.Helper() + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(head)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), tournament.Address.Hex()). + Return(tournament, nil).Twice() // observation load, then local reconciliation + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", tournament.Address).Return(adapter, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{BaseCycle: new(big.Int), Height: tournament.Height, + Kind: tournament.Kind, StartInstant: tournament.StartInstant, Allowance: tournament.Allowance}, nil).Once() + standing := TournamentStanding{State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, + HasCandidate: true, Candidate: *tournament.Snapshot.Candidate} + if head >= 100 { + standing.State, standing.AcceptsJoins = model.TournamentStandingRootWinner, false + standing.FinalState, standing.FinishedAt = *epoch.MachineHash, 100 + } + adapter.On("Standing", opts).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, standing.State) + previous := f.app.LastTournamentCheckBlock + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == previous+1 && opts.End != nil && *opts.End == head + })).Return(&TournamentEvents{}, nil).Once() + counts := zeroStructuralEventCounts() + counts.CommitmentJoined.SetUint64(1) + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(previous))).Return(counts, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(counts, nil).Once() + adapter.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(previous))). + Return(canonicalBondRecovery(tournament.Snapshot.BondRecovery.Disposition, common.Address{}, 0), nil).Once() + address := tournament.Address.Hex() + commitment := &model.Commitment{ApplicationID: f.app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Commitment: standing.Candidate, FinalStateHash: *epoch.MachineHash, BlockNumber: 20} + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return([]*model.Commitment{commitment}, uint64(1), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return([]*model.Match{}, uint64(0), nil).Once() + adapter.On("CommitmentStanding", opts, [32]byte(commitment.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 80}, nil).Once() + statusBeforePublication := epoch.Status + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, head). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(f.t, batches, 1) + require.Equal(f.t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(f.t, statusBeforePublication, epoch.Status) + *tournament = *batches[0].Tournament + }).Return(nil).Once() + f.t.Cleanup(func() { adapter.AssertExpectations(f.t) }) +} + +func TestObserverLagDoesNotBlockLatestStageAction(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock, f.app.LastEpochCheckBlock = 50, 99 + f.epoch.LastBlock = 10 + observer := &observerCheckpointFixture{t: t, s: f.s, repo: f.repo, factory: f.factory, + consensus: f.consensus, client: f.client, app: f.app} + tournament := resultBoundaryTournament(observer, f.epoch, *f.epoch.Commitment) + observer.expectResultBoundaryWindow(f.epoch, tournament, 99) + observed := resultTestSnapshot(f.epoch, false) + observed.stage.IsFinished = false + observed.stage.WinnerCommitment = common.Hash{} + observed.stage.WinnerPostEpochMachineStateHash = common.Hash{} + latest := resultTestSnapshot(f.epoch, false) + f.client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + f.expectSnapshot(99, observed) + f.expectSnapshot(120, latest) + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.consensus.On("StageTournamentResult", mock.Anything, f.epoch.Index, mock.Anything).Return(tx, nil).Once() + + require.NoError(t, f.s.validateApplication(t.Context(), f.app, 100)) + require.Equal(t, tx.Hash(), f.s.pendingTransactions[f.app.ID].Hash) + require.Equal(t, uint64(99), f.app.LastTournamentCheckBlock) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) +} + +func TestAcceptanceWaitsForTournamentObservation(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 99 + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + tournament := &model.Tournament{Snapshot: model.TournamentSnapshot{ + AsOfBlock: 99, FinishedAtBlock: 90, WinnerCommitment: epoch.Commitment, + }} + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Twice() + raw := types.Log{Address: app.IConsensusAddress, TxHash: *epoch.ClaimTransactionHash, BlockNumber: 100} + receipt := &types.Receipt{TxHash: raw.TxHash, BlockNumber: big.NewInt(100), + Status: types.ReceiptStatusSuccessful, Logs: []*types.Log{&raw}} + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, raw.TxHash).Return(receipt, nil).Twice() + s.client = client + consensus := &daveConsensusAdapterMock{} + consensus.On("ParseEpochSealed", raw).Return(&idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, Raw: raw, + }, nil).Once() + var writeCoverage []uint64 + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, epoch.ClaimTransactionHash). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, app.LastTournamentCheckBlock) }).Return(nil).Once() + + deferActions, err := s.reconcileAcceptedEpochs(t.Context(), app, []*model.Epoch{epoch}, consensus, 100) + require.NoError(t, err) + require.True(t, deferActions, "the acceptance receipt is newer than the published tournament window") + require.Empty(t, writeCoverage) + consensus.AssertNotCalled(t, "ParseEpochSealed", mock.Anything) + + app.LastTournamentCheckBlock = 100 + tournament.Snapshot.AsOfBlock = 100 + deferActions, err = s.reconcileAcceptedEpochs(t.Context(), app, []*model.Epoch{epoch}, consensus, 100) + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, []uint64{100}, writeCoverage) + repo.AssertExpectations(t) + client.AssertExpectations(t) + consensus.AssertExpectations(t) +} diff --git a/internal/prt/result_observation_lag_test.go b/internal/prt/result_observation_lag_test.go new file mode 100644 index 000000000..33d50cb9c --- /dev/null +++ b/internal/prt/result_observation_lag_test.go @@ -0,0 +1,60 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestResultPublicationMakesProgressWithContinuousObserverLag(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + tournament := resultBoundaryTournament(f, epoch, *epoch.Commitment) + var stageWriteCoverage []uint64 + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(100)). + Run(func(mock.Arguments) { + require.Equal(t, uint64(100), tournament.Snapshot.AsOfBlock, "publish the result window before its staged marker") + stageWriteCoverage = append(stageWriteCoverage, f.app.LastTournamentCheckBlock) + }).Return(nil).Once() + + for _, published := range []uint64{99, 100, 101} { + configured := published + 1 + f.app.LastEpochCheckBlock = published + f.expectResultBoundaryWindow(epoch, tournament, published) + staged := published >= 100 + snapshot := resultTestSnapshot(epoch, staged) + if staged { + snapshot.sealed.StagingBlockNumber = 100 + } else { + snapshot.stage.IsFinished = false + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + } + // Bind every result read to the block that was actually published. A + // read at the moving configured head cannot establish this checkpoint. + f.expectResultBoundarySnapshot(epoch, snapshot, published) + require.NoError(t, f.s.validateApplication(t.Context(), f.app, configured)) + require.Equal(t, published, f.app.LastTournamentCheckBlock) + require.Equal(t, published, tournament.Snapshot.AsOfBlock) + require.Less(t, f.app.LastTournamentCheckBlock, configured, "the observer never catches the configured head") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + if staged { + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(100)), epoch.StagedAtBlock) + require.Equal(t, []uint64{100}, stageWriteCoverage, "record once at the next tick, then retain the marker") + } else { + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Nil(t, epoch.StagedAtBlock) + require.Empty(t, stageWriteCoverage, "block 99 must not publish the stage from block 100") + } + } + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.Empty(t, f.s.pendingTransactions, "reader mode must not send transactions") +} diff --git a/internal/prt/result_projection_test.go b/internal/prt/result_projection_test.go new file mode 100644 index 000000000..5d5ca6890 --- /dev/null +++ b/internal/prt/result_projection_test.go @@ -0,0 +1,97 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestFailedRootDoesNotBlockOlderOwnedBondRecovery(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + f.expectTick(100, 100, snapshot, snapshot) + olderTournament := common.HexToAddress("0x900") + f.s.queueRootBondRecovery(f.app.ID, f.epoch.Index-1, olderTournament) + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(snapshot.sealed, nil).Once() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", olderTournament).Return(tournament, nil).Once() + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, f.s.txOptsFactory.From(), 1), nil).Once() + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + tournament.On("TryRecoveringBond", mock.Anything).Return(tx, nil).Once() + + require.NoError(t, f.tick(100)) + require.Equal(t, tx.Hash(), *f.s.rootBondRecoveries[f.app.ID][0].TxHash) + f.assertNoPermanentStatusWrite(t) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + tournament.AssertExpectations(t) +} + +func TestResultSnapshotAllowsZeroFinalState(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.MachineHash = new(common.Hash{}) + snapshot := resultTestSnapshot(epoch, false) + require.NoError(t, validateDaveConsensusSnapshot(snapshot, 20)) + require.NoError(t, matchConsensusSnapshotToEpoch(epoch, snapshot)) +} + +func TestFailedRootRepeatedObservationAndForeclosure(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + service, repo := newPRTServiceMock() + for range 2 { + require.NoError(t, service.recordTournamentResult(t.Context(), app, epoch, snapshot)) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + + app.ForecloseBlock = 100 + app.LastEpochCheckBlock = 100 + app.LastInputCheckBlock = 100 + app.LastTournamentCheckBlock = 100 + service.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + service.client = client + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(&daveConsensusAdapterMock{}, nil).Once() + service.adapterFactory = factory + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(true, nil).Once() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, + repository.Pagination{}, false).Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return( + &model.Tournament{Address: *epoch.TournamentAddress, Snapshot: model.TournamentSnapshot{ + AsOfBlock: 100, FinishedAtBlock: 90, Standing: model.TournamentStandingRootFailed, + }}, nil).Once() + repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, epoch.Index).Return(nil).Once() + + require.NoError(t, service.handleForeclosedApp(t.Context(), app, func() (uint64, error) { + return service.getDefaultBlockNumber(t.Context()) + })) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + client.AssertExpectations(t) + factory.AssertExpectations(t) +} From b14ec9819addf0914a6b241d7a157892d7decf71 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:19 -0300 Subject: [PATCH 36/41] feat(prt): submit v3 tournament actions Provide the final root join, tournament-result stage, and acceptance operations. Encode the full machine proof and classify contract reverts without treating latest-only evidence as confirmed application state. Use an in-memory exclusion slot for pending join, stage, and accept transactions. Even a mined receipt makes the current tick yield, so the next action uses a fresh chain view. Receipts alone do not set claim status. Include proof, pending-transaction, and revert tests. Remove the obsolete settle path. Keep the final bond-queue call in the acceptance operation. Action selection is connected in the service-cycle commit; active dispute moves and persistent transaction delivery remain outside this contract bump. --- internal/prt/consensus.go | 252 +++++++++++++++++ internal/prt/pending.go | 70 +++++ internal/prt/pending_test.go | 121 ++++++++ internal/prt/prt.go | 451 +++++++----------------------- internal/prt/reverts_test.go | 276 +++++++++++------- internal/prt/typed_errors_test.go | 118 -------- 6 files changed, 720 insertions(+), 568 deletions(-) create mode 100644 internal/prt/pending.go create mode 100644 internal/prt/pending_test.go delete mode 100644 internal/prt/typed_errors_test.go diff --git a/internal/prt/consensus.go b/internal/prt/consensus.go index f3e74acde..7a56100b1 100644 --- a/internal/prt/consensus.go +++ b/internal/prt/consensus.go @@ -195,3 +195,255 @@ func matchConsensusSnapshotToEpoch(epoch *model.Epoch, snapshot daveConsensusSna } return nil } + +func (s *Service) broadcastStageTournamentResult( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + consensus DaveConsensusAdapter, +) error { + proof, err := epoch.StateProof() + if err != nil { + return s.setApplicationCorrupted(ctx, app, + "cannot stage tournament result for epoch %d: persisted machine state proof is incomplete: %v", + epoch.Index, err) + } + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) + defer cancel() + txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) + if err != nil { + return fmt.Errorf("creating transaction options to stage epoch %d: %w", epoch.Index, err) + } + tx, err := consensus.StageTournamentResult(txOpts, epoch.Index, proof) + if err != nil { + return s.handleStageTournamentResultRevert(ctx, app, epoch, err) + } + if tx == nil { + return errors.New("stage tournament result returned a nil transaction") + } + txHash := tx.Hash() + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionStage, Hash: txHash, EpochIndex: epoch.Index, + } + s.Logger.Info("Sent tournament result stage transaction", + "application", app.Name, + "epoch_index", epoch.Index, + "tx", txHash) + return nil +} + +func (s *Service) broadcastAcceptTournamentResult( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + consensus DaveConsensusAdapter, + snapshot daveConsensusSnapshot, +) error { + if epoch.TournamentAddress == nil || *epoch.TournamentAddress != snapshot.sealed.Tournament { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has no validated root tournament for result acceptance", epoch.Index) + } + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) + defer cancel() + txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) + if err != nil { + return fmt.Errorf("creating transaction options to accept epoch %d: %w", epoch.Index, err) + } + s.queueRootBondRecovery(app.ID, epoch.Index, snapshot.sealed.Tournament) + tx, err := consensus.AcceptStagedTournamentResult(txOpts, epoch.Index) + if err != nil { + return s.handleAcceptTournamentResultRevert(ctx, app, epoch, err) + } + if tx == nil { + return errors.New("accept staged tournament result returned a nil transaction") + } + txHash := tx.Hash() + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionAccept, Hash: txHash, EpochIndex: epoch.Index, + } + s.Logger.Info("Sent staged tournament result acceptance transaction", + "application", app.Name, + "epoch_index", epoch.Index, + "tournament", snapshot.sealed.Tournament, + "tx", txHash) + return nil +} + +func (s *Service) handleStageTournamentResultRevert( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + err error, +) error { + if name := stageMachineValidationRevertName(err); name != "" { + if name == "InvalidMachineMerkleProof" { + // The contract checks this proof against its current winner, which + // may differ from the latest precheck after a reorg or RPC race. + // Only diagnose the proof once its target is confirmed. + if confirmErr := s.confirmStageProofTarget(ctx, app, epoch); confirmErr != nil { + return fmt.Errorf("stage proof rejected before its target could be confirmed: %w", confirmErr) + } + } + guidance := "The node validated the proof when it collected it. " + + "Check proof serialization, stored proof data, and node and contract versions before re-enabling." + if name == "InvalidPostEpochMachineIflagsYRegister" || name == "InvalidPostEpochMachineHtifTohostRegister" { + guidance = "The proven post-epoch machine state cannot finalize. " + + "Check the selected post-epoch state, stored data, and node and contract versions. " + + "If this state is unrecoverable, consider guardian foreclosure to permit emergency withdrawals and deposit refunds." + } + return s.setApplicationFailed(ctx, app, + "StageTournamentResult rejected the machine state proof with %s for epoch %d. %s", + name, epoch.Index, guidance) + } + switch { + case isTournamentError(err, "TournamentFailedNoWinner"): + s.Logger.Warn("Stage transaction observed a failed tournament; waiting for configured-block confirmation", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "TournamentResultAlreadyStaged"): + s.Logger.Info("Tournament result was already staged; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "TournamentNotFinishedYet"): + s.Logger.Info("Tournament result is not ready to stage; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "IncorrectEpochNumber"): + return s.handleResultEpochRace(app, "StageTournamentResult", epoch.Index, err) + case isDaveConsensusError(err, "ApplicationForeclosed"): + s.Logger.Warn("StageTournamentResult observed application foreclosure; waiting for the foreclosure observer", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveApplicationFailure(err): + return s.failDaveApplicationCall(ctx, app, "StageTournamentResult", epoch.Index, err) + case ethutil.IsNonceTooLowError(err): + s.Logger.Info("StageTournamentResult broadcast was rejected with nonce too low; waiting for chain reconciliation", + "application", app.Name, "epoch_index", epoch.Index) + return nil + default: + s.logErrorUnlessShutdown(ctx, "failed to stage tournament result", err, + "application", app.Name, + "epoch_index", epoch.Index, + "decoded_revert", describeKnownRevert(err)) + return err + } +} + +func (s *Service) confirmStageProofTarget(ctx context.Context, app *model.Application, epoch *model.Epoch) error { + block, err := s.getDefaultBlockNumber(ctx) + if err != nil { + return err + } + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + return err + } + snapshot, err := readDaveConsensusSnapshot(ctx, consensus, block) + if err != nil { + return err + } + if snapshot.sealed.EpochNumber != epoch.Index || !snapshot.stage.IsFinished || snapshot.stage.IsTournamentFailed { + return fmt.Errorf("epoch %d winner is not confirmed at configured block %d", epoch.Index, block) + } + if err := s.validateResultEpoch(ctx, app, epoch, snapshot.sealed.EpochNumber); err != nil { + return err + } + return matchConsensusSnapshotToEpoch(epoch, snapshot) +} + +func stageMachineValidationRevertName(err error) string { + for _, name := range []string{ + "InvalidSiblingsArrayLength", + "InvalidMachineMerkleProof", + "InvalidPostEpochMachineIflagsYRegister", + "InvalidPostEpochMachineHtifTohostRegister", + } { + if isDaveConsensusError(err, name) { + return name + } + } + return "" +} + +func (s *Service) handleAcceptTournamentResultRevert( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + err error, +) error { + switch { + case isDaveConsensusError(err, "TournamentResultNotStaged"): + s.Logger.Info("Tournament result is no longer staged; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "ClaimStagingPeriodNotOverYet"): + s.Logger.Info("Tournament result is not ready to accept; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "IncorrectEpochNumber"): + return s.handleResultEpochRace(app, "AcceptStagedTournamentResult", epoch.Index, err) + case isDaveConsensusError(err, "ApplicationForeclosed"): + s.Logger.Warn("AcceptStagedTournamentResult observed application foreclosure; waiting for the foreclosure observer", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveApplicationFailure(err): + return s.failDaveApplicationCall(ctx, app, "AcceptStagedTournamentResult", epoch.Index, err) + case ethutil.IsNonceTooLowError(err): + s.Logger.Info("AcceptStagedTournamentResult broadcast was rejected with nonce too low; waiting for chain reconciliation", + "application", app.Name, "epoch_index", epoch.Index) + return nil + default: + s.logErrorUnlessShutdown(ctx, "failed to accept staged tournament result", err, + "application", app.Name, + "epoch_index", epoch.Index, + "decoded_revert", describeKnownRevert(err)) + return err + } +} + +func (s *Service) handleResultEpochRace( + app *model.Application, + action string, + epochNumber uint64, + err error, +) error { + received, actual, ok := decodeIncorrectEpochNumber(err) + if !ok { + return err + } + if received.Cmp(actual) > 0 { + // Simulation may use a lagging replica or an unfinalized fork. It is + // not evidence that the local epoch is invalid. + return err + } + s.Logger.Info("Tournament result transaction used a stale epoch; waiting for chain synchronization", + "application", app.Name, + "action", action, + "epoch_index", epochNumber, + "chain_epoch", actual) + return nil +} + +func isDaveApplicationFailure(err error) bool { + return isDaveConsensusError(err, "ApplicationNotDeployed") || + isDaveConsensusError(err, "ApplicationReverted") || + isDaveConsensusError(err, "IllformedApplicationReturnData") +} + +func (s *Service) failDaveApplicationCall( + ctx context.Context, + app *model.Application, + action string, + epochNumber uint64, + err error, +) error { + for _, name := range []string{"ApplicationNotDeployed", "ApplicationReverted", "IllformedApplicationReturnData"} { + if !isDaveConsensusError(err, name) { + continue + } + return s.setApplicationFailed(ctx, app, + "%s reverted with %s for epoch %d. Verify the application and consensus contracts before re-enabling.%s", + action, name, epochNumber, ethutil.ApplicationReturnDataSuffix(err, idaveconsensus.IDaveConsensusMetaData, name)) + } + return err +} diff --git a/internal/prt/pending.go b/internal/prt/pending.go new file mode 100644 index 000000000..82355e61c --- /dev/null +++ b/internal/prt/pending.go @@ -0,0 +1,70 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +type tournamentAction string + +const ( + tournamentActionJoin tournamentAction = "join" + tournamentActionStage tournamentAction = "stage" + tournamentActionAccept tournamentAction = "accept" +) + +// pendingTournamentTransaction is a local exclusion slot, not a delivery queue. +// It is lost on restart. Pending and missing transactions have no retry deadline. +type pendingTournamentTransaction struct { + Action tournamentAction + Hash common.Hash + EpochIndex uint64 +} + +// waitForTournamentTransaction blocks new actions for this tick, including when +// a mined transaction releases its slot. The next tick must read fresh state. +// Receipts provide diagnostics; they do not change stored claim state. +func (s *Service) waitForTournamentTransaction(ctx context.Context, app *Application) (bool, error) { + tx, exists := s.pendingTransactions[app.ID] + if !exists { + return false, nil + } + _, pending, err := s.client.TransactionByHash(ctx, tx.Hash) + if err != nil { + return true, fmt.Errorf("checking %s transaction %s: %w", tx.Action, tx.Hash, err) + } + if pending { + s.Logger.Debug("Tournament transaction is still pending", + "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash) + return true, nil + } + receipt, err := s.client.TransactionReceipt(ctx, tx.Hash) + if err != nil { + return true, fmt.Errorf("reading %s transaction receipt %s: %w", tx.Action, tx.Hash, err) + } + if receipt == nil || receipt.TxHash != tx.Hash { + return true, fmt.Errorf("%s transaction %s has a missing or mismatched receipt", tx.Action, tx.Hash) + } + if _, err := checkedUint64(receipt.BlockNumber, "tournament transaction receipt block"); err != nil { + return true, err + } + if receipt.Status != types.ReceiptStatusFailed && receipt.Status != types.ReceiptStatusSuccessful { + return true, fmt.Errorf("%s transaction %s has invalid receipt status %d", tx.Action, tx.Hash, receipt.Status) + } + delete(s.pendingTransactions, app.ID) + if receipt.Status == types.ReceiptStatusFailed { + s.Logger.Error("Tournament transaction reverted; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash) + } else { + s.Logger.Debug("Tournament transaction was mined; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash) + } + return true, nil +} diff --git a/internal/prt/pending_test.go b/internal/prt/pending_test.go new file mode 100644 index 000000000..0362850bf --- /dev/null +++ b/internal/prt/pending_test.go @@ -0,0 +1,121 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestPendingTournamentTransactionRetainsOrReleasesSlot(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + for _, test := range []struct { + name string + pending bool + lookupError error + receiptError error + mutate func(*types.Receipt) *types.Receipt + wantError bool + wantRelease bool + wantRevert bool + }{ + {name: "pending", pending: true}, + {name: "missing transaction", lookupError: ethereum.NotFound, wantError: true}, + {name: "missing receipt", receiptError: ethereum.NotFound, wantError: true}, + {name: "nil receipt", mutate: func(*types.Receipt) *types.Receipt { return nil }, wantError: true}, + {name: "wrong hash", mutate: func(r *types.Receipt) *types.Receipt { r.TxHash = common.Hash{}; return r }, wantError: true}, + {name: "missing block", mutate: func(r *types.Receipt) *types.Receipt { r.BlockNumber = nil; return r }, wantError: true}, + {name: "invalid status", mutate: func(r *types.Receipt) *types.Receipt { r.Status = 2; return r }, wantError: true}, + {name: "mined success", wantRelease: true}, + {name: "mined revert", mutate: func(r *types.Receipt) *types.Receipt { r.Status = 0; return r }, + wantRelease: true, wantRevert: true}, + } { + t.Run(string(action)+"/"+test.name, func(t *testing.T) { + app := prtRevertTestApp() + s, repo := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + tx := pendingTournamentTransaction{Action: action, Hash: common.HexToHash("0xbeef"), EpochIndex: 3} + s.pendingTransactions[app.ID] = tx + s.pendingTransactions[app.ID+1] = tx + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, tx.Hash). + Return((*types.Transaction)(nil), test.pending, test.lookupError).Once() + if !test.pending && test.lookupError == nil { + receipt := &types.Receipt{TxHash: tx.Hash, BlockNumber: big.NewInt(21), Status: types.ReceiptStatusSuccessful} + if test.mutate != nil { + receipt = test.mutate(receipt) + } + client.On("TransactionReceipt", mock.Anything, tx.Hash).Return(receipt, test.receiptError).Once() + } + s.client = client + // A receipt newer than the observed head does not need finality to + // release the local slot. It cannot mark an epoch or app as terminal. + joinEpoch, _, err := s.progressTournamentResult(t.Context(), app, 20, 20) + require.Equal(t, test.wantError, err != nil, "%v", err) + require.Nil(t, joinEpoch) + if test.wantRelease { + require.NotContains(t, s.pendingTransactions, app.ID) + } else { + require.Equal(t, tx, s.pendingTransactions[app.ID]) + } + require.Equal(t, tx, s.pendingTransactions[app.ID+1], "another application's slot must not change") + require.Equal(t, ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + if test.wantRevert { + require.Contains(t, output.String(), "level=ERROR") + require.Contains(t, output.String(), "action="+string(action)) + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), tx.Hash.Hex()) + } else { + require.NotContains(t, output.String(), "level=ERROR") + } + client.AssertExpectations(t) + }) + } + } +} + +func TestPendingJoinReadsCurrentEpochOnlyAfterMining(t *testing.T) { + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage = CanStageTournamentResult{EpochNumber: epoch.Index} + s, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + tx := types.NewTx(&types.LegacyTx{}) + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionJoin, Hash: tx.Hash(), EpochIndex: epoch.Index - 1, + } + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, tx.Hash()).Return(tx, true, nil).Once() + client.On("TransactionByHash", mock.Anything, tx.Hash()).Return(tx, false, nil).Once() + client.On("TransactionReceipt", mock.Anything, tx.Hash()).Return(&types.Receipt{ + TxHash: tx.Hash(), BlockNumber: big.NewInt(20), Status: types.ReceiptStatusSuccessful, + }, nil).Once() + s.client = client + for range 2 { + joinEpoch, _, err := s.progressTournamentResult(t.Context(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.Empty(t, repo.Calls) + require.Empty(t, consensus.Calls) + } + // The chain advanced while the old join was pending. The next snapshot, + // not the old transaction's epoch, supplies the new join decision. + joinEpoch, _, err := s.progressTournamentResult(t.Context(), app, 20, 20) + require.NoError(t, err) + require.Same(t, epoch, joinEpoch) + client.AssertExpectations(t) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} diff --git a/internal/prt/prt.go b/internal/prt/prt.go index a8ba3d5e1..149f17ba5 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -492,246 +492,6 @@ func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentB return nil } -func (s *Service) trySettle(ctx context.Context, app *Application, mostRecentBlock uint64) error { - if _, exist := s.currentEpochIndex[app.ID]; !exist { - s.currentEpochIndex[app.ID] = 0 - } - currentEpochIndex := s.currentEpochIndex[app.ID] - - if tx, joinTxIsInFlight := s.joinInFlight[app.ID]; joinTxIsInFlight { - s.Logger.Debug("Waiting for join tournament transaction to be mined", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - return nil // wait for join to be mined before settling - } - - if tx, settleTxIsInFlight := s.settleInFlight[app.ID]; settleTxIsInFlight { - _, isPending, err := s.client.TransactionByHash(ctx, *tx) - if err != nil { - s.Logger.Error("failed to fetch last settle transaction status", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx, "error", err) - return err - } - if isPending { - s.Logger.Debug("Previous settle transaction is still pending", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - return nil - } - s.Logger.Debug("Previous settle transaction has been mined", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - delete(s.settleInFlight, app.ID) - // Return so that the next tick's checkEpochs syncs the EpochSealed - // event before we re-check CanSettle. Without this, a stale - // mostRecentBlock snapshot could cause CanSettle to return true - // and trigger a duplicate Settle that reverts on-chain. - return nil - } - - consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) - if err != nil { - s.Logger.Error("failed to bind dave consensus contract", "application", app.Name, - "consensus_address", app.IConsensusAddress.String(), "error", err) - return err - } - - callOpts := &bind.CallOpts{ - Context: ctx, - BlockNumber: new(big.Int).SetUint64(mostRecentBlock), - } - - result, err := consensus.CanSettle(callOpts) - if err != nil { - s.Logger.Error("failed to call CanSettle on DaveConsensus", "application", app.Name, - "consensus", app.IConsensusAddress.String(), "error", err) - return err - } - - currentEpochIndex = result.EpochNumber.Uint64() - s.currentEpochIndex[app.ID] = currentEpochIndex - - if !result.IsFinished { - s.Logger.Debug("Epoch root tournament has not finished yet. Skipping Settle", - "application", app.Name, "epoch_index", currentEpochIndex) - return nil // nothing to do - } - - epoch, err := s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), currentEpochIndex) - if err != nil { - s.Logger.Error("failed to list epochs", "application", app.Name, "error", err) - return err - } - if epoch == nil || epoch.Status != EpochStatus_ClaimComputed { - s.Logger.Info("Application sync has not finished. Skipping Settle", "application", app.Name, - "epoch_index", currentEpochIndex) - return nil // nothing to do - } - - if epoch.TxBufferDataBlock == nil || epoch.TxBufferProof == nil { - return s.setApplicationCorrupted(ctx, app, - "epoch %d has missing required fields for settlement", epoch.Index) - } - - // Check on-chain if the epoch was already settled (e.g., after a node - // restart where settleInFlight was lost). CanSettle only checks if - // the tournament has finished, not if settlement was already performed. - alreadySettled, err := consensus.IsEpochSettled(callOpts, currentEpochIndex) - if err != nil { - s.Logger.Error("failed to check if epoch is already settled", "application", app.Name, - "epoch_index", currentEpochIndex, "error", err) - return err - } - if alreadySettled { - s.Logger.Info("Epoch already settled on-chain, waiting for event sync", - "application", app.Name, "epoch_index", currentEpochIndex) - return nil - } - - s.Logger.Info("Sending Settle transaction", "application", app.Name, "epoch_index", epoch.Index, - "outputs_merkle_root", epoch.TxBufferDataBlock.String()) - - if s.txOptsFactory == nil { - return fmt.Errorf("txOpts is required for settlement") - } - txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) - defer cancel() - txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) - if err != nil { - return fmt.Errorf("creating transaction options for settlement: %w", err) - } - tx, err := consensus.Settle(txOpts, result.EpochNumber, - *epoch.TxBufferDataBlock, hashSliceToByteSlice(epoch.TxBufferProof)) - if err != nil { - return s.handleSettleRevert(ctx, app, result.EpochNumber.Uint64(), err) - } - settleTx := tx.Hash() - s.settleInFlight[app.ID] = &settleTx - - return nil -} - -// handleSettleRevert classifies a Settle error and performs the matching -// state change. The known DaveConsensus reverts: -// -// - IncorrectEpochNumber: carries (received, actual). received < actual -// means the epoch was already settled — after a restart when the -// IsEpochSettled pre-check used a slightly stale block number, or -// another entity settled concurrently; wait for event sync. -// received > actual means the local epoch index is ahead of the chain -// (wrong consensus address or corrupted local state) — FAILED, since -// waiting would stall silently forever. -// - TournamentNotFinishedYet: CanSettle returned true at this tick's pinned -// block, but the provider simulated the call against different state. -// Transient; retry next tick. -// - InvalidOutputsMerkleRootProofSize / InvalidOutputsMerkleRootProof: the -// locally stored outputs merkle proof does not prove the outputs root -// against the settled machine state — local data corruption; CORRUPTED. -// - ApplicationForeclosed: retry while the EVM reader records the -// foreclosure marker (settle runs the same foreclosure probe as the -// IConsensus claim methods). -// - ApplicationNotDeployed / ApplicationReverted / -// IllformedApplicationReturnData: the foreclosure probe failed — wrong -// application address, or a broken/adversarial application contract; -// FAILED, with the application's revert data preserved in the reason. -// -// JSON-RPC "nonce too low" broadcast rejections retry next tick; unknown -// errors are returned to the caller unchanged, with the decoded revert name -// in the log when one of the known ABIs declares it. -func (s *Service) handleSettleRevert(ctx context.Context, app *Application, epochNumber uint64, err error) error { - switch { - case isDaveConsensusError(err, "IncorrectEpochNumber"): - if received, actual, ok := decodeIncorrectEpochNumber(err); ok && - received.Cmp(actual) > 0 { - return s.setApplicationFailed(ctx, app, - "Settle reverted with IncorrectEpochNumber: the node tried to "+ - "settle epoch %s but the chain expects epoch %s — the local "+ - "epoch index is ahead of the DaveConsensus contract. Verify "+ - "the consensus address configuration and local state before "+ - "re-enabling.", - received, actual) - } - s.Logger.Info( - "Epoch already settled on-chain (detected via revert), "+ - "waiting for event sync", - "application", app.Name, - "epoch_index", epochNumber) - return nil - - case isDaveConsensusError(err, "TournamentNotFinishedYet"): - s.Logger.Warn( - "Settle reverted with TournamentNotFinishedYet; the provider's "+ - "state may lag this tick's CanSettle read, retrying next tick", - "application", app.Name, - "epoch_index", epochNumber) - return nil - - case isDaveConsensusError(err, "InvalidOutputsMerkleRootProofSize"): - return s.setApplicationCorrupted(ctx, app, - "Settle reverted with InvalidOutputsMerkleRootProofSize for epoch %d — "+ - "the outputs merkle proof stored locally has the wrong length for "+ - "the settled machine state.", - epochNumber) - - case isDaveConsensusError(err, "InvalidOutputsMerkleRootProof"): - return s.setApplicationCorrupted(ctx, app, - "Settle reverted with InvalidOutputsMerkleRootProof for epoch %d — "+ - "the outputs merkle proof stored locally does not prove the outputs "+ - "root against the settled machine state.", - epochNumber) - - case isDaveConsensusError(err, "ApplicationForeclosed"): - s.Logger.Warn("Settle reverted with ApplicationForeclosed; "+ - "awaiting Foreclosure observer to record the foreclosure marker", - "application", app.Name, - "epoch_index", epochNumber) - return nil - - case isDaveConsensusError(err, "ApplicationNotDeployed"): - return s.setApplicationFailed(ctx, app, - "Settle reverted with ApplicationNotDeployed for epoch %d: no "+ - "contract code exists at the application address bound to the "+ - "DaveConsensus contract. Verify the application address and "+ - "that the application contract is deployed on this chain "+ - "before re-enabling.", - epochNumber) - - case isDaveConsensusError(err, "ApplicationReverted"): - return s.setApplicationFailed(ctx, app, - "Settle reverted with ApplicationReverted for epoch %d: the "+ - "application contract reverted when the consensus contract "+ - "queried it. Verify the deployed contract and its compatibility "+ - "with the consensus contract before re-enabling.%s", - epochNumber, daveAppReturnDataSuffix(err, "ApplicationReverted")) - - case isDaveConsensusError(err, "IllformedApplicationReturnData"): - return s.setApplicationFailed(ctx, app, - "Settle reverted with IllformedApplicationReturnData for epoch %d: "+ - "the application contract returned malformed data when the "+ - "consensus contract queried it. Verify the deployed contract "+ - "and its compatibility with the consensus contract before "+ - "re-enabling.%s", - epochNumber, daveAppReturnDataSuffix(err, "IllformedApplicationReturnData")) - - case ethutil.IsNonceTooLowError(err): - // Transient broadcast race: the chain has already mined a tx with - // this EOA's nonce, so this attempt is rejected before execution. - // Most commonly hit straddling a node restart — the prior process - // broadcast Settle (or some other tx) that landed, but the - // post-restart PendingNonceAt has not yet caught up. The next tick's - // IsEpochSettled check reads chain state at a fresh block and - // short-circuits if our prior Settle actually mined; otherwise a - // new broadcast goes out with a fresh nonce. - s.Logger.Info( - "Settle broadcast rejected with 'nonce too low'; "+ - "deferring to the next tick's IsEpochSettled reconciliation", - "application", app.Name, - "epoch_index", epochNumber) - return nil - } - s.Logger.Error("failed to send Settle transaction", "application", app.Name, - "epoch_index", epochNumber, "error", err, - "decoded_revert", describeKnownRevert(err)) - return err -} - // observeApplicationTournaments indexes chain facts without consulting local // claim readiness or changing application health. The returned roots are also // used by the separate local claim reconciliation after publication. @@ -1185,127 +945,95 @@ func applyTournamentCreationEvent(child *Tournament, event *itournament.ITournam return nil } -func (s *Service) reactToTournament(ctx context.Context, app *Application, mostRecentBlock uint64) error { - currentEpochIndex, exist := s.currentEpochIndex[app.ID] - if !exist { - errMsg := "current epoch index not found for application. Should not happen" - s.Logger.Error(errMsg, "application", app.Name) - return errors.New(errMsg) - } - if tx, settleTxIsInFlight := s.settleInFlight[app.ID]; settleTxIsInFlight { - s.Logger.Debug("Waiting for settle transaction to be mined", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - return nil // wait for settle to be mined - } - - if tx, joinTxIsInFlight := s.joinInFlight[app.ID]; joinTxIsInFlight { - _, isPending, err := s.client.TransactionByHash(ctx, *tx) - if err != nil { - s.Logger.Error("failed to fetch last join tournament transaction status", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx, "error", err) - return err - } - if isPending { - s.Logger.Debug("Previous join tournament transaction is still pending", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - return nil - } - s.Logger.Debug("Previous join tournament transaction has been mined", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - delete(s.joinInFlight, app.ID) - // Return so that the next tick's checkEpochs syncs the CommitmentJoined - // event before we re-check GetCommitment. Without this, a stale - // mostRecentBlock snapshot could cause GetCommitment to return nil - // and trigger a duplicate JoinTournament that reverts on-chain. - return nil - } - - epoch, err := s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), currentEpochIndex) - if err != nil { - s.Logger.Error("failed to list epochs", "application", app.Name, "error", err) - return err - } +// reactToTournament reports true only when the commitment is already joined. +// A deferred or newly broadcast join must not permit a new bond refund this tick. +func (s *Service) reactToTournament(ctx context.Context, app *Application, epoch *Epoch, mostRecentBlock uint64) (bool, error) { if epoch == nil || epoch.Status != EpochStatus_ClaimComputed { - s.Logger.Debug("Application sync has not finished. Skipping join tournament", "application", app.Name, - "epoch_index", currentEpochIndex) - return nil // nothing to do + s.Logger.Debug("Application sync has not finished. Skipping join tournament", "application", app.Name) + return false, nil } if epoch.TournamentAddress == nil || epoch.Commitment == nil || epoch.MachineHash == nil || epoch.CommitmentProof == nil { - return s.setApplicationCorrupted(ctx, app, + return false, s.setApplicationCorrupted(ctx, app, "epoch %d has missing required fields for tournament reaction", epoch.Index) } commitment, err := s.repository.GetCommitment(ctx, app.IApplicationAddress.Hex(), epoch.Index, epoch.TournamentAddress.Hex(), epoch.Commitment.String()) if err != nil { - s.Logger.Error("failed to get commitment from repository", "application", app.Name, - "epoch_index", currentEpochIndex, "tournament", epoch.TournamentAddress.Hex(), - "commitment", epoch.Commitment.Hex(), "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to get commitment from repository", err, "application", app.Name, + "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex(), + "commitment", epoch.Commitment.Hex()) + return false, err } if commitment != nil { s.Logger.Debug("Commitment already joined. Skipping JoinTournament", "application", app.Name, - "epoch_index", currentEpochIndex, "tournament", epoch.TournamentAddress.Hex(), "commitment", epoch.Commitment.Hex()) - return nil + "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex(), "commitment", epoch.Commitment.Hex()) + return true, nil } tournamentAdapter, err := s.adapterFactory.CreateTournamentAdapter(*epoch.TournamentAddress) if err != nil { s.Logger.Error("failed to create tournament adapter", "application", app.Name, "tournament", epoch.TournamentAddress.String(), "error", err) - return err + return false, err } - callOpts := &bind.CallOpts{ - Context: ctx, - BlockNumber: new(big.Int).SetUint64(mostRecentBlock), - } + callOpts := pinnedCallOpts(ctx, mostRecentBlock) // Check on-chain if the commitment was already joined (e.g., after a node - // restart where joinInFlight was lost and the DB event sync hasn't caught up). - alreadyJoined, err := tournamentAdapter.IsCommitmentJoined(callOpts, *epoch.Commitment) + // restart where the pending transaction was lost and the DB event sync hasn't caught up). + commitmentStanding, err := tournamentAdapter.CommitmentStanding(callOpts, *epoch.Commitment) if err != nil { - s.Logger.Error("failed to check commitment on-chain", "application", app.Name, - "epoch_index", currentEpochIndex, "tournament", epoch.TournamentAddress.Hex(), - "commitment", epoch.Commitment.Hex(), "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to check commitment on-chain", err, "application", app.Name, + "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex(), + "commitment", epoch.Commitment.Hex()) + return false, err } - if alreadyJoined { + if commitmentStanding.Joined { + if err := s.validateJoinedCommitment(ctx, app, epoch, commitmentStanding); err != nil { + return false, err + } s.Logger.Info("Commitment already joined on-chain, waiting for event sync", - "application", app.Name, "epoch_index", currentEpochIndex, + "application", app.Name, "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex(), "commitment", epoch.Commitment.Hex()) - return nil + return true, nil } - bondValue, err := tournamentAdapter.BondValue(callOpts) + descriptor, err := tournamentAdapter.Descriptor(callOpts) if err != nil { - s.Logger.Error("failed to fetch tournament bond value", "application", app.Name, - "epoch_index", currentEpochIndex, "tournament", epoch.TournamentAddress.Hex(), - "error", err) - return err + return false, fmt.Errorf("reading root tournament geometry before joining: %w", err) + } + if descriptor.Level != uint64(RootLevel) || descriptor.Height != Log2EpochComputationHashLeafCount { + return false, s.setApplicationFailed(ctx, app, + "Cannot join tournament %s: root level %d and commitment height %d are required; got level %d and height %d. "+ + "Check the tournament factory and node versions before re-enabling.", + epoch.TournamentAddress, RootLevel, Log2EpochComputationHashLeafCount, descriptor.Level, descriptor.Height) } - if s.txOptsFactory == nil { - return fmt.Errorf("txOpts is required for joining tournament") + bondValue, err := tournamentAdapter.BondValue(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament bond value", err, "application", app.Name, + "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex()) + return false, err } + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) defer cancel() txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) if err != nil { - return fmt.Errorf("creating transaction options for joining tournament: %w", err) + return false, fmt.Errorf("creating transaction options for joining tournament: %w", err) } txOptsWithValue := *txOpts txOptsWithValue.Value = bondValue - // FIXME move this to constants - idx := uint64(1<<48) - 1 //nolint: mnd + idx := uint64(1< actual means the local epoch index is ahead of the chain. func decodeIncorrectEpochNumber(err error) (received, actual *big.Int, ok bool) { values, ok := ethutil.UnpackRevert(err, idaveconsensus.IDaveConsensusMetaData, "IncorrectEpochNumber") @@ -1490,23 +1262,6 @@ func decodeIncorrectEpochNumber(err error) (received, actual *big.Int, ok bool) return received, actual, true } -// daveAppReturnDataSuffix formats the application-provided returndata carried -// by ApplicationReverted and IllformedApplicationReturnData reverts as a -// reason suffix. The bytes are controlled by the application contract, so -// they are hex-encoded to keep them inert in logs and in the database. -// Returns "" when the revert data cannot be decoded. -func daveAppReturnDataSuffix(err error, name string) string { - values, ok := ethutil.UnpackRevert(err, idaveconsensus.IDaveConsensusMetaData, name) - if !ok || len(values) < 2 { - return "" - } - data, ok := values[1].([]byte) - if !ok { - return "" - } - return fmt.Sprintf(" Application return data: 0x%x.", data) -} - // describeKnownRevert renders the revert carried by err against the ABIs this // service interacts with, for the unknown-error log lines. Returns "" when // nothing matches. diff --git a/internal/prt/reverts_test.go b/internal/prt/reverts_test.go index e5f49b3f1..960d0f4a2 100644 --- a/internal/prt/reverts_test.go +++ b/internal/prt/reverts_test.go @@ -15,12 +15,20 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +const ( + executionRevertedError = "execution reverted" + applicationForeclosed = "ApplicationForeclosed" +) + // daveConsensusRevertError creates a typed IDaveConsensus revert carrying only // the 4-byte selector — sufficient for the classifiers to match by name. func daveConsensusRevertError(name string) error { @@ -32,7 +40,7 @@ func daveConsensusRevertError(name string) error { if !ok { panic(fmt.Sprintf("unknown IDaveConsensus error: %s", name)) } - return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", abiErr.ID[:4])} + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", abiErr.ID[:4])} } // daveRevertWithArgs creates a typed IDaveConsensus revert carrying the given @@ -51,7 +59,7 @@ func daveRevertWithArgs(name string, args ...any) error { panic(err) } payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) - return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", payload)} + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", payload)} } // tournamentRevertError creates a typed ITournament revert carrying only the @@ -65,7 +73,7 @@ func tournamentRevertError(name string) error { if !ok { panic(fmt.Sprintf("unknown ITournament error: %s", name)) } - return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", abiErr.ID[:4])} + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", abiErr.ID[:4])} } func prtRevertTestApp() *model.Application { @@ -73,6 +81,7 @@ func prtRevertTestApp() *model.Application { ID: 7, Name: "prt-app", IApplicationAddress: common.BigToAddress(common.Big1), + IConsensusAddress: common.HexToAddress("0x100"), ConsensusType: model.Consensus_PRT, Status: model.ApplicationStatus_OK, Enabled: true, @@ -82,10 +91,12 @@ func prtRevertTestApp() *model.Application { func prtRevertTestEpoch() *model.Epoch { tournament := common.BigToAddress(common.Big2) commitment := common.HexToHash("0xabcd") + machineHash := common.HexToHash("0x1234") return &model.Epoch{ Index: 3, TournamentAddress: &tournament, Commitment: &commitment, + MachineHash: &machineHash, } } @@ -104,142 +115,161 @@ func reasonContains(substrings ...string) func(*string) bool { } } -// TestHandleSettleRevert covers the Settle revert classification: already -// settled and transient conditions retry silently, bad local proofs mark the -// app CORRUPTED, a consensus/application binding mismatch marks it FAILED, -// and unknown errors propagate unchanged. -func TestHandleSettleRevert(t *testing.T) { - const epochNumber = uint64(3) +func TestHandleStageTournamentResultRevert(t *testing.T) { + epoch := prtRevertTestEpoch() - t.Run("IncorrectEpochNumber_undecodable_waitsForEventSync", func(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - daveConsensusRevertError("IncorrectEpochNumber")) - assert.NoError(t, err) - }) + for _, name := range []string{ + "TournamentResultAlreadyStaged", + "TournamentNotFinishedYet", + applicationForeclosed, + } { + t.Run(name+"_waitsForSynchronization", func(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + err := s.handleStageTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, daveConsensusRevertError(name)) + assert.NoError(t, err) + }) + } + + for _, name := range []string{ + "InvalidSiblingsArrayLength", + "InvalidMachineMerkleProof", + "InvalidPostEpochMachineIflagsYRegister", + "InvalidPostEpochMachineHtifTohostRegister", + } { + t.Run(name+"_setsFailed", func(t *testing.T) { + s, r := newPRTServiceMock() + app := prtRevertTestApp() + testEpoch := epoch + if name == "InvalidMachineMerkleProof" { + testEpoch = resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(testEpoch, false) + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + consensus := &daveConsensusAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(20)) + consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + s.client = client + s.defaultBlock = model.DefaultBlock_Finalized + s.adapterFactory = factory + defer client.AssertExpectations(t) + defer consensus.AssertExpectations(t) + defer factory.AssertExpectations(t) + } + guidance := "Check proof serialization" + if name == "InvalidPostEpochMachineIflagsYRegister" || name == "InvalidPostEpochMachineHtifTohostRegister" { + guidance = "The proven post-epoch machine state cannot finalize" + } + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(reasonContains("StageTournamentResult", name, "epoch 3", guidance))). + Return(nil).Once() + assert.NoError(t, s.handleStageTournamentResultRevert( + context.Background(), app, testEpoch, daveConsensusRevertError(name))) + r.AssertExpectations(t) + }) + } - t.Run("IncorrectEpochNumber_behind_waitsForEventSync", func(t *testing.T) { + t.Run("TournamentFailedNoWinner_waitsForConfirmation", func(t *testing.T) { s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - // received 3 < actual 5: the chain settled past us — already settled. - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(3), big.NewInt(5))) - assert.NoError(t, err) + app := prtRevertTestApp() + assert.NoError(t, s.handleStageTournamentResultRevert( + context.Background(), app, epoch, tournamentRevertError("TournamentFailedNoWinner"))) + assert.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) }) - t.Run("IncorrectEpochNumber_ahead_setsFailed", func(t *testing.T) { + t.Run("IncorrectEpochNumber_behind_waitsForSynchronization", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) - app := prtRevertTestApp() - // received 7 > actual 5: local epoch index is ahead of the chain — - // waiting for event sync would stall silently forever. - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, - mock.MatchedBy(reasonContains( - "IncorrectEpochNumber", "epoch 7", "epoch 5", "ahead", "before re-enabling"))). - Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(7), big.NewInt(5))) - assert.NoError(t, err) + assert.NoError(t, s.handleStageTournamentResultRevert(context.Background(), prtRevertTestApp(), epoch, + daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(3), big.NewInt(5)))) }) - t.Run("TournamentNotFinishedYet_retries", func(t *testing.T) { + t.Run("IncorrectEpochNumber_ahead_retriesWithoutStatusChange", func(t *testing.T) { s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - daveConsensusRevertError("TournamentNotFinishedYet")) - assert.NoError(t, err, "a CanSettle/simulation race must retry, not surface an error") + app := prtRevertTestApp() + boom := daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(7), big.NewInt(5)) + assert.ErrorIs(t, s.handleStageTournamentResultRevert(context.Background(), app, epoch, boom), boom) + assert.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) }) - for _, revertName := range []string{ - "InvalidOutputsMerkleRootProofSize", - "InvalidOutputsMerkleRootProof", - } { - t.Run(revertName+"_setsCorrupted", func(t *testing.T) { + for _, name := range []string{"ApplicationReverted", "IllformedApplicationReturnData"} { + t.Run(name+"_setsFailedWithReturnData", func(t *testing.T) { s, r := newPRTServiceMock() - defer r.AssertExpectations(t) app := prtRevertTestApp() - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, - mock.MatchedBy(reasonContains("Settle reverted with "+revertName, "epoch 3"))). + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(reasonContains("StageTournamentResult", name, "0xdead"))). Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveConsensusRevertError(revertName)) - assert.Error(t, err, "CORRUPTED is terminal; the handler must return the reason error") + assert.NoError(t, s.handleStageTournamentResultRevert(context.Background(), app, epoch, + daveRevertWithArgs(name, common.BigToAddress(common.Big1), []byte{0xde, 0xad}))) + assert.Equal(t, model.ApplicationStatus_Failed, app.Status) + r.AssertExpectations(t) }) } - t.Run("ApplicationForeclosed_retries", func(t *testing.T) { + t.Run("NonceTooLow_waitsForSynchronization", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - daveConsensusRevertError("ApplicationForeclosed")) - assert.NoError(t, err, "must retry while the EVM reader records the foreclosure marker") + assert.NoError(t, s.handleStageTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, errors.New("nonce too low"))) }) - t.Run("ApplicationNotDeployed_setsFailed", func(t *testing.T) { + t.Run("unknown_propagates", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) - app := prtRevertTestApp() - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, - mock.MatchedBy(reasonContains( - "Settle reverted with ApplicationNotDeployed", "epoch 3", "before re-enabling"))). - Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveConsensusRevertError("ApplicationNotDeployed")) - // SetFailedf returns nil on success; the FAILED write itself is - // asserted by the mock expectation above. - assert.NoError(t, err) + boom := errors.New("boom") + assert.ErrorIs(t, s.handleStageTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, boom), boom) }) +} - t.Run("ApplicationReverted_setsFailedWithReturnData", func(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - app := prtRevertTestApp() - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, - mock.MatchedBy(reasonContains( - "Settle reverted with ApplicationReverted", "epoch 3", - "Application return data: 0xdead", "before re-enabling"))). - Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveRevertWithArgs("ApplicationReverted", - common.BigToAddress(common.Big1), []byte{0xde, 0xad})) - assert.NoError(t, err) - }) +func TestHandleAcceptTournamentResultRevert(t *testing.T) { + epoch := prtRevertTestEpoch() + for _, name := range []string{ + "TournamentResultNotStaged", + "ClaimStagingPeriodNotOverYet", + applicationForeclosed, + } { + t.Run(name+"_waitsForSynchronization", func(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + assert.NoError(t, s.handleAcceptTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, daveConsensusRevertError(name))) + }) + } - t.Run("IllformedApplicationReturnData_setsFailed", func(t *testing.T) { + t.Run("ApplicationNotDeployed_setsFailed", func(t *testing.T) { s, r := newPRTServiceMock() - defer r.AssertExpectations(t) app := prtRevertTestApp() r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, - mock.MatchedBy(reasonContains( - "Settle reverted with IllformedApplicationReturnData", "epoch 3", - "before re-enabling"))). + mock.MatchedBy(reasonContains("AcceptStagedTournamentResult", "ApplicationNotDeployed"))). Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveConsensusRevertError("IllformedApplicationReturnData")) - assert.NoError(t, err) - }) - - t.Run("NonceTooLow_retries", func(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - errors.New("nonce too low")) - assert.NoError(t, err) + assert.NoError(t, s.handleAcceptTournamentResultRevert( + context.Background(), app, epoch, daveConsensusRevertError("ApplicationNotDeployed"))) + r.AssertExpectations(t) }) t.Run("unknown_propagates", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) - boom := errors.New("boom") - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, boom) - assert.Equal(t, boom, err) + boom := errors.New("factory failed") + assert.ErrorIs(t, s.handleAcceptTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, boom), boom) }) } // TestHandleJoinTournamentRevert covers the JoinTournament revert // classification: an already-joined commitment retries silently (whether -// detected via ClockAlreadyInitialized or via the IsCommitmentJoined re-check +// detected via ClockAlreadyInitialized or via the CommitmentStanding re-check // behind a window revert), a genuinely missed join window marks the app // FAILED, bad local commitment proofs mark it CORRUPTED, and unknown errors // propagate. @@ -253,7 +283,7 @@ func TestHandleJoinTournamentRevert(t *testing.T) { }) for _, revertName := range []string{"TournamentIsClosed", "TournamentIsFinished"} { - t.Run(revertName+"_notJoined_setsFailed", func(t *testing.T) { + t.Run(revertName+"_confirmedNotJoined_setsFailed", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) app := prtRevertTestApp() @@ -266,11 +296,51 @@ func TestHandleJoinTournamentRevert(t *testing.T) { "before re-enabling"))). Return(nil).Once() adapter := &tournamentAdapterMock{} - adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(false, nil).Once() + adapter.On("CommitmentStanding", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts != nil && opts.BlockNumber == nil + }), [32]byte(*epoch.Commitment)). + Return(CommitmentStanding{}, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(20)) + adapter.On("Standing", opts). + Return(TournamentStanding{State: model.TournamentStandingRootFailed, FinishedAt: 19}, nil).Once() + adapter.On("CommitmentStanding", opts, [32]byte(*epoch.Commitment)). + Return(CommitmentStanding{}, nil).Once() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + s.client = client + s.defaultBlock = model.DefaultBlock_Finalized err := s.handleJoinTournamentRevert(context.Background(), app, epoch, adapter, tournamentRevertError(revertName)) assert.NoError(t, err) + assert.Equal(t, model.ApplicationStatus_Failed, app.Status) + adapter.AssertExpectations(t) + client.AssertExpectations(t) + }) + + t.Run(revertName+"_configuredWindowOpen_waitsForConfirmation", func(t *testing.T) { + s, r := newPRTServiceMock() + app := prtRevertTestApp() + epoch := prtRevertTestEpoch() + adapter := &tournamentAdapterMock{} + adapter.On("CommitmentStanding", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts != nil && opts.BlockNumber == nil + }), [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, nil).Once() + adapter.On("Standing", mock.MatchedBy(resultCallOptsAtBlock(20))). + Return(TournamentStanding{State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true}, nil).Once() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + s.client = client + s.defaultBlock = model.DefaultBlock_Finalized + + assert.NoError(t, s.handleJoinTournamentRevert(context.Background(), app, epoch, + adapter, tournamentRevertError(revertName))) + assert.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) adapter.AssertExpectations(t) + client.AssertExpectations(t) }) } @@ -283,7 +353,8 @@ func TestHandleJoinTournamentRevert(t *testing.T) { defer r.AssertExpectations(t) epoch := prtRevertTestEpoch() adapter := &tournamentAdapterMock{} - adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(true, nil).Once() + adapter.On("CommitmentStanding", mock.Anything, [32]byte(*epoch.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: *epoch.MachineHash}, nil).Once() err := s.handleJoinTournamentRevert(context.Background(), prtRevertTestApp(), epoch, adapter, tournamentRevertError("TournamentIsClosed")) assert.NoError(t, err, "an already-joined commitment must not mark the app FAILED") @@ -295,7 +366,8 @@ func TestHandleJoinTournamentRevert(t *testing.T) { defer r.AssertExpectations(t) epoch := prtRevertTestEpoch() adapter := &tournamentAdapterMock{} - adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(false, errors.New("rpc down")).Once() + adapter.On("CommitmentStanding", mock.Anything, [32]byte(*epoch.Commitment)). + Return(CommitmentStanding{}, errors.New("rpc down")).Once() boom := tournamentRevertError("TournamentIsClosed") err := s.handleJoinTournamentRevert(context.Background(), prtRevertTestApp(), epoch, adapter, boom) diff --git a/internal/prt/typed_errors_test.go b/internal/prt/typed_errors_test.go deleted file mode 100644 index 2c08187d7..000000000 --- a/internal/prt/typed_errors_test.go +++ /dev/null @@ -1,118 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package prt - -import ( - "testing" - - "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" - "github.com/cartesi/rollups-node/pkg/contracts/itournament" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestPRTTypedErrorNamesExistInABI walks every typed-error name the PRT -// package references and asserts it exists in the appropriate ABI metadata. -// Catches silent regressions when contracts rename errors (e.g. v3 renamed -// ClockNotTimedOut → NeitherClockHasTimedOut and BothClocksHaveNotTimedOut -// → AtLeastOneClockHasNotTimedOut — neither old name appears in PRT today, -// but the same shape of rename can recur). -// -// Maintenance: add an entry here every time PRT starts referencing a new -// typed error by name (via ethutil.IsCustomError or a hardcoded selector). -// Mirror entries are kept across both ABIs where appropriate. -func TestPRTTypedErrorNamesExistInABI(t *testing.T) { - tournamentABI, err := itournament.ITournamentMetaData.GetAbi() - require.NoError(t, err) - daveABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() - require.NoError(t, err) - - cases := []struct { - name string - abi map[string]struct { - present bool - } - // where: brief locator pointing at the source reference, for - // failure messages. - where string - }{ - // itournament_adapter.go: Result() tolerates ArbitrationResult - // reverting with TournamentFailedNoWinner. - {name: "TournamentFailedNoWinner", where: "itournament_adapter.go (Result)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - - // prt.go: handleJoinTournamentRevert classifies JoinTournament reverts. - {name: "ClockAlreadyInitialized", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - {name: "TournamentIsClosed", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - {name: "TournamentIsFinished", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - {name: "CommitmentStateMismatch", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - {name: "CommitmentProofWrongSize", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - - // prt.go: handleSettleRevert classifies Settle reverts. - {name: "IncorrectEpochNumber", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "TournamentNotFinishedYet", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "InvalidOutputsMerkleRootProofSize", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "InvalidOutputsMerkleRootProof", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "ApplicationForeclosed", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "ApplicationNotDeployed", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "ApplicationReverted", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "IllformedApplicationReturnData", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - for which := range tc.abi { - var ok bool - switch which { - case "itournament": - _, ok = tournamentABI.Errors[tc.name] - case "idaveconsensus": - _, ok = daveABI.Errors[tc.name] - default: - t.Fatalf("unknown ABI bucket %q for %s", which, tc.name) - } - assert.True(t, ok, - "%s missing from %s ABI (referenced by %s) — check whether the contract renamed it", - tc.name, which, tc.where) - } - }) - } -} - -// TestPRTHasNoReferencesToRenamedErrors locks against accidental reintroduction -// of v2 error names that v3 renamed. If a future maintainer copies a code -// fragment from a v2 branch that references one of these, the existence check -// in TestPRTTypedErrorNamesExistInABI would still catch it — but this test -// fails earlier with a more direct message. -func TestPRTHasNoReferencesToRenamedErrors(t *testing.T) { - tournamentABI, err := itournament.ITournamentMetaData.GetAbi() - require.NoError(t, err) - - v3Renames := map[string]string{ - "ClockNotTimedOut": "NeitherClockHasTimedOut", - "BothClocksHaveNotTimedOut": "AtLeastOneClockHasNotTimedOut", - } - for oldName, newName := range v3Renames { - _, oldExists := tournamentABI.Errors[oldName] - assert.False(t, oldExists, - "v2 error %q unexpectedly present in v3 ITournament ABI", oldName) - _, newExists := tournamentABI.Errors[newName] - assert.True(t, newExists, - "v3 renamed error %q missing from ITournament ABI (was %q in v2)", - newName, oldName) - } -} From 4d88c8684c17d60920c6e4d8561bfbd1e726ac28 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:19 -0300 Subject: [PATCH 37/41] feat(prt): recover node-owned root bonds Track node-owned root-bond recovery candidates in memory. Before a recovery broadcast, read the on-chain bond disposition, eligible epoch, and payment owner. Retire completed, impossible, or foreign candidates. Reconcile an existing recovery transaction before starting another and respect other pending PRT mutations. Include ownership, disposition, receipt, and foreclosure recovery-operation tests. This is bounded payment maintenance, not a persistent delivery queue. The next commit supplies the final selection rule that gives join, stage, and accept priority over a new recovery broadcast. --- internal/prt/bond.go | 350 ++++++++++++++++++ internal/prt/bond_foreclosure_test.go | 79 ++++ internal/prt/bond_test.go | 497 ++++++++++++++++++++++++++ 3 files changed, 926 insertions(+) create mode 100644 internal/prt/bond.go create mode 100644 internal/prt/bond_foreclosure_test.go create mode 100644 internal/prt/bond_test.go diff --git a/internal/prt/bond.go b/internal/prt/bond.go new file mode 100644 index 000000000..fa452d2a6 --- /dev/null +++ b/internal/prt/bond.go @@ -0,0 +1,350 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +type rootBondRecovery struct { + EpochIndex uint64 + Tournament common.Address + TxHash *common.Hash +} + +func (s *Service) queueRootBondRecovery(appID int64, epochIndex uint64, tournament common.Address) { + for _, candidate := range s.rootBondRecoveries[appID] { + if candidate.EpochIndex == epochIndex && candidate.Tournament == tournament { + return + } + } + s.rootBondRecoveries[appID] = append(s.rootBondRecoveries[appID], &rootBondRecovery{ + EpochIndex: epochIndex, + Tournament: tournament, + }) + s.Logger.Info("Queued root tournament bond recovery candidate; restart clears this in-memory candidate", + "application_id", appID, + "epoch_index", epochIndex, + "tournament", tournament) +} + +func (s *Service) rootBondRecoveryInFlight(appID int64) (*rootBondRecovery, error) { + var found *rootBondRecovery + for _, candidate := range s.rootBondRecoveries[appID] { + if candidate.TxHash == nil { + continue + } + if found != nil { + return nil, fmt.Errorf("application %d has multiple root bond recovery transactions in flight", appID) + } + found = candidate + } + return found, nil +} + +func (s *Service) hasNonRecoveryMutationInFlight(appID int64) bool { + _, pending := s.pendingTransactions[appID] + return pending +} + +func (s *Service) retireRootBondRecovery(appID int64, target *rootBondRecovery) { + candidates := s.rootBondRecoveries[appID] + for i, candidate := range candidates { + if candidate != target { + continue + } + candidates = append(candidates[:i], candidates[i+1:]...) + if len(candidates) == 0 { + delete(s.rootBondRecoveries, appID) + } else { + s.rootBondRecoveries[appID] = candidates + } + return + } +} + +// recoverRootBonds reconciles queued root tournament bonds from oldest to +// newest. It can submit at most one recovery transaction in one call. +func (s *Service) recoverRootBonds(ctx context.Context, app *model.Application, mostRecentBlock uint64) error { + if len(s.rootBondRecoveries[app.ID]) == 0 { + return nil + } + if s.hasNonRecoveryMutationInFlight(app.ID) { + return nil + } + + inFlight, err := s.rootBondRecoveryInFlight(app.ID) + if err != nil { + return err + } + if inFlight != nil { + return s.reconcileRootBondRecoveryTransaction(ctx, app, inFlight, mostRecentBlock) + } + + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + return fmt.Errorf("binding DaveConsensus for root bond recovery: %w", err) + } + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + sealed, err := consensus.GetCurrentSealedEpoch(callOpts) + if err != nil { + return fmt.Errorf("reading current epoch for root bond recovery: %w", err) + } + + for len(s.rootBondRecoveries[app.ID]) > 0 { + candidate := s.rootBondRecoveries[app.ID][0] + // Live apps recover only after acceptance advances the sealed epoch. + // Foreclosure prevents acceptance, but the current root can still + // finish and release its bond. Its disposition and claimer decide below. + if candidate.EpochIndex > sealed.EpochNumber || + (candidate.EpochIndex == sealed.EpochNumber && !app.IsForeclosed()) { + return nil + } + mutated, err := s.recoverRootBond(ctx, app, candidate, callOpts) + if err != nil || mutated { + return err + } + if len(s.rootBondRecoveries[app.ID]) > 0 && s.rootBondRecoveries[app.ID][0] == candidate { + return nil + } + } + return nil +} + +func (s *Service) recoverRootBond( + ctx context.Context, + app *model.Application, + candidate *rootBondRecovery, + callOpts *bind.CallOpts, +) (bool, error) { + tournament, err := s.adapterFactory.CreateTournamentAdapter(candidate.Tournament) + if err != nil { + return false, fmt.Errorf("binding root tournament %s for bond recovery: %w", candidate.Tournament, err) + } + recovery, err := tournament.BondRecovery(callOpts) + if err != nil { + return false, fmt.Errorf("reading root bond recovery for tournament %s: %w", candidate.Tournament, err) + } + + switch recovery.Disposition { + case model.BondDispositionTournamentRunning: + s.Logger.Warn("Root tournament bond is still running", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + return false, nil + case model.BondDispositionNoWinner: + s.Logger.Error("Root tournament bond has no winner and cannot be recovered", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + s.retireRootBondRecovery(app.ID, candidate) + return false, nil + case model.BondDispositionRecovered: + s.Logger.Info("Root tournament bond is recovered", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + s.retireRootBondRecovery(app.ID, candidate) + return false, nil + case model.BondDispositionRecoverable: + if recovery.Claimer != s.txOptsFactory.From() { + s.Logger.Info("Root tournament bond recovery candidate belongs to another claimer; retiring candidate", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "claimer", recovery.Claimer, + "payment", recovery.Payment) + s.retireRootBondRecovery(app.ID, candidate) + return false, nil + } + if err := s.broadcastRootBondRecovery(ctx, app, candidate, tournament, recovery); err != nil { + return false, err + } + return true, nil + default: + return false, fmt.Errorf("root tournament %s returned unknown bond disposition %s", + candidate.Tournament, recovery.Disposition) + } +} + +func (s *Service) broadcastRootBondRecovery( + ctx context.Context, + app *model.Application, + candidate *rootBondRecovery, + tournament TournamentAdapter, + recovery BondRecovery, +) error { + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) + defer cancel() + txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) + if err != nil { + return fmt.Errorf("creating transaction options to recover epoch %d root bond: %w", candidate.EpochIndex, err) + } + tx, err := tournament.TryRecoveringBond(txOpts) + if err != nil { + return s.handleRootBondRecoveryRevert(app, candidate, err) + } + if tx == nil { + return errors.New("root bond recovery returned a nil transaction") + } + txHash := tx.Hash() + candidate.TxHash = &txHash + s.Logger.Info("Sent root tournament bond recovery transaction", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "claimer", recovery.Claimer, + "payment", recovery.Payment, + "tx", txHash) + return nil +} + +func (s *Service) handleRootBondRecoveryRevert( + app *model.Application, + candidate *rootBondRecovery, + err error, +) error { + switch { + case isTournamentError(err, "TournamentNotFinished"): + s.Logger.Warn("Root bond recovery observed a running tournament; waiting for a fresh view", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + return nil + case isTournamentError(err, "NoWinner"): + s.Logger.Warn("Root bond recovery observed no winner; waiting for a fresh view", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + return nil + case ethutil.IsNonceTooLowError(err): + s.Logger.Info("Root bond recovery nonce is too low; waiting for chain reconciliation", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + return nil + default: + return err + } +} + +func (s *Service) reconcileRootBondRecoveryTransaction( + ctx context.Context, + app *model.Application, + candidate *rootBondRecovery, + mostRecentBlock uint64, +) error { + txHash := *candidate.TxHash + _, pending, err := s.client.TransactionByHash(ctx, txHash) + if err != nil { + return fmt.Errorf("checking root bond recovery transaction %s: %w", txHash, err) + } + if pending { + return nil + } + receipt, err := s.client.TransactionReceipt(ctx, txHash) + if err != nil { + return fmt.Errorf("fetching root bond recovery receipt %s: %w", txHash, err) + } + if receipt == nil || receipt.BlockNumber == nil || receipt.BlockNumber.Sign() < 0 { + return fmt.Errorf("root bond recovery transaction %s has an invalid receipt block", txHash) + } + if receipt.TxHash != txHash { + return fmt.Errorf("root bond recovery receipt hash %s differs from transaction %s", receipt.TxHash, txHash) + } + if receipt.Status != types.ReceiptStatusFailed && receipt.Status != types.ReceiptStatusSuccessful { + return fmt.Errorf("root bond recovery transaction %s has invalid receipt status %d", txHash, receipt.Status) + } + callBlock := new(big.Int).SetUint64(mostRecentBlock) + if receipt.BlockNumber.Cmp(callBlock) > 0 { + callBlock.Set(receipt.BlockNumber) + } + + tournament, err := s.adapterFactory.CreateTournamentAdapter(candidate.Tournament) + if err != nil { + return fmt.Errorf("binding root tournament %s after recovery transaction: %w", candidate.Tournament, err) + } + recovery, err := tournament.BondRecovery(&bind.CallOpts{Context: ctx, BlockNumber: callBlock}) + if err != nil { + if errors.Is(err, errInvalidBondRecovery) { + candidate.TxHash = nil + } + return fmt.Errorf("reading root bond state after transaction %s: %w", txHash, err) + } + + switch recovery.Disposition { + case model.BondDispositionRecovered: + s.Logger.Info("Root tournament bond recovery is confirmed", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "status", receipt.Status) + s.retireRootBondRecovery(app.ID, candidate) + return nil + case model.BondDispositionRecoverable: + if recovery.Claimer != s.txOptsFactory.From() { + s.Logger.Warn("Root tournament bond now belongs to another claimer; retiring recovery", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "status", receipt.Status, + "claimer", recovery.Claimer, + "payment", recovery.Payment) + s.retireRootBondRecovery(app.ID, candidate) + return nil + } + if receipt.Status == types.ReceiptStatusFailed { + candidate.TxHash = nil + s.Logger.Warn("Root tournament bond recovery transaction reverted; waiting for a fresh retry", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "claimer", recovery.Claimer, + "payment", recovery.Payment) + return nil + } + s.Logger.Error("Root tournament bond payment failed; suppressing automatic retry", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "status", receipt.Status, + "claimer", recovery.Claimer, + "payment", recovery.Payment, + "outcome", "failed_push") + s.retireRootBondRecovery(app.ID, candidate) + return nil + case model.BondDispositionNoWinner: + s.Logger.Error("Root tournament bond has no winner after recovery transaction", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "status", receipt.Status) + s.retireRootBondRecovery(app.ID, candidate) + return nil + case model.BondDispositionTournamentRunning: + candidate.TxHash = nil + return fmt.Errorf("root tournament %s reports a running bond after mined recovery transaction %s", + candidate.Tournament, txHash) + default: + candidate.TxHash = nil + return fmt.Errorf("root tournament %s returned unknown bond disposition %s after transaction %s", + candidate.Tournament, recovery.Disposition, txHash) + } +} diff --git a/internal/prt/bond_foreclosure_test.go b/internal/prt/bond_foreclosure_test.go new file mode 100644 index 000000000..07e31c88b --- /dev/null +++ b/internal/prt/bond_foreclosure_test.go @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRootBondRecoveryForeclosureAdmission(t *testing.T) { + owned := common.HexToAddress("0x600") + tests := []struct { + name string + foreclosed bool + candidateEpoch uint64 + recovery BondRecovery + wantRead bool + wantTx bool + }{ + { + name: "foreclosed current epoch recovers owned bond", foreclosed: true, candidateEpoch: 3, + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), wantRead: true, wantTx: true, + }, + { + name: "foreclosed future epoch waits", foreclosed: true, candidateEpoch: 4, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + if test.foreclosed { + app.ForecloseBlock = 10 + } + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + tournamentAddress := common.HexToAddress("0x300") + service.queueRootBondRecovery(app.ID, test.candidateEpoch, tournamentAddress) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(20))). + Return(CurrentSealedEpoch{EpochNumber: 3, Tournament: tournamentAddress}, nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + tournament := &tournamentAdapterMock{} + if test.wantRead { + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(20))).Return(test.recovery, nil).Once() + } + if test.wantTx { + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + } + + require.NoError(t, service.recoverRootBonds(t.Context(), app, 20)) + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Equal(t, test.wantTx, service.rootBondRecoveries[app.ID][0].TxHash != nil) + require.Empty(t, service.pendingTransactions) + service.repository.(*prtRepositoryMock).AssertExpectations(t) + factory.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestRootBondRecoveryWithoutCandidatesDoesNotReadChain(t *testing.T) { + app := prtForeclosedApp(1, 10) + factory := &adapterFactoryMock{} + service := newRootBondTestService(common.HexToAddress("0x600"), factory) + + require.NoError(t, service.recoverRootBonds(t.Context(), app, 20)) + require.Empty(t, service.rootBondRecoveries) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + factory.AssertNotCalled(t, "CreateTournamentAdapter", mock.Anything) +} diff --git a/internal/prt/bond_test.go b/internal/prt/bond_test.go new file mode 100644 index 000000000..ad2f6c414 --- /dev/null +++ b/internal/prt/bond_test.go @@ -0,0 +1,497 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +const noWinnerCase = "no winner" + +func TestBondRecoveryFromBindingMapsAndOwnsPayment(t *testing.T) { + claimer := common.HexToAddress("0x100") + payment := big.NewInt(42) + recovery, err := bondRecoveryFromBinding(uint8(2), claimer, payment) + require.NoError(t, err) + require.Equal(t, model.BondDispositionRecoverable, recovery.Disposition) + require.Equal(t, claimer, recovery.Claimer) + require.Equal(t, int64(42), recovery.Payment.Int64()) + + recovery.Payment.SetInt64(7) + require.Equal(t, int64(42), payment.Int64()) +} + +func TestBondRecoveryFromBindingValidatesCanonicalState(t *testing.T) { + claimer := common.HexToAddress("0x100") + tests := []struct { + name string + disposition uint8 + claimer common.Address + payment *big.Int + }{ + {name: "unknown disposition", disposition: 4, payment: big.NewInt(0)}, + {name: "nil payment", disposition: uint8(3)}, + {name: "negative payment", disposition: uint8(2), claimer: claimer, payment: big.NewInt(-1)}, + {name: "running claimer", disposition: uint8(0), claimer: claimer, payment: big.NewInt(0)}, + {name: "no winner payment", disposition: uint8(1), payment: big.NewInt(1)}, + {name: "recovered claimer", disposition: uint8(3), claimer: claimer, payment: big.NewInt(0)}, + {name: "recoverable zero claimer", disposition: uint8(2), payment: big.NewInt(0)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := bondRecoveryFromBinding(test.disposition, test.claimer, test.payment) + require.ErrorIs(t, err, errInvalidBondRecovery) + }) + } + + recovery, err := bondRecoveryFromBinding( + uint8(2), claimer, big.NewInt(0), + ) + require.NoError(t, err) + require.Zero(t, recovery.Payment.Sign(), "a zero recoverable payment is valid") +} + +func TestAcceptQueuesRootBondBeforeCallAndDeduplicates(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + snapshot := resultTestSnapshot(epoch, true) + acceptErr := errors.New("accept failed") + consensus := &daveConsensusAdapterMock{} + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + var output bytes.Buffer + service.Logger = slog.New(slog.NewTextHandler(&output, nil)) + consensus.On("AcceptStagedTournamentResult", mock.Anything, epoch.Index). + Run(func(mock.Arguments) { + require.Len(t, service.rootBondRecoveries[app.ID], 1) + candidate := service.rootBondRecoveries[app.ID][0] + require.Equal(t, epoch.Index, candidate.EpochIndex) + require.Equal(t, snapshot.sealed.Tournament, candidate.Tournament) + }). + Return((*types.Transaction)(nil), acceptErr).Twice() + + for range 2 { + err := service.broadcastAcceptTournamentResult(context.Background(), app, epoch, consensus, snapshot) + require.ErrorIs(t, err, acceptErr) + } + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Equal(t, 1, strings.Count(output.String(), "Queued root tournament bond recovery candidate")) + require.Contains(t, output.String(), "restart clears") + require.Contains(t, output.String(), "application_id=7") + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), snapshot.sealed.Tournament.Hex()) + require.NotContains(t, output.String(), "payment=") + require.NotContains(t, output.String(), "claimer=") + consensus.AssertExpectations(t) +} + +func TestRootBondRecoveryWaitsForNextEpoch(t *testing.T) { + app := prtRevertTestApp() + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + service.queueRootBondRecovery(app.ID, 3, common.HexToAddress("0x300")) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(20))). + Return(CurrentSealedEpoch{EpochNumber: 3}, nil).Once() + service.adapterFactory.(*adapterFactoryMock). + On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + + require.NoError(t, service.recoverRootBonds(context.Background(), app, 20)) + require.Len(t, service.rootBondRecoveries[app.ID], 1) + service.adapterFactory.(*adapterFactoryMock).AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestRootBondRecoveryHandlesEveryDispositionAndOwnership(t *testing.T) { + owned := common.HexToAddress("0x600") + external := common.HexToAddress("0x700") + tests := []struct { + name string + recovery BondRecovery + wantRetired bool + wantTx bool + }{ + {name: "running", recovery: canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0)}, + {name: noWinnerCase, recovery: canonicalBondRecovery(model.BondDispositionNoWinner, common.Address{}, 0), wantRetired: true}, + {name: "recovered by other actor", + recovery: canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), wantRetired: true}, + {name: "external claimer", recovery: canonicalBondRecovery(model.BondDispositionRecoverable, external, 8), wantRetired: true}, + {name: "owned zero payment", recovery: canonicalBondRecovery(model.BondDispositionRecoverable, owned, 0), wantTx: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + tournamentAddress := common.HexToAddress("0x300") + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(20))).Return(test.recovery, nil).Once() + if test.wantTx { + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + } + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + require.NoError(t, service.recoverRootBonds(context.Background(), app, 20)) + if test.wantRetired { + require.Empty(t, service.rootBondRecoveries[app.ID]) + // A resolved candidate must not trigger another state read or send. + require.NoError(t, service.recoverRootBonds(context.Background(), app, 21)) + } else { + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Equal(t, test.wantTx, service.rootBondRecoveries[app.ID][0].TxHash != nil) + } + factory.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestPendingRootBondRecoveryBlocksOtherMutations(t *testing.T) { + app := prtRevertTestApp() + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: common.HexToAddress("0x300")} + txHash := common.HexToHash("0x400") + candidate.TxHash = &txHash + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return(types.NewTx(&types.LegacyTx{}), true, nil).Once() + service.client = client + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.NoError(t, service.recoverRootBonds(context.Background(), app, 20)) + require.Equal(t, &txHash, candidate.TxHash) + client.AssertExpectations(t) +} + +func TestOtherPRTMutationBlocksRootBondRecovery(t *testing.T) { + app := prtRevertTestApp() + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + factory := &adapterFactoryMock{} + service := newRootBondTestService(common.HexToAddress("0x600"), factory) + service.queueRootBondRecovery(app.ID, 3, common.HexToAddress("0x300")) + txHash := common.HexToHash("0x400") + service.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: action, Hash: txHash} + + require.NoError(t, service.recoverRootBonds(context.Background(), app, 20)) + require.Len(t, service.rootBondRecoveries[app.ID], 1) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + factory.AssertNotCalled(t, "CreateTournamentAdapter", mock.Anything) + }) + } +} + +func TestConcurrentRootRecoveryAndResultMutationIsRejected(t *testing.T) { + app := prtRevertTestApp() + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + recoveryTx := common.HexToHash("0x400") + stageTx := common.HexToHash("0x500") + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{{ + EpochIndex: 3, + Tournament: common.HexToAddress("0x300"), + TxHash: &recoveryTx, + }} + service.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: tournamentActionStage, Hash: stageTx} + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.Nil(t, joinEpoch) + require.ErrorContains(t, err, "bond recovery and another PRT transaction in flight") +} + +func TestRootBondRecoveryKnownRevertsWaitForFreshView(t *testing.T) { + app := prtRevertTestApp() + for _, test := range []struct { + name string + err error + }{ + {name: "tournament not finished", err: tournamentRevertError("TournamentNotFinished")}, + {name: noWinnerCase, err: tournamentRevertError("NoWinner")}, + {name: "nonce too low", err: errors.New("nonce too low")}, + } { + t.Run(test.name, func(t *testing.T) { + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: common.HexToAddress("0x300")} + tournament := &tournamentAdapterMock{} + tournament.On("TryRecoveringBond", mock.Anything). + Return((*types.Transaction)(nil), test.err).Once() + + err := service.broadcastRootBondRecovery( + context.Background(), app, candidate, tournament, + canonicalBondRecovery(model.BondDispositionRecoverable, common.HexToAddress("0x600"), 1), + ) + require.NoError(t, err) + require.Nil(t, candidate.TxHash) + tournament.AssertExpectations(t) + }) + } +} + +func TestMinedRootBondRecoveryUsesPostTransactionState(t *testing.T) { + owned := common.HexToAddress("0x600") + external := common.HexToAddress("0x700") + for _, test := range []struct { + name string + status uint64 + recovery BondRecovery + wantRetired bool + wantTxCleared bool + wantError string + wantFailedPush bool + }{ + {name: "recovered", status: types.ReceiptStatusSuccessful, + recovery: canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), wantRetired: true}, + {name: "successful receipt with failed push", status: types.ReceiptStatusSuccessful, + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, owned, 7), + wantRetired: true, wantFailedPush: true}, + {name: "recoverable bond now belongs to another claimer", status: types.ReceiptStatusSuccessful, + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, external, 7), wantRetired: true}, + {name: "failed receipt with recoverable state", status: types.ReceiptStatusFailed, + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, owned, 7), wantTxCleared: true}, + {name: noWinnerCase, status: types.ReceiptStatusSuccessful, + recovery: canonicalBondRecovery(model.BondDispositionNoWinner, common.Address{}, 0), wantRetired: true}, + {name: "running", status: types.ReceiptStatusFailed, + recovery: canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0), + wantTxCleared: true, wantError: "running bond"}, + } { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + var logs bytes.Buffer + service.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + tournamentAddress := common.HexToAddress("0x300") + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: tournamentAddress, TxHash: &txHash} + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, txHash).Return(&types.Receipt{ + Status: test.status, + TxHash: txHash, + BlockNumber: big.NewInt(21), + }, nil).Once() + service.client = client + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(21))).Return(test.recovery, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + err := service.recoverRootBonds(context.Background(), app, 20) + if test.wantError == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, test.wantError) + } + require.Equal(t, test.wantRetired, len(service.rootBondRecoveries[app.ID]) == 0) + if test.wantTxCleared { + require.Nil(t, candidate.TxHash) + } + if test.wantFailedPush { + require.Contains(t, logs.String(), "level=ERROR") + require.Contains(t, logs.String(), "outcome=failed_push") + } else { + require.NotContains(t, logs.String(), "outcome=failed_push") + } + client.AssertExpectations(t) + factory.AssertExpectations(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestRootBondRecoveryKeepsCandidateOnErrors(t *testing.T) { + owned := common.HexToAddress("0x600") + app := prtRevertTestApp() + tournamentAddress := common.HexToAddress("0x300") + + t.Run("pre-broadcast view", func(t *testing.T) { + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything).Return(BondRecovery{}, errors.New("view failed")).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + require.ErrorContains(t, service.recoverRootBonds(context.Background(), app, 20), "view failed") + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + }) + + t.Run("nil transaction", func(t *testing.T) { + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + tournament.On("TryRecoveringBond", mock.Anything).Return((*types.Transaction)(nil), nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + require.ErrorContains(t, service.recoverRootBonds(context.Background(), app, 20), "nil transaction") + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + }) + + t.Run("transaction lookup", func(t *testing.T) { + service := newRootBondTestService(owned, &adapterFactoryMock{}) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: tournamentAddress, TxHash: &txHash} + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), false, errors.New("lookup failed")).Once() + service.client = client + + require.ErrorContains(t, service.recoverRootBonds(context.Background(), app, 20), "lookup failed") + require.Equal(t, &txHash, candidate.TxHash) + }) + + for _, test := range []struct { + name string + viewErr error + clearTx bool + }{ + {name: "post-transaction view", viewErr: errors.New("view failed")}, + {name: "invalid post-transaction view", viewErr: errInvalidBondRecovery, clearTx: true}, + } { + t.Run(test.name, func(t *testing.T) { + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: tournamentAddress, TxHash: &txHash} + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, txHash). + Return(&types.Receipt{TxHash: txHash, BlockNumber: big.NewInt(21)}, nil).Once() + service.client = client + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything).Return(BondRecovery{}, test.viewErr).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + require.ErrorIs(t, service.recoverRootBonds(context.Background(), app, 20), test.viewErr) + if test.clearTx { + require.Nil(t, candidate.TxHash) + } else { + require.Equal(t, &txHash, candidate.TxHash) + } + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Same(t, candidate, service.rootBondRecoveries[app.ID][0]) + client.AssertExpectations(t) + tournament.AssertExpectations(t) + factory.AssertExpectations(t) + }) + } + + for _, test := range []struct { + name string + receipt *types.Receipt + want string + }{ + {name: "receipt hash", receipt: &types.Receipt{ + TxHash: common.HexToHash("0xbad"), BlockNumber: big.NewInt(21), + }, want: "differs from transaction"}, + {name: "receipt status", receipt: &types.Receipt{ + TxHash: common.HexToHash("0x400"), BlockNumber: big.NewInt(21), Status: 2, + }, want: "invalid receipt status"}, + } { + t.Run(test.name, func(t *testing.T) { + service := newRootBondTestService(owned, &adapterFactoryMock{}) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: tournamentAddress, TxHash: &txHash} + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, txHash).Return(test.receipt, nil).Once() + service.client = client + + require.ErrorContains(t, service.recoverRootBonds(context.Background(), app, 20), test.want) + require.Equal(t, &txHash, candidate.TxHash) + }) + } +} + +func TestRootBondRecoveryIsOldestFirstAndIsolatedPerApplication(t *testing.T) { + owned := common.HexToAddress("0x600") + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + appOne := prtRevertTestApp() + appTwo := prtRevertTestApp() + appTwo.ID = 8 + appTwo.Name = "prt-app-two" + appTwo.IConsensusAddress = common.HexToAddress("0x108") + rootOne := common.HexToAddress("0x301") + rootTwo := common.HexToAddress("0x302") + rootThree := common.HexToAddress("0x303") + service.queueRootBondRecovery(appOne.ID, 1, rootOne) + service.queueRootBondRecovery(appOne.ID, 2, rootTwo) + service.queueRootBondRecovery(appTwo.ID, 1, rootThree) + + for _, app := range []*model.Application{appOne, appTwo} { + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(CurrentSealedEpoch{EpochNumber: 3}, nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + } + for _, address := range []common.Address{rootOne, rootThree} { + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: uint64(address[19])}), nil).Once() + factory.On("CreateTournamentAdapter", address).Return(tournament, nil).Once() + } + + require.NoError(t, service.recoverRootBonds(context.Background(), appOne, 20)) + require.NotNil(t, service.rootBondRecoveries[appOne.ID][0].TxHash) + require.Nil(t, service.rootBondRecoveries[appOne.ID][1].TxHash) + require.NoError(t, service.recoverRootBonds(context.Background(), appTwo, 20)) + require.NotNil(t, service.rootBondRecoveries[appTwo.ID][0].TxHash) + factory.AssertNotCalled(t, "CreateTournamentAdapter", rootTwo) + factory.AssertExpectations(t) +} + +func newRootBondTestService(from common.Address, factory AdapterFactory) *Service { + service, _ := newPRTServiceMock() + service.adapterFactory = factory + service.submissionTimeout = time.Second + service.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: from}) + return service +} + +func canonicalBondRecovery(disposition model.BondDisposition, claimer common.Address, payment int64) BondRecovery { + return BondRecovery{Disposition: disposition, Claimer: claimer, Payment: big.NewInt(payment)} +} From c3fb32ad55d56f1bb096afdb51e17f17c1ad98c6 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:19 -0300 Subject: [PATCH 38/41] feat(prt): coordinate the v3 service cycle Connect the final PRT helpers through one service cycle. For each enabled application, observe first, then reconcile healthy local claims against published chain state. Read publishable results at the minimum of the configured block and the published tournament cursor. Use latest separately to select live actions, not to confirm database staging or terminal application status. Keep progressTournamentResult whole: pending transactions, published result reads, latest-state checks, and action priority are one decision path. A required join, stage, or accept takes priority over a new bond recovery. If the local claim is not ready for an open join window, do not fill that gap with a new recovery transaction. During foreclosure, continue observation and eligible queued recovery, but send no new join, stage, or accept. Wait for both ingestion cursors and local processing, reconcile accepted claims, then terminalize the remaining claims. Include service-cycle, block-policy, foreclosure, recovery-selection, shutdown, and cancellation tests. This completes the PRT wiring without an intermediate service-loop design. The node still observes disputes but does not submit dispute moves. Persistent transaction delivery remains a separate change. Validate saved chain, observation policy, and submission mode before constructing the signer. Honor the explicit legacy-fee override and retain observe-first application selection. --- internal/config/generate/Config.toml | 8 +- internal/prt/block_policy_test.go | 450 +++++++++++ internal/prt/bond.go | 93 ++- internal/prt/bond_discovery_test.go | 256 ++++++ internal/prt/bond_missing_test.go | 161 ++++ internal/prt/consensus.go | 117 +++ internal/prt/consensus_test.go | 732 ++++++++++++++++++ internal/prt/fixture_test.go | 11 + .../prt/foreclosure_recovery_drain_test.go | 138 ++++ internal/prt/handle_foreclosed_test.go | 319 ++++++-- internal/prt/join_log_test.go | 68 ++ internal/prt/legacy_fees_test.go | 135 ++++ internal/prt/missing_transaction_test.go | 286 +++++++ internal/prt/observation_health.go | 3 +- internal/prt/observation_health_test.go | 14 +- internal/prt/observation_shutdown_test.go | 314 ++++++++ internal/prt/passive_observer_test.go | 120 ++- internal/prt/pending.go | 56 +- internal/prt/pending_test.go | 11 +- internal/prt/prt.go | 158 +--- internal/prt/recovery_selection_test.go | 249 ++++++ internal/prt/service.go | 299 ++++--- internal/prt/service_config_test.go | 103 +++ internal/prt/startup_signer_test.go | 64 ++ internal/prt/supervisor_readiness_test.go | 63 ++ internal/prt/tick_policy_test.go | 313 ++++++++ internal/prt/validation_test.go | 169 ++-- 27 files changed, 4293 insertions(+), 417 deletions(-) create mode 100644 internal/prt/block_policy_test.go create mode 100644 internal/prt/bond_discovery_test.go create mode 100644 internal/prt/bond_missing_test.go create mode 100644 internal/prt/consensus_test.go create mode 100644 internal/prt/foreclosure_recovery_drain_test.go create mode 100644 internal/prt/join_log_test.go create mode 100644 internal/prt/legacy_fees_test.go create mode 100644 internal/prt/missing_transaction_test.go create mode 100644 internal/prt/observation_shutdown_test.go create mode 100644 internal/prt/recovery_selection_test.go create mode 100644 internal/prt/service_config_test.go create mode 100644 internal/prt/startup_signer_test.go create mode 100644 internal/prt/supervisor_readiness_test.go create mode 100644 internal/prt/tick_policy_test.go diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index 9ca91a783..ee7256153 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -395,7 +395,13 @@ go-type = "AuthKind" description = """ One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws". -The PRT auth variable for the kind defined here is required. Eg.: CARTESI_PRT_AUTH_MNEMONIC""" +When claim submission is enabled, configure the PRT auth variable for the selected +kind, for example CARTESI_PRT_AUTH_MNEMONIC. The standalone node always starts PRT, +even when it serves only Authority or Quorum applications. CARTESI_AUTH_* does not +replace CARTESI_PRT_AUTH_*. PRT signer credentials are not required when claim +submission is disabled. + +Use distinct addresses for claimer and PRT.""" omit = true used-by = ["node", "prt"] diff --git a/internal/prt/block_policy_test.go b/internal/prt/block_policy_test.go new file mode 100644 index 000000000..78e8e7f91 --- /dev/null +++ b/internal/prt/block_policy_test.go @@ -0,0 +1,450 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func TestCreateUsesMatchingPersistedPRTDefaultBlock(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + rawConfig, err := json.Marshal(PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, + ClaimSubmissionEnabled: false, + ChainID: 42, + }) + require.NoError(t, err) + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return(rawConfig, nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + + svc, err := Create(ctx, &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, + BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainId: 42, + FeatureClaimSubmissionEnabled: false, + }, + Repository: repo, + EthClient: client, + AdapterFactory: &adapterFactoryMock{}, + }) + require.NoError(t, err) + s := svc.(*Service) + require.False(t, s.submissionEnabled) + require.NotNil(t, s.pendingTransactions) + require.NotNil(t, s.disputeWarnings) + require.NotNil(t, s.zeroStagingWarnings) + require.NotNil(t, s.rootBondRecoveries) + block, err := s.getDefaultBlockNumber(ctx) + require.NoError(t, err) + require.Equal(t, uint64(100), block) + client.AssertNotCalled(t, "BlockNumber", mock.Anything) + client.AssertExpectations(t) + repo.AssertExpectations(t) +} + +type prtBlockPolicyCreateRepository struct { + repository.Repository + mock.Mock +} + +func (r *prtBlockPolicyCreateRepository) LoadNodeConfigRaw(ctx context.Context, key string) ( + []byte, time.Time, time.Time, error, +) { + args := r.Called(ctx, key) + return args.Get(0).([]byte), time.Time{}, time.Time{}, args.Error(1) +} + +func TestPRTDefaultBlockFailureDoesNotFallBackToLatest(t *testing.T) { + for _, test := range []struct { + name string + header *types.Header + err error + }{ + {name: "unavailable finalized head", err: errors.New("finalized head unavailable")}, + {name: "nil header"}, + {name: "nil number", header: &types.Header{}}, + {name: "negative number", header: &types.Header{Number: big.NewInt(-1)}}, + {name: "overflow number", header: &types.Header{Number: new(big.Int).Lsh(big.NewInt(1), 64)}}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + s.defaultBlock = model.DefaultBlock_Finalized + s.submissionEnabled = true + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(test.header, test.err).Once() + s.client = client + + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{prtRevertTestApp()}, uint64(1), nil).Once() + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.Error(t, err) + if test.err != nil { + require.ErrorIs(t, err, test.err) + } + client.AssertNotCalled(t, "BlockNumber", mock.Anything) + repo.AssertNotCalled(t, "ListEpochs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + client.AssertExpectations(t) + }) + } +} + +func TestPRTLatestStageCanAcceptWithoutPersistingStage(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + trusted := resultTestSnapshot(f.epoch, false) + latest := resultTestSnapshot(f.epoch, true) + latest.sealed.StagingBlockNumber = 118 + latest.accept.IsClaimStagingPeriodOver = true + f.expectTick(100, 120, trusted, latest) + acceptTx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.consensus.On("AcceptStagedTournamentResult", mock.Anything, f.epoch.Index).Return(acceptTx, nil).Once() + + require.NoError(t, f.tick(100)) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Nil(t, f.epoch.StagedAtBlock) + require.Equal(t, acceptTx.Hash(), f.s.pendingTransactions[f.app.ID].Hash) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) +} + +func TestPRTLatestStageReorgDoesNotLatchLocalState(t *testing.T) { + for _, restaged := range []bool{false, true} { + name := "removed stage can be submitted again" + if restaged { + name = "same result restaged at a different block" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + trusted := resultTestSnapshot(f.epoch, false) + latest := resultTestSnapshot(f.epoch, true) + latest.sealed.StagingBlockNumber = 118 + f.expectTick(100, 120, trusted, latest) + require.NoError(t, f.tick(100)) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Nil(t, f.epoch.StagedAtBlock) + + changed := resultTestSnapshot(f.epoch, restaged) + if restaged { + changed.sealed.StagingBlockNumber = 121 + } else { + f.consensus.On("StageTournamentResult", mock.Anything, f.epoch.Index, mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 2}), nil).Once() + } + f.expectTick(101, 122, trusted, changed) + require.NoError(t, f.tick(101)) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Nil(t, f.epoch.StagedAtBlock) + if !restaged { + require.Equal(t, tournamentActionStage, f.s.pendingTransactions[f.app.ID].Action) + } + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + }) + } +} + +func TestPRTStageIsPersistedOnlyWhenTrustedHeadCatchesUp(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + unstaged := resultTestSnapshot(f.epoch, false) + staged := resultTestSnapshot(f.epoch, true) + staged.sealed.StagingBlockNumber = 118 + f.expectTick(100, 120, unstaged, staged) + require.NoError(t, f.tick(100)) + f.assertNoStageWrite(t) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, f.epoch.Index, uint64(118)).Return(nil).Once() + f.expectTick(119, 120, staged, staged) + require.NoError(t, f.tick(119)) + require.Equal(t, model.EpochStatus_ClaimStaged, f.epoch.Status) + require.NotNil(t, f.epoch.StagedAtBlock) + require.Equal(t, uint64(118), *f.epoch.StagedAtBlock) + f.assertNoPermanentStatusWrite(t) +} + +func TestPRTLatestResultMismatchDoesNotChangeApplicationStatus(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*daveConsensusSnapshot) + }{ + {name: "winner commitment", mutate: func(s *daveConsensusSnapshot) { + s.stage.WinnerCommitment = common.HexToHash("0xbad") + }}, + {name: "winner machine", mutate: func(s *daveConsensusSnapshot) { + s.stage.WinnerPostEpochMachineStateHash = common.HexToHash("0xbad") + s.sealed.StagedPostEpochMachineStateHash = s.stage.WinnerPostEpochMachineStateHash + s.accept.StagedPostEpochMachineStateHash = s.stage.WinnerPostEpochMachineStateHash + }}, + {name: "staged outputs", mutate: func(s *daveConsensusSnapshot) { + s.sealed.StagedPostEpochOutputsMerkleRoot = common.HexToHash("0xbad") + s.accept.StagedPostEpochOutputsMerkleRoot = s.sealed.StagedPostEpochOutputsMerkleRoot + }}, + {name: "input bounds", mutate: func(s *daveConsensusSnapshot) { + s.sealed.InputIndexUpperBound++ + }}, + {name: "tournament address", mutate: func(s *daveConsensusSnapshot) { + s.sealed.Tournament = common.HexToAddress("0xbad") + }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + trusted := resultTestSnapshot(f.epoch, false) + latest := resultTestSnapshot(f.epoch, true) + latest.sealed.StagingBlockNumber = 118 + latest.accept.IsClaimStagingPeriodOver = true + test.mutate(&latest) + f.expectTick(100, 120, trusted, latest) + + // A temporary mismatch can return an error or defer work. Neither + // outcome permits a durable failure status or a result transaction. + _ = f.tick(100) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + }) + } +} + +func TestPRTLatestProofRevertWaitsForTrustedWinner(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + trusted := resultTestSnapshot(f.epoch, false) + trusted.stage.IsFinished = false + trusted.stage.WinnerCommitment = common.Hash{} + trusted.stage.WinnerPostEpochMachineStateHash = common.Hash{} + latest := resultTestSnapshot(f.epoch, false) + f.expectTick(100, 120, trusted, latest) + f.consensus.On("StageTournamentResult", mock.Anything, f.epoch.Index, mock.Anything). + Return((*types.Transaction)(nil), daveConsensusRevertError("InvalidMachineMerkleProof")).Once() + // The error path must confirm the winner before it diagnoses the stored + // proof. The tournament is still unfinished at the configured head. + f.expectHead(100) + f.expectSnapshot(100, trusted) + + require.ErrorContains(t, f.tick(100), "winner is not confirmed") + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + require.Empty(t, f.s.pendingTransactions) +} + +func TestPRTObserverStopsAtTrustedHead(t *testing.T) { + for _, finishedAt := range []uint64{0, 118} { + name := "running tournament" + if finishedAt != 0 { + name = "stored finish block is above trusted head" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock = 80 + f.app.LastEpochCheckBlock = 100 + f.epoch.LastBlock = 90 + trusted := resultTestSnapshot(f.epoch, false) + trusted.stage.IsFinished = false + trusted.stage.WinnerCommitment = common.Hash{} + trusted.stage.WinnerPostEpochMachineStateHash = common.Hash{} + latest := resultTestSnapshot(f.epoch, true) + latest.sealed.StagingBlockNumber = 118 + f.expectTick(100, 120, trusted, latest) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.TournamentAddress.Hex()). + Return(&model.Tournament{ + ApplicationID: f.app.ID, + EpochIndex: f.epoch.Index, + Address: *f.epoch.TournamentAddress, + MaxLevel: 1, + StartInstant: 90, + Kind: model.TournamentKindLeaf, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: finishedAt}, + }, nil).Once() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(tournament, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + tournament.On("Descriptor", opts). + Return(TournamentDescriptor{BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, StartInstant: 90}, nil).Once() + tournament.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingMatchesActive}, nil).Once() + expectTournamentAuxiliaryReads(tournament, opts, RootLevel, model.TournamentStandingMatchesActive) + tournament.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts != nil && opts.Start == 90 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + tournament.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + observer := &observerCheckpointFixture{t: t, s: f.s, repo: f.repo, app: f.app} + observer.emptyParticipants(f.epoch, *f.epoch.TournamentAddress) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.Anything, uint64(100)).Return(nil).Once() + observer.unaccepted(f.epoch, 0) + + require.NoError(t, runPRTApplicationTick(f.s, f.app)) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestPRTJoinUsesLatestCommitmentToSuppressDuplicate(t *testing.T) { + for _, matches := range []bool{true, false} { + name := "matching final state" + if !matches { + name = "different final state defers" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + f.expectTick(100, 120, snapshot, snapshot) + f.repo.On("GetCommitment", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index, + f.epoch.TournamentAddress.Hex(), f.epoch.Commitment.String()).Return((*model.Commitment)(nil), nil).Once() + finalState := *f.epoch.MachineHash + if !matches { + finalState = common.HexToHash("0xbad") + } + tournament := &tournamentAdapterMock{} + tournament.On("CommitmentStanding", mock.MatchedBy(resultCallOptsAtBlock(120)), [32]byte(*f.epoch.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: finalState}, nil).Once() + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(tournament, nil).Once() + + err := f.tick(100) + if matches { + require.NoError(t, err) + } + tournament.AssertNotCalled(t, "JoinTournament", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.assertNoPermanentStatusWrite(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestPRTReaderUsesOnlyConfiguredHead(t *testing.T) { + f := newPRTBlockPolicyFixture(t, false) + f.expectHead(100) + snapshot := resultTestSnapshot(f.epoch, true) + snapshot.sealed.StagingBlockNumber = 99 + f.expectSnapshot(100, snapshot) + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, f.epoch.Index, uint64(99)).Return(nil).Once() + + require.NoError(t, f.tick(100)) + require.Equal(t, model.EpochStatus_ClaimStaged, f.epoch.Status) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) +} + +type prtBlockPolicyFixture struct { + s *Service + app *model.Application + epoch *model.Epoch + repo *prtRepositoryMock + client *ethClientMock + consensus *daveConsensusAdapterMock + factory *adapterFactoryMock +} + +func newPRTBlockPolicyFixture(t *testing.T, submissionEnabled bool) *prtBlockPolicyFixture { + t.Helper() + f := &prtBlockPolicyFixture{ + app: prtRevertTestApp(), + epoch: resultTestEpoch(model.EpochStatus_ClaimComputed), + client: ðClientMock{}, + consensus: &daveConsensusAdapterMock{}, + factory: &adapterFactoryMock{}, + } + applyPRTStateProof(f.epoch, repotest.KeccakStateProof(common.HexToHash("0x500"))) + f.epoch.CommitmentProof = []common.Hash{common.HexToHash("0x700")} + f.s, f.repo = newPRTServiceMock() + f.s.adapterFactory = f.factory + f.s.submissionTimeout = time.Second + f.s.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: common.HexToAddress("0x600")}) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = submissionEnabled + f.s.client = f.client + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index).Return(f.epoch, nil) + f.factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Return(f.consensus, nil) + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.client.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.factory.AssertExpectations(t) + }) + return f +} + +func (f *prtBlockPolicyFixture) expectHead(block uint64) { + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: new(big.Int).SetUint64(block)}, nil).Once() +} + +func (f *prtBlockPolicyFixture) expectSnapshot(block uint64, snapshot daveConsensusSnapshot) { + opts := mock.MatchedBy(resultCallOptsAtBlock(block)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() +} + +func (f *prtBlockPolicyFixture) expectTick(trustedBlock, latestBlock uint64, trusted, latest daveConsensusSnapshot) { + f.expectHead(trustedBlock) + f.client.On("BlockNumber", mock.Anything).Return(latestBlock, nil).Once() + f.expectSnapshot(trustedBlock, trusted) + if latestBlock != trustedBlock { + f.expectSnapshot(latestBlock, latest) + } +} + +func (f *prtBlockPolicyFixture) tick(trustedBlock uint64) error { + // The existing observer has already scanned this trusted head. Keep this + // fixture focused on the block policy for result and join actions. + f.app.LastTournamentCheckBlock = trustedBlock + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), nil).Once() + return runPRTApplicationTick(f.s, f.app) +} + +func runPRTApplicationTick(s *Service, app *model.Application) error { + block, err := s.getDefaultBlockNumber(context.Background()) + if err != nil { + return err + } + return s.validateApplication(context.Background(), app, block) +} + +func (f *prtBlockPolicyFixture) assertNoStageWrite(t *testing.T) { + t.Helper() + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func (f *prtBlockPolicyFixture) assertNoPermanentStatusWrite(t *testing.T) { + t.Helper() + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) +} diff --git a/internal/prt/bond.go b/internal/prt/bond.go index fa452d2a6..efd997f86 100644 --- a/internal/prt/bond.go +++ b/internal/prt/bond.go @@ -9,6 +9,7 @@ import ( "fmt" "math/big" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -18,9 +19,62 @@ import ( ) type rootBondRecovery struct { - EpochIndex uint64 - Tournament common.Address - TxHash *common.Hash + EpochIndex uint64 + Tournament common.Address + TxHash *common.Hash + FirstMissingBlock *uint64 +} + +// discoverForeclosedRootBond finds the current root even when foreclosure +// prevented an accept attempt. Only published, signer-owned recoverable bonds +// enter the queue. Running roots remain eligible for discovery on a later tick. +func (s *Service) discoverForeclosedRootBond( + ctx context.Context, app *model.Application, epochs []*model.Epoch, consensus DaveConsensusAdapter, observedBlock uint64, +) error { + if !s.submissionEnabled || consensus == nil || !app.ForeclosureScanCaughtUp() || + observedBlock < app.ForecloseBlock || observedBlock < app.LastTournamentCheckBlock { + return nil + } + sealed, err := consensus.GetCurrentSealedEpoch(pinnedCallOpts(ctx, observedBlock)) + if err != nil { + return fmt.Errorf("reading foreclosed current root for bond recovery: %w", err) + } + if s.discoveredForeclosedRootBonds[app.ID] == sealed.Tournament { + return nil + } + for _, epoch := range epochs { + if epoch.Index != sealed.EpochNumber || epoch.TournamentAddress == nil || + *epoch.TournamentAddress != sealed.Tournament || epoch.Commitment == nil { + continue + } + tournament, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), sealed.Tournament.Hex()) + if err != nil { + return fmt.Errorf("loading foreclosed current root for bond recovery: %w", err) + } + if tournament == nil || tournament.Snapshot.AsOfBlock > app.LastTournamentCheckBlock || + tournament.Snapshot.AsOfBlock > observedBlock { + return nil + } + recovery := tournament.Snapshot.BondRecovery + if recovery.Disposition != model.BondDispositionRecoverable || recovery.Claimer == nil || + *recovery.Claimer != s.txOptsFactory.From() { + return nil + } + s.queueRootBondRecovery(app.ID, epoch.Index, sealed.Tournament) + // Keep this marker after retirement: a successful transaction can have + // a failed payment push, which must not restart an automatic retry loop. + // Restart clears both the marker and the existing in-memory queue. + s.markForeclosedRootBondDiscovered(app.ID, sealed.Tournament) + return nil + } + return nil +} + +func (s *Service) markForeclosedRootBondDiscovered(appID int64, tournament common.Address) { + if s.discoveredForeclosedRootBonds == nil { + s.discoveredForeclosedRootBonds = make(map[int64]common.Address) + } + s.discoveredForeclosedRootBonds[appID] = tournament } func (s *Service) queueRootBondRecovery(appID int64, epochIndex uint64, tournament common.Address) { @@ -201,6 +255,7 @@ func (s *Service) broadcastRootBondRecovery( } txHash := tx.Hash() candidate.TxHash = &txHash + candidate.FirstMissingBlock = nil s.Logger.Info("Sent root tournament bond recovery transaction", "application", app.Name, "epoch_index", candidate.EpochIndex, @@ -248,25 +303,46 @@ func (s *Service) reconcileRootBondRecoveryTransaction( ) error { txHash := *candidate.TxHash _, pending, err := s.client.TransactionByHash(ctx, txHash) - if err != nil { + missing := errors.Is(err, ethereum.NotFound) + if err != nil && !missing { return fmt.Errorf("checking root bond recovery transaction %s: %w", txHash, err) } - if pending { + if !missing { + candidate.FirstMissingBlock = nil + } + if !missing && pending { return nil } receipt, err := s.client.TransactionReceipt(ctx, txHash) + if missing && errors.Is(err, ethereum.NotFound) { + if candidate.FirstMissingBlock == nil || mostRecentBlock < *candidate.FirstMissingBlock { + candidate.FirstMissingBlock = new(mostRecentBlock) + return nil + } + if mostRecentBlock-*candidate.FirstMissingBlock >= maxMissingTournamentTransactionBlocks { + s.Logger.Warn("Root bond recovery transaction remains missing; waiting for fresh bond state before retry", + "application", app.Name, "epoch_index", candidate.EpochIndex, "tournament", candidate.Tournament, + "tx", txHash, "missing_since_block", *candidate.FirstMissingBlock, "latest_block", mostRecentBlock) + candidate.TxHash, candidate.FirstMissingBlock = nil, nil + } + return nil + } if err != nil { return fmt.Errorf("fetching root bond recovery receipt %s: %w", txHash, err) } - if receipt == nil || receipt.BlockNumber == nil || receipt.BlockNumber.Sign() < 0 { + if receipt == nil { return fmt.Errorf("root bond recovery transaction %s has an invalid receipt block", txHash) } + if _, err := checkedUint64(receipt.BlockNumber, "root bond recovery transaction receipt block"); err != nil { + return err + } if receipt.TxHash != txHash { return fmt.Errorf("root bond recovery receipt hash %s differs from transaction %s", receipt.TxHash, txHash) } if receipt.Status != types.ReceiptStatusFailed && receipt.Status != types.ReceiptStatusSuccessful { return fmt.Errorf("root bond recovery transaction %s has invalid receipt status %d", txHash, receipt.Status) } + candidate.FirstMissingBlock = nil callBlock := new(big.Int).SetUint64(mostRecentBlock) if receipt.BlockNumber.Cmp(callBlock) > 0 { callBlock.Set(receipt.BlockNumber) @@ -327,6 +403,11 @@ func (s *Service) reconcileRootBondRecoveryTransaction( "claimer", recovery.Claimer, "payment", recovery.Payment, "outcome", "failed_push") + if app.IsForeclosed() { + // A candidate queued before foreclosure can finish before the + // observation view catches up. Later discovery must not retry it. + s.markForeclosedRootBondDiscovered(app.ID, candidate.Tournament) + } s.retireRootBondRecovery(app.ID, candidate) return nil case model.BondDispositionNoWinner: diff --git a/internal/prt/bond_discovery_test.go b/internal/prt/bond_discovery_test.go new file mode 100644 index 000000000..866beabc8 --- /dev/null +++ b/internal/prt/bond_discovery_test.go @@ -0,0 +1,256 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +type foreclosedBondFixture struct { + s *Service + repo *prtRepositoryMock + client *ethClientMock + consensus *daveConsensusAdapterMock + tournament *tournamentAdapterMock + app *model.Application + epoch *model.Epoch + projection *model.Tournament + sealed CurrentSealedEpoch + owned common.Address +} + +func newForeclosedBondFixture(t *testing.T, disposition model.BondDisposition) *foreclosedBondFixture { + t.Helper() + factory := &adapterFactoryMock{} + f := &foreclosedBondFixture{ + app: prtForeclosedApp(1, 100), epoch: resultTestEpoch(model.EpochStatus_ClaimStaged), + client: ðClientMock{}, consensus: &daveConsensusAdapterMock{}, tournament: &tournamentAdapterMock{}, + owned: common.HexToAddress("0x600"), + } + f.app.LastTournamentCheckBlock = 120 + f.s = newRootBondTestService(f.owned, factory) + f.s.client, f.s.submissionEnabled = f.client, true + f.repo = f.s.repository.(*prtRepositoryMock) + f.sealed = CurrentSealedEpoch{EpochNumber: f.epoch.Index, Tournament: *f.epoch.TournamentAddress} + f.projection = &model.Tournament{ + ApplicationID: f.app.ID, EpochIndex: f.epoch.Index, Address: *f.epoch.TournamentAddress, + Snapshot: model.TournamentSnapshot{ + AsOfBlock: 120, FinishedAtBlock: 110, WinnerCommitment: f.epoch.Commitment, + BondRecovery: model.TournamentBondRecovery{Disposition: disposition, Claimer: &f.owned}, + }, + } + factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Return(f.consensus, nil) + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.client.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.tournament.AssertExpectations(t) + factory.AssertExpectations(t) + }) + return f +} + +func (f *foreclosedBondFixture) expectObservedTick(unreconciled bool, loadProjection bool) { + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(120))).Return(f.sealed, nil).Once() + if loadProjection { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.TournamentAddress.Hex()). + Return(f.projection, nil).Once() + } + f.repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock).Return(false, nil).Once() + f.repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock). + Return(unreconciled, nil).Once() + if unreconciled { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.TournamentAddress.Hex()). + Return(f.projection, nil).Once() + f.repo.On("ListEpochs", mock.Anything, f.app.Name, + repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + f.repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, f.app.ID, f.epoch.Index). + Run(func(mock.Arguments) { f.epoch.Status = model.EpochStatus_ClaimForeclosed }).Return(nil).Once() + } +} + +func (f *foreclosedBondFixture) expectRecoveryBroadcast(tx *types.Transaction) { + f.client.On("BlockNumber", mock.Anything).Return(uint64(125), nil).Once() + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(125))).Return(f.sealed, nil).Once() + f.s.adapterFactory.(*adapterFactoryMock).On("CreateTournamentAdapter", f.projection.Address).Return(f.tournament, nil).Once() + f.tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(125))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, f.owned, 1), nil).Once() + f.tournament.On("TryRecoveringBond", mock.Anything).Return(tx, nil).Once() +} + +func (f *foreclosedBondFixture) tick(t *testing.T) { + t.Helper() + require.NoError(t, f.s.handleForeclosedApp(t.Context(), f.app, func() (uint64, error) { return 120, nil })) + // Foreclosure permits bond recovery, never another join/stage/accept. + require.Empty(t, f.s.pendingTransactions) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) +} + +func TestForeclosedRootBondDiscoveryWithoutAcceptAttempt(t *testing.T) { + for _, status := range []model.EpochStatus{model.EpochStatus_ClaimStaged, model.EpochStatus_ClaimForeclosed} { + t.Run(status.String(), func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = status + f.expectObservedTick(status == model.EpochStatus_ClaimStaged, true) + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.expectRecoveryBroadcast(tx) + require.Empty(t, f.s.rootBondRecoveries) + f.tick(t) + require.Len(t, f.s.rootBondRecoveries[f.app.ID], 1) + require.Equal(t, tx.Hash(), *f.s.rootBondRecoveries[f.app.ID][0].TxHash) + require.Equal(t, model.EpochStatus_ClaimForeclosed, f.epoch.Status) + }) + } +} + +func TestForeclosedRunningRootBecomesRecoverableAfterClaimDrain(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionTournamentRunning) + f.projection.Snapshot.FinishedAtBlock = 0 + f.expectObservedTick(true, true) + f.tick(t) + require.Empty(t, f.s.rootBondRecoveries) + require.Empty(t, f.s.discoveredForeclosedRootBonds) + require.Equal(t, model.EpochStatus_ClaimForeclosed, f.epoch.Status) + + f.projection.Snapshot.FinishedAtBlock = 115 + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionRecoverable + f.expectObservedTick(false, true) + f.expectRecoveryBroadcast(types.NewTx(&types.LegacyTx{Nonce: 1})) + f.tick(t) + require.Len(t, f.s.rootBondRecoveries[f.app.ID], 1) +} + +func TestForeclosedRootDiscoveryDoesNotQueueIneligibleBonds(t *testing.T) { + for _, test := range []struct { + name string + update func(*foreclosedBondFixture) + }{ + {name: "foreign claimer", update: func(f *foreclosedBondFixture) { + f.projection.Snapshot.BondRecovery.Claimer = new(common.HexToAddress("0x700")) + }}, + {name: "recovered", update: func(f *foreclosedBondFixture) { + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionRecovered + }}, + {name: "no winner", update: func(f *foreclosedBondFixture) { + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionNoWinner + }}, + {name: "projection beyond published cursor", update: func(f *foreclosedBondFixture) { + f.projection.Snapshot.AsOfBlock = 121 + }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = model.EpochStatus_ClaimForeclosed + test.update(f) + for range 2 { + f.expectObservedTick(false, true) + f.tick(t) + } + require.Empty(t, f.s.rootBondRecoveries) + require.Empty(t, f.s.discoveredForeclosedRootBonds) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + }) + } +} + +func TestForeclosedRootDiscoveryDoesNotRetryFailedPaymentPush(t *testing.T) { + for _, queuedBeforeForeclosure := range []bool{false, true} { + name := "discovered after foreclosure" + if queuedBeforeForeclosure { + name = "queued before foreclosure with observation lag" + } + t.Run(name, func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = model.EpochStatus_ClaimForeclosed + if queuedBeforeForeclosure { + f.s.queueRootBondRecovery(f.app.ID, f.epoch.Index, f.projection.Address) + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionTournamentRunning + } + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.expectObservedTick(false, true) + f.expectRecoveryBroadcast(tx) + f.tick(t) + + f.expectObservedTick(false, queuedBeforeForeclosure) + f.client.On("BlockNumber", mock.Anything).Return(uint64(125), nil).Once() + f.client.On("TransactionByHash", mock.Anything, tx.Hash()).Return(tx, false, nil).Once() + f.client.On("TransactionReceipt", mock.Anything, tx.Hash()).Return(&types.Receipt{ + TxHash: tx.Hash(), BlockNumber: big.NewInt(125), Status: types.ReceiptStatusSuccessful, + }, nil).Once() + f.s.adapterFactory.(*adapterFactoryMock).On("CreateTournamentAdapter", f.projection.Address).Return(f.tournament, nil).Once() + f.tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(125))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, f.owned, 1), nil).Once() + f.tick(t) + require.Empty(t, f.s.rootBondRecoveries) + + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionRecoverable + f.expectObservedTick(false, false) + f.tick(t) + f.tournament.AssertNumberOfCalls(t, "TryRecoveringBond", 1) + require.Empty(t, f.s.rootBondRecoveries) + }) + } +} + +func TestForeclosedRootDiscoveryRequiresCurrentRootIdentity(t *testing.T) { + for _, mismatch := range []string{"epoch", "tournament"} { + t.Run(mismatch, func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = model.EpochStatus_ClaimForeclosed + if mismatch == "epoch" { + f.sealed.EpochNumber++ + } else { + f.sealed.Tournament = common.HexToAddress("0x900") + } + f.expectObservedTick(false, false) + f.tick(t) + require.Empty(t, f.s.rootBondRecoveries) + require.Empty(t, f.s.discoveredForeclosedRootBonds) + }) + } +} + +func TestForeclosedRootDiscoveryRetriesReadErrors(t *testing.T) { + for _, failure := range []string{"current epoch", "published root"} { + t.Run(failure, func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = model.EpochStatus_ClaimForeclosed + readErr := errors.New("discovery read failed") + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + var chainErr error + if failure == "current epoch" { + chainErr = readErr + } else { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), f.projection.Address.Hex()). + Return((*model.Tournament)(nil), readErr).Once() + } + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(120))).Return(f.sealed, chainErr).Once() + f.repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock).Return(false, nil).Once() + f.repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock).Return(false, nil).Once() + require.ErrorIs(t, f.s.handleForeclosedApp(t.Context(), f.app, func() (uint64, error) { return 120, nil }), readErr) + require.Empty(t, f.s.discoveredForeclosedRootBonds, "a read failure must not consume discovery") + require.Empty(t, f.s.rootBondRecoveries) + + f.expectObservedTick(false, true) + f.expectRecoveryBroadcast(types.NewTx(&types.LegacyTx{Nonce: 1})) + f.tick(t) + require.Len(t, f.s.rootBondRecoveries[f.app.ID], 1) + }) + } +} diff --git a/internal/prt/bond_missing_test.go b/internal/prt/bond_missing_test.go new file mode 100644 index 000000000..bd456fa58 --- /dev/null +++ b/internal/prt/bond_missing_test.go @@ -0,0 +1,161 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" +) + +func TestRootBondRecoveryMissingHashWaitsForFreshState(t *testing.T) { + for _, recoveredElsewhere := range []bool{false, true} { + t.Run(map[bool]string{false: "retry owned bond", true: "another caller recovered"}[recoveredElsewhere], func(t *testing.T) { + app := prtRevertTestApp() + owned := common.HexToAddress("0x600") + factory := &adapterFactoryMock{} + s := newRootBondTestService(owned, factory) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: common.HexToAddress("0x300"), TxHash: &txHash} + s.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + s.client = client + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), false, ethereum.NotFound).Times(5) + client.On("TransactionReceipt", mock.Anything, txHash). + Return((*types.Receipt)(nil), ethereum.NotFound).Times(5) + for _, head := range []uint64{100, 100, 99, 162} { + require.NoError(t, s.recoverRootBonds(t.Context(), app, head)) + require.Equal(t, &txHash, candidate.TxHash) + } + require.Equal(t, uint64(99), *candidate.FirstMissingBlock) + require.NoError(t, s.recoverRootBonds(t.Context(), app, 163)) + require.Nil(t, candidate.TxHash) + require.Nil(t, candidate.FirstMissingBlock) + require.Len(t, s.rootBondRecoveries[app.ID], 1) + require.Empty(t, factory.Calls, "expiry must end the action cycle") + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, s.repository.(*prtRepositoryMock).Calls) + + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(164))). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + tournament := &tournamentAdapterMock{} + factory.On("CreateTournamentAdapter", candidate.Tournament).Return(tournament, nil).Once() + recovery := canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1) + if recoveredElsewhere { + recovery = canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0) + } else { + tournament.On("TryRecoveringBond", mock.Anything).Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + } + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(164))).Return(recovery, nil).Once() + require.NoError(t, s.recoverRootBonds(t.Context(), app, 164)) + if recoveredElsewhere { + require.Empty(t, s.rootBondRecoveries[app.ID]) + } else { + require.NotNil(t, candidate.TxHash) + } + client.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestRootBondRecoveryMissingHashRetainsUncertainTransaction(t *testing.T) { + rpcErr := errors.New("provider unavailable") + for _, test := range []struct { + name string + lookupErr error + pending bool + receiptErr error + receipt *types.Receipt + wantErr error + resetTimer bool + }{ + {name: "pending resets timer", pending: true, resetTimer: true}, + {name: "transaction visible without receipt", receiptErr: ethereum.NotFound, wantErr: ethereum.NotFound, resetTimer: true}, + {name: "lookup failure", lookupErr: rpcErr, wantErr: rpcErr}, + {name: "lookup cancellation", lookupErr: context.Canceled, wantErr: context.Canceled}, + {name: "receipt failure", lookupErr: ethereum.NotFound, receiptErr: rpcErr, wantErr: rpcErr}, + {name: "receipt cancellation", lookupErr: ethereum.NotFound, receiptErr: context.Canceled, wantErr: context.Canceled}, + {name: "nil receipt", lookupErr: ethereum.NotFound}, + {name: "wrong receipt hash", lookupErr: ethereum.NotFound, + receipt: &types.Receipt{TxHash: common.HexToHash("0xbad"), BlockNumber: big.NewInt(21)}}, + {name: "invalid receipt block", lookupErr: ethereum.NotFound, + receipt: &types.Receipt{TxHash: common.HexToHash("0x400"), BlockNumber: big.NewInt(-1)}}, + {name: "overflow receipt block", lookupErr: ethereum.NotFound, + receipt: &types.Receipt{TxHash: common.HexToHash("0x400"), BlockNumber: new(big.Int).Lsh(big.NewInt(1), 64)}}, + {name: "invalid receipt status", lookupErr: ethereum.NotFound, + receipt: &types.Receipt{TxHash: common.HexToHash("0x400"), BlockNumber: big.NewInt(21), Status: 2}}, + } { + t.Run(test.name, func(t *testing.T) { + s := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + app := prtRevertTestApp() + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, TxHash: &txHash, FirstMissingBlock: new(uint64(1))} + s.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + s.client = client + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), test.pending, test.lookupErr).Once() + if !test.pending && (test.lookupErr == nil || errors.Is(test.lookupErr, ethereum.NotFound)) { + client.On("TransactionReceipt", mock.Anything, txHash).Return(test.receipt, test.receiptErr).Once() + } + err := s.recoverRootBonds(t.Context(), app, 100) + switch { + case test.pending: + require.NoError(t, err) + case test.wantErr != nil: + require.ErrorIs(t, err, test.wantErr) + default: + require.Error(t, err) + } + require.Equal(t, &txHash, candidate.TxHash) + require.Equal(t, test.resetTimer, candidate.FirstMissingBlock == nil) + require.Len(t, s.rootBondRecoveries[app.ID], 1) + require.Empty(t, s.repository.(*prtRepositoryMock).Calls) + client.AssertExpectations(t) + }) + } +} + +func TestRootBondRecoveryReceiptOnlyResetsMissingTimer(t *testing.T) { + app := prtRevertTestApp() + factory := &adapterFactoryMock{} + s := newRootBondTestService(common.HexToAddress("0x600"), factory) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{ + EpochIndex: 3, Tournament: common.HexToAddress("0x300"), TxHash: &txHash, FirstMissingBlock: new(uint64(1)), + } + s.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + s.client = client + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), false, ethereum.NotFound).Once() + client.On("TransactionReceipt", mock.Anything, txHash).Return(&types.Receipt{ + TxHash: txHash, BlockNumber: big.NewInt(101), Status: types.ReceiptStatusSuccessful, + }, nil).Once() + tournament := &tournamentAdapterMock{} + factory.On("CreateTournamentAdapter", candidate.Tournament).Return(tournament, nil).Once() + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(101))). + Return(canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), nil).Once() + require.NoError(t, s.recoverRootBonds(t.Context(), app, 100)) + require.Nil(t, candidate.FirstMissingBlock) + require.Empty(t, s.rootBondRecoveries[app.ID]) + client.AssertExpectations(t) + factory.AssertExpectations(t) + tournament.AssertExpectations(t) +} diff --git a/internal/prt/consensus.go b/internal/prt/consensus.go index 7a56100b1..9b7258d81 100644 --- a/internal/prt/consensus.go +++ b/internal/prt/consensus.go @@ -88,6 +88,123 @@ func validateDaveConsensusSnapshot(snapshot daveConsensusSnapshot, mostRecentBlo return nil } +// progressTournamentResult records results at the published tournament block, +// bounded by the configured head. A separate latest snapshot decides whether +// to send a stage or accept transaction now. +// A non-nil return value is the epoch that can be joined from that latest snapshot. +// Recovery is allowed only during a known wait or to reconcile a sent refund. +func (s *Service) progressTournamentResult( + ctx context.Context, + app *model.Application, + confirmedBlock uint64, + latestBlock uint64, +) (*model.Epoch, bool, error) { + recovery, err := s.rootBondRecoveryInFlight(app.ID) + if err != nil { + return nil, false, err + } + if recovery != nil { + if s.hasNonRecoveryMutationInFlight(app.ID) { + return nil, false, fmt.Errorf("application %s has bond recovery and another PRT transaction in flight", + app.IApplicationAddress) + } + s.Logger.Info("Tournament actions wait for pending root bond recovery", + "application", app.Name, "epoch_index", recovery.EpochIndex, "tx", *recovery.TxHash) + return nil, true, nil + } + if blocked, err := s.waitForTournamentTransaction(ctx, app, latestBlock); blocked || err != nil { + return nil, false, err + } + + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + return nil, false, fmt.Errorf("binding DaveConsensus for app %s: %w", app.IApplicationAddress, err) + } + observedBlock := min(confirmedBlock, app.LastTournamentCheckBlock) + var snapshot daveConsensusSnapshot + var epoch *model.Epoch + observedSnapshot := false + // Root discovery can stay behind a moving configured head. Read the result + // at the committed window instead of waiting for those heads to coincide. + // Zero means that no tournament window has been published yet. + if observedBlock != 0 { + snapshot, err = readDaveConsensusSnapshot(ctx, consensus, observedBlock) + if err != nil && !errors.Is(err, bind.ErrNoCode) { + return nil, false, fmt.Errorf("reading DaveConsensus snapshot for app %s: %w", app.IApplicationAddress, err) + } + if err == nil { + observedSnapshot = true + epoch, err = s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), snapshot.sealed.EpochNumber) + if err != nil { + return nil, false, fmt.Errorf("loading current sealed epoch %d: %w", snapshot.sealed.EpochNumber, err) + } + if pendingTournamentResult(epoch) && (epoch.StagedAtBlock == nil || *epoch.StagedAtBlock <= observedBlock) { + if err := s.recordTournamentResult(ctx, app, epoch, snapshot); err != nil { + return nil, false, err + } + } + } else { + // A bootstrap window can precede consensus deployment. This does + // not prevent a submitter from checking the latest chain below. + s.Logger.Debug("DaveConsensus is not deployed at the published tournament block", + "application", app.Name, "block", observedBlock) + } + } + if !s.submissionEnabled { + return nil, false, nil + } + + if !observedSnapshot || latestBlock != observedBlock { + snapshot, err = readDaveConsensusSnapshot(ctx, consensus, latestBlock) + if err != nil { + return nil, false, fmt.Errorf("reading latest DaveConsensus snapshot for app %s: %w", app.IApplicationAddress, err) + } + if epoch == nil || epoch.Index != snapshot.sealed.EpochNumber { + epoch, err = s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), snapshot.sealed.EpochNumber) + if err != nil { + return nil, false, fmt.Errorf("loading latest sealed epoch %d: %w", snapshot.sealed.EpochNumber, err) + } + } + } + if !pendingTournamentResult(epoch) { + s.Logger.Debug("Local epoch is not ready for tournament result processing", + "application", app.Name, + "epoch_index", snapshot.sealed.EpochNumber) + // A failed root has no remaining join or stage action to prioritize. + return nil, snapshot.stage.IsTournamentFailed, nil + } + if err := s.validateResultEpoch(ctx, app, epoch, snapshot.sealed.EpochNumber); err != nil { + return nil, false, err + } + // Latest evidence may prevent this action, but cannot change stored epoch + // or application status. Re-read it on the next tick after a race or reorg. + if err := matchConsensusSnapshotToEpoch(epoch, snapshot); err != nil { + return nil, false, fmt.Errorf("latest tournament result does not match local epoch: %w", err) + } + if snapshot.stage.IsTournamentFailed { + return nil, true, nil + } + if snapshot.sealed.IsTournamentResultStaged { + if !snapshot.accept.DoAllSentriesAgreeWithStagedTournamentResult && + !snapshot.accept.IsClaimStagingPeriodOver { + return nil, true, nil + } + return nil, false, s.broadcastAcceptTournamentResult(ctx, app, epoch, consensus, snapshot) + } + + if epoch.Status == model.EpochStatus_ClaimStaged { + s.Logger.Info("Local staged epoch is not staged in the pinned DaveConsensus snapshot; waiting for synchronization", + "application", app.Name, + "epoch_index", epoch.Index, + "block", latestBlock) + return nil, false, nil + } + if !snapshot.stage.IsFinished { + return epoch, false, nil + } + return nil, false, s.broadcastStageTournamentResult(ctx, app, epoch, consensus) +} + func pendingTournamentResult(epoch *model.Epoch) bool { return epoch != nil && (epoch.Status == model.EpochStatus_ClaimComputed || epoch.Status == model.EpochStatus_ClaimStaged) } diff --git a/internal/prt/consensus_test.go b/internal/prt/consensus_test.go new file mode 100644 index 000000000..66823c0cf --- /dev/null +++ b/internal/prt/consensus_test.go @@ -0,0 +1,732 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func TestCheckedUint64(t *testing.T) { + require.Equal(t, uint64(42), mustCheckedUint64(t, big.NewInt(42))) + for _, value := range []*big.Int{ + nil, + big.NewInt(-1), + new(big.Int).Lsh(common.Big1, 64), + } { + _, err := checkedUint64(value, "test field") + require.ErrorContains(t, err, "test field is not a uint64") + } +} + +func mustCheckedUint64(t *testing.T, value *big.Int) uint64 { + t.Helper() + result, err := checkedUint64(value, "test field") + require.NoError(t, err) + return result +} + +func TestDaveMachineValidityProofUsesContractOrderAndOwnsSiblings(t *testing.T) { + proof := model.StateProof{ + IflagsYDataBlock: common.HexToHash("0x11"), IflagsYProof: [][32]byte{{0x12}, {0x13}}, + HtifTohostDataBlock: common.HexToHash("0x21"), HtifTohostProof: [][32]byte{{0x22}, {0x23}}, + TxBufferDataBlock: common.HexToHash("0x31"), TxBufferProof: [][32]byte{{0x32}, {0x33}}, + } + wire := daveMachineValidityProof(proof) + require.Equal(t, [32]byte(proof.IflagsYDataBlock), wire.IflagsYProof.DataBlock) + require.Equal(t, proof.IflagsYProof, wire.IflagsYProof.Siblings) + require.Equal(t, [32]byte(proof.HtifTohostDataBlock), wire.HtifTohostProof.DataBlock) + require.Equal(t, proof.HtifTohostProof, wire.HtifTohostProof.Siblings) + require.Equal(t, [32]byte(proof.TxBufferDataBlock), wire.TxBufferProof.DataBlock) + require.Equal(t, proof.TxBufferProof, wire.TxBufferProof.Siblings) + wire.IflagsYProof.Siblings[0][0] ^= 0xff + wire.HtifTohostProof.Siblings[0][0] ^= 0xff + wire.TxBufferProof.Siblings[0][0] ^= 0xff + require.Equal(t, byte(0x12), proof.IflagsYProof[0][0]) + require.Equal(t, byte(0x22), proof.HtifTohostProof[0][0]) + require.Equal(t, byte(0x32), proof.TxBufferProof[0][0]) +} + +func TestReadDaveConsensusSnapshotUsesOnePinnedCallOptions(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + snapshot := resultTestSnapshot(epoch, true) + consensus := &daveConsensusAdapterMock{} + var first *bind.CallOpts + checkOpts := func(args mock.Arguments) { + opts := args.Get(0).(*bind.CallOpts) + require.Equal(t, int64(20), opts.BlockNumber.Int64()) + if first == nil { + first = opts + } else { + require.Same(t, first, opts) + } + } + consensus.On("GetCurrentSealedEpoch", mock.Anything).Run(checkOpts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", mock.Anything).Run(checkOpts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Run(checkOpts).Return(snapshot.accept, nil).Once() + + got, err := readDaveConsensusSnapshot(context.Background(), consensus, 20) + require.NoError(t, err) + require.Equal(t, snapshot, got) + consensus.AssertExpectations(t) +} + +func TestValidateDaveConsensusSnapshotRejectsInconsistentViews(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + tests := []struct { + name string + mutate func(*daveConsensusSnapshot) + want string + }{ + {name: "epoch number", mutate: func(s *daveConsensusSnapshot) { s.accept.EpochNumber++ }, want: "epoch numbers"}, + {name: "staged flag", mutate: func(s *daveConsensusSnapshot) { + s.accept.IsTournamentResultStaged = false + }, want: "staged flags"}, + {name: "staging block", mutate: func(s *daveConsensusSnapshot) { s.sealed.StagingBlockNumber = 21 }, want: "staging block"}, + {name: "staged machine", mutate: func(s *daveConsensusSnapshot) { + s.accept.StagedPostEpochMachineStateHash[0] ^= 0xff + }, want: "staged machine state differs"}, + {name: "staged outputs", mutate: func(s *daveConsensusSnapshot) { + s.accept.StagedPostEpochOutputsMerkleRoot[0] ^= 0xff + }, want: "staged outputs root differs"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + snapshot := resultTestSnapshot(epoch, true) + test.mutate(&snapshot) + require.ErrorContains(t, validateDaveConsensusSnapshot(snapshot, 20), test.want) + }) + } +} + +func TestProgressTournamentResultReaderReconcilesStagedResult(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + service, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, true), false) + repo.On("UpdateEpochReconciledStaged", mock.Anything, epoch.ApplicationID, epoch.Index, uint64(10)). + Return(nil).Once() + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.NotNil(t, epoch.StagedAtBlock) + require.Equal(t, uint64(10), *epoch.StagedAtBlock) + consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestProgressTournamentResultReaderDoesNotStageResult(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, false), false) + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestProgressTournamentResultUsesLatestWithoutPublishedConsensus(t *testing.T) { + for _, publishedBlock := range []uint64{0, 99} { + name := "no published window" + if publishedBlock != 0 { + name = "published window precedes deployment" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock = publishedBlock + if publishedBlock != 0 { + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(publishedBlock))). + Return(CurrentSealedEpoch{}, bind.ErrNoCode).Once() + } + latest := resultTestSnapshot(f.epoch, false) + latest.stage.IsFinished = false + f.expectSnapshot(120, latest) + + epoch, _, err := f.s.progressTournamentResult(t.Context(), f.app, 100, 120) + require.NoError(t, err) + require.Same(t, f.epoch, epoch, "latest join selection must remain available") + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + f.consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(0))) + }) + } +} + +func TestProgressTournamentResultReaderWaitsWithoutPublishedConsensus(t *testing.T) { + for _, publishedBlock := range []uint64{0, 99} { + t.Run(new(big.Int).SetUint64(publishedBlock).String(), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = publishedBlock + consensus := &daveConsensusAdapterMock{} + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + s.adapterFactory = factory + if publishedBlock != 0 { + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(publishedBlock))). + Return(CurrentSealedEpoch{}, bind.ErrNoCode).Once() + } + + epoch, _, err := s.progressTournamentResult(t.Context(), app, 100, 100) + require.NoError(t, err) + require.Nil(t, epoch) + require.Empty(t, repo.Calls) + consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(100))) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + }) + } +} + +func TestProgressTournamentResultReusesPublishedSnapshotAtLatest(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock = 100 + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + f.expectSnapshot(100, snapshot) + + epoch, _, err := f.s.progressTournamentResult(t.Context(), f.app, 100, 100) + require.NoError(t, err) + require.Same(t, f.epoch, epoch) + f.consensus.AssertNumberOfCalls(t, "GetCurrentSealedEpoch", 1) +} + +func TestProgressTournamentResultPreservesObservedReadError(t *testing.T) { + s, repo := newPRTServiceMock() + s.submissionEnabled = true + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 99 + consensus := &daveConsensusAdapterMock{} + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + s.adapterFactory = factory + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(99))). + Return(CurrentSealedEpoch{}, context.DeadlineExceeded).Once() + + epoch, _, err := s.progressTournamentResult(t.Context(), app, 100, 120) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, epoch) + require.Empty(t, repo.Calls) + consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(120))) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) +} + +func TestProgressTournamentResultReloadsLatestEpochAfterPublishedResult(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock = 99 + observed := resultTestSnapshot(f.epoch, false) + observed.stage.IsFinished = false + f.expectSnapshot(99, observed) + latestEpoch := *f.epoch + latestEpoch.Index++ + latestEpoch.TournamentAddress = new(common.HexToAddress("0x201")) + latest := resultTestSnapshot(&latestEpoch, false) + latest.stage.IsFinished = false + f.expectSnapshot(120, latest) + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), latestEpoch.Index).Return(&latestEpoch, nil).Once() + + epoch, _, err := f.s.progressTournamentResult(t.Context(), f.app, 100, 120) + require.NoError(t, err) + require.Same(t, &latestEpoch, epoch) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) +} + +func TestProgressTournamentResultWaitsBelowStoredStagingBlock(t *testing.T) { + f := newPRTBlockPolicyFixture(t, false) + f.app.LastTournamentCheckBlock = 99 + f.epoch.Status = model.EpochStatus_ClaimStaged + f.epoch.StagedAtBlock = new(uint64(100)) + older := resultTestSnapshot(f.epoch, true) + older.sealed.StagingBlockNumber = 90 + f.expectSnapshot(99, older) + + epoch, _, err := f.s.progressTournamentResult(t.Context(), f.app, 101, 101) + require.NoError(t, err) + require.Nil(t, epoch) + require.Equal(t, new(uint64(100)), f.epoch.StagedAtBlock) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) +} + +func TestProgressTournamentResultRejectsInputBoundsMismatch(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*daveConsensusSnapshot) + }{ + {name: "lower bound", mutate: func(snapshot *daveConsensusSnapshot) { + snapshot.sealed.InputIndexLowerBound++ + }}, + {name: "upper bound", mutate: func(snapshot *daveConsensusSnapshot) { + snapshot.sealed.InputIndexUpperBound++ + }}, + } { + t.Run(test.name, func(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + test.mutate(&snapshot) + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(reasonContains("input bounds inconsistent"))).Return(nil).Once() + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.Error(t, err) + require.Nil(t, joinEpoch) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + require.Empty(t, service.rootBondRecoveries[app.ID]) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + }) + } +} + +func TestProgressTournamentResultKeepsFailedRootObservable(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.Nil(t, joinEpoch) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestProgressTournamentResultAcceptsWhenSentriesAgreeOrPeriodIsOver(t *testing.T) { + for _, test := range []struct { + name string + agree bool + period bool + accepts bool + }{ + {name: "sentries agree", agree: true, accepts: true}, + {name: "zero period is over", period: true, accepts: true}, + {name: "not ready", accepts: false}, + } { + t.Run(test.name, func(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + snapshot := resultTestSnapshot(epoch, true) + snapshot.accept.DoAllSentriesAgreeWithStagedTournamentResult = test.agree + snapshot.accept.IsClaimStagingPeriodOver = test.period + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + if test.accepts { + tx := types.NewTx(&types.LegacyTx{Nonce: 7}) + consensus.On("AcceptStagedTournamentResult", mock.Anything, epoch.Index).Return(tx, nil).Once() + } + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), prtRevertTestApp(), 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + if test.accepts { + require.Contains(t, service.pendingTransactions, epoch.ApplicationID) + } else { + require.NotContains(t, service.pendingTransactions, epoch.ApplicationID) + } + repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + consensus.AssertExpectations(t) + }) + } +} + +func TestProgressTournamentResultRejectsIncompleteProofBeforeBroadcast(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, false), true) + app := prtRevertTestApp() + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(reasonContains("cannot stage", "proof is incomplete"))).Return(nil).Once() + + _, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.Error(t, err) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestProgressTournamentResultDefersCompleteProofValidationToContract(t *testing.T) { + proof := repotest.KeccakStateProof(common.HexToHash("0x1234")) + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + applyPRTStateProof(epoch, proof) + epoch.TxBufferProof[0][0] ^= 0xff + corruptSibling := [32]byte(epoch.TxBufferProof[0]) + snapshot := resultTestSnapshot(epoch, false) + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + service.client = client + service.defaultBlock = model.DefaultBlock_Finalized + opts := mock.MatchedBy(resultCallOptsAtBlock(20)) + consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + factory := service.adapterFactory.(*adapterFactoryMock) + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + consensus.On("StageTournamentResult", mock.Anything, epoch.Index, + mock.MatchedBy(func(got model.StateProof) bool { + return got.TxBufferProof[0] == corruptSibling + })).Return((*types.Transaction)(nil), daveConsensusRevertError("InvalidMachineMerkleProof")).Once() + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(reasonContains("Check proof serialization", "stored proof data"))).Return(nil).Once() + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.Empty(t, service.pendingTransactions) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + client.AssertExpectations(t) + factory.AssertExpectations(t) +} + +func TestProgressTournamentResultStagesCompleteProof(t *testing.T) { + proof := repotest.KeccakStateProof(common.HexToHash("0x1234")) + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + applyPRTStateProof(epoch, proof) + snapshot := resultTestSnapshot(epoch, false) + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + tx := types.NewTx(&types.LegacyTx{Nonce: 8}) + consensus.On("StageTournamentResult", mock.Anything, epoch.Index, + mock.MatchedBy(func(got model.StateProof) bool { + return got.IflagsYDataBlock == proof.IflagsYDataBlock && + got.HtifTohostDataBlock == proof.HtifTohostDataBlock && + got.TxBufferDataBlock == proof.TxBufferDataBlock && + len(got.IflagsYProof) == model.StateProofSiblingCount && + len(got.HtifTohostProof) == model.StateProofSiblingCount && + len(got.TxBufferProof) == model.StateProofSiblingCount + })).Return(tx, nil).Once() + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), prtRevertTestApp(), 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.Contains(t, service.pendingTransactions, epoch.ApplicationID) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestCheckEpochsAcceptsComputedOrStagedEpochFromExactEpochSealedEvent(t *testing.T) { + for _, status := range []model.EpochStatus{ + model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimStaged, + } { + t.Run(status.String(), func(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(status) + epoch.LastBlock = 10 + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + wrongEmitterLog := &types.Log{ + Address: common.HexToAddress("0xbad"), TxHash: acceptTx, BlockNumber: 12, Index: 1, + } + wrongHashLog := &types.Log{ + Address: app.IConsensusAddress, TxHash: common.HexToHash("0xbad"), BlockNumber: 12, Index: 2, + } + staleLog := &types.Log{Address: app.IConsensusAddress, TxHash: acceptTx, BlockNumber: 12, Index: 3} + exactLog := &types.Log{Address: app.IConsensusAddress, TxHash: acceptTx, BlockNumber: 12, Index: 4} + receipt := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: acceptTx, + BlockNumber: big.NewInt(12), + Logs: []*types.Log{wrongEmitterLog, wrongHashLog, staleLog, exactLog}, + } + staleEvent := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(99), + Raw: *staleLog, + } + exactEvent := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: new(big.Int).SetUint64(epoch.Index + 1), + InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, + Raw: *exactLog, + } + + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, + mock.MatchedBy(func(filter repository.EpochFilter) bool { + return filter.HasTournament != nil && *filter.HasTournament && len(filter.Status) == 0 + }), repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{ + ApplicationID: app.ID, + EpochIndex: epoch.Index, + Address: *epoch.TournamentAddress, + MaxLevel: 3, + Level: 0, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9}, + }, nil).Once() + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, + mock.MatchedBy(func(hash *common.Hash) bool { return hash != nil && *hash == acceptTx })). + Return(nil).Once() + + consensus := &daveConsensusAdapterMock{} + consensus.On("ParseEpochSealed", *staleLog).Return(staleEvent, nil).Once() + consensus.On("ParseEpochSealed", *exactLog).Return(exactEvent, nil).Once() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(receipt, nil).Once() + + service, _ := newPRTServiceMock() + service.repository = repo + service.adapterFactory = factory + service.client = client + service.submissionEnabled = true + deferActions, err := service.checkEpochs(context.Background(), app, 20) + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, []*rootBondRecovery{{ + EpochIndex: epoch.Index, + Tournament: *epoch.TournamentAddress, + }}, service.rootBondRecoveries[app.ID]) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestCheckEpochsValidatesAcceptanceReceiptAgainstPinnedHead(t *testing.T) { + for _, test := range []struct { + name string + receipt *types.Receipt + wantErr string + }{ + { + name: "wrong transaction hash", + receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0xbad"), + BlockNumber: big.NewInt(12), + }, + wantErr: "differs from observed hash", + }, + { + name: "receipt after pinned head", + receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0x700"), + BlockNumber: big.NewInt(21), + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9}}, nil).Once() + consensus := &daveConsensusAdapterMock{} + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(test.receipt, nil).Once() + service, _ := newPRTServiceMock() + service.repository = repo + service.adapterFactory = factory + service.client = client + + deferActions, err := service.checkEpochs(context.Background(), app, 20) + if test.wantErr == "" { + require.NoError(t, err) + require.True(t, deferActions) + } else { + require.ErrorContains(t, err, test.wantErr) + } + repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + consensus.AssertNotCalled(t, "ParseEpochSealed", mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestValidateApplicationDefersActionsForAcceptanceAfterPinnedHead(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9}}, nil).Once() + consensus := &daveConsensusAdapterMock{} + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: acceptTx, + BlockNumber: big.NewInt(21), + }, nil).Once() + service, _ := newPRTServiceMock() + service.repository = repo + service.adapterFactory = factory + service.client = client + service.defaultBlock = model.DefaultBlock_Finalized + + require.NoError(t, runPRTApplicationTick(service, app)) + consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.Anything) + consensus.AssertNotCalled(t, "CanStageTournamentResult", mock.Anything) + consensus.AssertNotCalled(t, "CanAcceptStagedTournamentResult", mock.Anything) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) +} + +func resultCallOptsAtBlock(block uint64) func(*bind.CallOpts) bool { + return func(opts *bind.CallOpts) bool { + return opts != nil && opts.BlockNumber != nil && opts.BlockNumber.IsUint64() && opts.BlockNumber.Uint64() == block + } +} + +func resultTestEpoch(status model.EpochStatus) *model.Epoch { + tournament := common.HexToAddress("0x200") + commitment := common.HexToHash("0x300") + machineHash := common.HexToHash("0x400") + outputsRoot := common.HexToHash("0x500") + epoch := &model.Epoch{ + ApplicationID: 7, + Index: 3, + InputIndexLowerBound: 4, + InputIndexUpperBound: 9, + Status: status, + TournamentAddress: &tournament, + Commitment: &commitment, + MachineHash: &machineHash, + TxBufferDataBlock: &outputsRoot, + } + if status == model.EpochStatus_ClaimStaged { + epoch.StagedAtBlock = new(uint64) + *epoch.StagedAtBlock = 10 + } + return epoch +} + +func resultTestSnapshot(epoch *model.Epoch, staged bool) daveConsensusSnapshot { + snapshot := daveConsensusSnapshot{ + sealed: CurrentSealedEpoch{ + EpochNumber: epoch.Index, + InputIndexLowerBound: epoch.InputIndexLowerBound, + InputIndexUpperBound: epoch.InputIndexUpperBound, + Tournament: *epoch.TournamentAddress, + IsTournamentResultStaged: staged, + }, + stage: CanStageTournamentResult{ + IsFinished: true, + IsTournamentResultStaged: staged, + EpochNumber: epoch.Index, + WinnerCommitment: *epoch.Commitment, + WinnerPostEpochMachineStateHash: *epoch.MachineHash, + }, + accept: CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: staged, + EpochNumber: epoch.Index, + }, + } + if staged { + snapshot.sealed.StagingBlockNumber = 10 + snapshot.sealed.StagedPostEpochMachineStateHash = *epoch.MachineHash + snapshot.sealed.StagedPostEpochOutputsMerkleRoot = *epoch.TxBufferDataBlock + snapshot.accept.StagedPostEpochMachineStateHash = *epoch.MachineHash + snapshot.accept.StagedPostEpochOutputsMerkleRoot = *epoch.TxBufferDataBlock + } + return snapshot +} + +func resultTestService( + t *testing.T, + epoch *model.Epoch, + snapshot daveConsensusSnapshot, + submissionEnabled bool, +) (*Service, *prtRepositoryMock, *daveConsensusAdapterMock) { + t.Helper() + app := prtRevertTestApp() + app.IConsensusAddress = common.HexToAddress("0x100") + repo := &prtRepositoryMock{} + repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", mock.Anything).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Return(snapshot.accept, nil).Once() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + service, _ := newPRTServiceMock() + service.repository = repo + service.adapterFactory = factory + service.submissionEnabled = submissionEnabled + service.submissionTimeout = time.Second + service.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: common.HexToAddress("0x600")}) + return service, repo, consensus +} + +func applyPRTStateProof(epoch *model.Epoch, proof model.StateProof) { + epoch.MachineHash = new(common.Hash) + *epoch.MachineHash = proof.MachineHash + epoch.TxBufferDataBlock = new(common.Hash) + *epoch.TxBufferDataBlock = proof.TxBufferDataBlock + epoch.TxBufferProof = stateProofHashes(proof.TxBufferProof) + epoch.IflagsYDataBlock = new(common.Hash) + *epoch.IflagsYDataBlock = proof.IflagsYDataBlock + epoch.IflagsYProof = stateProofHashes(proof.IflagsYProof) + epoch.HtifTohostDataBlock = new(common.Hash) + *epoch.HtifTohostDataBlock = proof.HtifTohostDataBlock + epoch.HtifTohostProof = stateProofHashes(proof.HtifTohostProof) +} + +func stateProofHashes(proof [][32]byte) []common.Hash { + result := make([]common.Hash, len(proof)) + for i := range proof { + result[i] = proof[i] + } + return result +} diff --git a/internal/prt/fixture_test.go b/internal/prt/fixture_test.go index b9d0bdcde..19780e986 100644 --- a/internal/prt/fixture_test.go +++ b/internal/prt/fixture_test.go @@ -160,6 +160,11 @@ func (m *prtRepositoryMock) GetCommitment( return c, args.Error(1) } +func (m *prtRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + func (m *prtRepositoryMock) SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { args := m.Called(ctx, key, rawJSON) return args.Error(0) @@ -194,6 +199,12 @@ type ethClientMock struct { var _ EthClientInterface = (*ethClientMock)(nil) +func (m *ethClientMock) SuggestGasPrice(ctx context.Context) (*big.Int, error) { + args := m.Called(ctx) + price, _ := args.Get(0).(*big.Int) + return price, args.Error(1) +} + func (m *ethClientMock) TransactionReceipt( ctx context.Context, txHash common.Hash, diff --git a/internal/prt/foreclosure_recovery_drain_test.go b/internal/prt/foreclosure_recovery_drain_test.go new file mode 100644 index 000000000..8b0a8c5b9 --- /dev/null +++ b/internal/prt/foreclosure_recovery_drain_test.go @@ -0,0 +1,138 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func TestForeclosedClaimsDrainWhileTournamentTransactionIsPending(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + app := prtForeclosedApp(1, 100) + s := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + s.submissionEnabled = true + s.queueRootBondRecovery(app.ID, 3, common.HexToAddress("0x300")) + txHash := common.HexToHash("0x400") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: action, Hash: txHash, EpochIndex: 3} + client := ðClientMock{} + s.client = client + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), true, nil).Once() + repo := s.repository.(*prtRepositoryMock) + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(true, nil).Once() + repo.On("ListEpochs", mock.Anything, app.Name, + repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{{Index: 3}, {Index: 2, ClaimTransactionHash: &txHash}}, uint64(2), nil).Once() + repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(3)).Return(nil).Once() + require.NoError(t, s.handleForeclosedApp(t.Context(), app, expectEmptyForeclosedObservation(repo, app))) + require.Contains(t, s.pendingTransactions, app.ID) + require.Nil(t, s.rootBondRecoveries[app.ID][0].TxHash, "no recovery broadcast while another transaction is pending") + repo.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(2)) + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestForeclosedDrainRetainsRecoveryErrorOnEveryReturn(t *testing.T) { + const ( + observationErrorCase = "observation error" + ingestionIncompleteCase = "ingestion incomplete" + inputsPendingCase = "inputs pending" + inputQueryErrorCase = "input query error" + claimsDrainedCase = "claims drained" + claimQueryErrorCase = "claim query error" + epochQueryErrorCase = "epoch query error" + epochWriteErrorCase = "epoch write error" + shutdownCase = "shutdown" + ) + recoveryErr := errors.New("recovery provider unavailable") + drainErr := errors.New("drain failed") + for _, path := range []string{ + observationErrorCase, ingestionIncompleteCase, inputsPendingCase, inputQueryErrorCase, claimsDrainedCase, + claimQueryErrorCase, epochQueryErrorCase, epochWriteErrorCase, "claim terminalized", shutdownCase, + } { + t.Run(path, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + app := prtForeclosedApp(1, 100) + s := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + s.submissionEnabled = true + s.queueRootBondRecovery(app.ID, 3, common.HexToAddress("0x300")) + client := ðClientMock{} + s.client = client + client.On("BlockNumber", mock.Anything).Run(func(mock.Arguments) { + if path == shutdownCase { + cancel() + } + }).Return(uint64(0), recoveryErr).Once() + repo := s.repository.(*prtRepositoryMock) + var observation func() (uint64, error) + if path == observationErrorCase { + observation = func() (uint64, error) { return 0, drainErr } + } else { + observation = expectEmptyForeclosedObservation(repo, app) + } + if path == ingestionIncompleteCase { + app.LastInputCheckBlock = 99 + } + if path != observationErrorCase && path != ingestionIncompleteCase && path != shutdownCase { + var inputErr error + if path == inputQueryErrorCase { + inputErr = drainErr + } + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock). + Return(path == inputsPendingCase, inputErr).Once() + if path != inputsPendingCase && inputErr == nil { + var claimErr error + if path == claimQueryErrorCase { + claimErr = drainErr + } + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock). + Return(path != claimsDrainedCase, claimErr).Once() + if path != claimsDrainedCase && claimErr == nil { + var epochErr error + if path == epochQueryErrorCase { + epochErr = drainErr + } + repo.On("ListEpochs", mock.Anything, app.Name, + repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{{Index: 3}}, uint64(1), epochErr).Once() + if epochErr == nil { + if path == epochWriteErrorCase { + epochErr = drainErr + } + repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(3)).Return(epochErr).Once() + } + } + } + } + + err := s.handleForeclosedApp(ctx, app, observation) + require.ErrorIs(t, err, recoveryErr) + if path == shutdownCase { + require.ErrorIs(t, err, context.Canceled) + } + if path == observationErrorCase || path == inputQueryErrorCase || path == claimQueryErrorCase || + path == epochQueryErrorCase || path == epochWriteErrorCase { + require.ErrorIs(t, err, drainErr) + } + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/handle_foreclosed_test.go b/internal/prt/handle_foreclosed_test.go index 2be4a46ae..8f41b0f0b 100644 --- a/internal/prt/handle_foreclosed_test.go +++ b/internal/prt/handle_foreclosed_test.go @@ -4,13 +4,18 @@ package prt import ( + "bytes" "context" "errors" + "log/slog" + "math/big" "testing" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -35,6 +40,14 @@ func prtForeclosedApp(id int64, block uint64) *model.Application { } } +// An empty root query still proves that passive observation ran. It must +// include all tournament-bearing epochs, not only particular claim states. +func expectEmptyForeclosedObservation(r *prtRepositoryMock, app *model.Application) func() (uint64, error) { + r.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch{}, uint64(0), nil).Once() + return func() (uint64, error) { return 120, nil } +} + // TestHandleForeclosedApp_NoOpWhenForecloseBlockZero verifies the guard at // the top of handleForeclosedApp. The PRT Tick passes every running app // through this function; only those with a non-zero ForecloseBlock should @@ -44,26 +57,182 @@ func TestHandleForeclosedApp_NoOpWhenForecloseBlockZero(t *testing.T) { defer r.AssertExpectations(t) app := &model.Application{ID: 1, ConsensusType: model.Consensus_PRT} - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + require.NoError(t, s.handleForeclosedApp(context.Background(), app, nil)) +} + +func TestHandleForeclosedAppRecoversQueuedRootBond(t *testing.T) { + app := prtForeclosedApp(1, 100) + app.IConsensusAddress = common.HexToAddress("0x200") + owned := common.HexToAddress("0x600") + tournamentAddress := common.HexToAddress("0x300") + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.submissionEnabled = true + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + acceptTx := common.HexToHash("0x400") + service.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionAccept, Hash: acceptTx, EpochIndex: 3, + } + client := ðClientMock{} + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("TransactionByHash", mock.Anything, acceptTx). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, TxHash: acceptTx, BlockNumber: big.NewInt(119), + }, nil).Once() + service.client = client + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + service.repository.(*prtRepositoryMock).On( + "HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Twice() + + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + client.On("BlockNumber", mock.Anything).Return(uint64(121), nil).Once() + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.NotNil(t, service.rootBondRecoveries[app.ID][0].TxHash) + factory.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + client.AssertExpectations(t) +} + +func TestHandleForeclosedAppReconcilesInFlightRootBondRecovery(t *testing.T) { + app := prtForeclosedApp(1, 100) + owned := common.HexToAddress("0x600") + tournamentAddress := common.HexToAddress("0x300") + recoveryTx := common.HexToHash("0x400") + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.submissionEnabled = true + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{{ + EpochIndex: 3, + Tournament: tournamentAddress, + TxHash: &recoveryTx, + }} + client := ðClientMock{} + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("TransactionByHash", mock.Anything, recoveryTx). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, recoveryTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: recoveryTx, + BlockNumber: big.NewInt(119), + }, nil).Once() + service.client = client + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + service.repository.(*prtRepositoryMock).On( + "HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.Empty(t, service.rootBondRecoveries[app.ID]) + factory.AssertExpectations(t) + tournament.AssertExpectations(t) + client.AssertExpectations(t) } -func TestGetAllRunningApplications_UsesPRTTickFilter(t *testing.T) { +func TestHandleForeclosedAppReconcilesJoinBeforeQueuedRootBond(t *testing.T) { + for _, pending := range []bool{true, false} { + name := "mined" + if pending { + name = "pending" + } + t.Run(name, func(t *testing.T) { + app := prtForeclosedApp(1, 100) + app.IConsensusAddress = common.HexToAddress("0x200") + owned := common.HexToAddress("0x600") + tournamentAddress := common.HexToAddress("0x300") + joinTx := common.HexToHash("0x400") + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.submissionEnabled = true + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + service.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionJoin, Hash: joinTx, EpochIndex: 3, + } + client := ðClientMock{} + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("TransactionByHash", mock.Anything, joinTx). + Return(types.NewTx(&types.LegacyTx{}), pending, nil).Once() + service.client = client + + if pending { + service.repository.(*prtRepositoryMock).On( + "HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.Contains(t, service.pendingTransactions, app.ID) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + } else { + client.On("TransactionReceipt", mock.Anything, joinTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, TxHash: joinTx, BlockNumber: big.NewInt(119), + }, nil).Once() + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + service.repository.(*prtRepositoryMock).On( + "HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Twice() + + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.NotContains(t, service.pendingTransactions, app.ID) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + client.On("BlockNumber", mock.Anything).Return(uint64(121), nil).Once() + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.NotNil(t, service.rootBondRecoveries[app.ID][0].TxHash) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + } + service.repository.(*prtRepositoryMock).AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestGetObservableApplications_IncludesUnhealthyApps(t *testing.T) { r := &prtRepositoryMock{} r.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { return f.Enabled != nil && *f.Enabled && f.ConsensusType != nil && *f.ConsensusType == model.Consensus_PRT && - assert.ElementsMatch(t, - []model.ApplicationStatus{model.ApplicationStatus_OK}, - f.Statuses, - ) + len(f.Statuses) == 0 }), repository.Pagination{}, false, ).Return([]*model.Application{}, uint64(0), nil).Once() - _, _, err := getAllRunningApplications(context.Background(), r) + _, _, err := getObservableApplications(context.Background(), r) require.NoError(t, err) r.AssertExpectations(t) } @@ -72,7 +241,7 @@ func TestGetAllRunningApplications_UsesPRTTickFilter(t *testing.T) { // pre-foreclosure-work guard. While the advancer/validator have epochs to // process before the foreclose block, the PRT app must keep its current // status. Marking it terminal early would lose the last machine state needed -// to settle any in-flight tournament. +// to process its last pre-foreclosure epoch. func TestHandleForeclosedApp_DefersWhenUndrained(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) @@ -84,20 +253,12 @@ func TestHandleForeclosedApp_DefersWhenUndrained(t *testing.T) { // No UpdateApplicationStatus expectation — see TestProcessForeclosedApps_DefersWhenUndrained // in the claimer suite for the equivalent reasoning. - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + require.NoError(t, s.handleForeclosedApp(context.Background(), app, expectEmptyForeclosedObservation(r, app))) } -// TestHandleForeclosedApp_NoOpWhenFullyDrained verifies that once every -// pre-foreclosure epoch is terminal, handleForeclosedApp is a no-op: it does -// not reconcile (no chain reads), does not foreclose, and does not touch the -// application status. The app keeps health status OK with foreclose_block set; -// evmreader picks up the post-foreclosure observation work from here. -// -// The mock registers no UpdateApplicationStatus / ListEpochs / -// UpdateEpochWithForeclosedClaim expectation; testify/mock fails the test on an -// unexpected call, so any regression that re-runs drain work on an already -// terminal app trips this test loudly. -func TestHandleForeclosedApp_NoOpWhenFullyDrained(t *testing.T) { +// A completed local drain must not stop passive tournament observation. With +// no roots, this pass only queries eligibility and checks the existing gates. +func TestHandleForeclosedApp_ObservesAfterLocalDrain(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) @@ -109,7 +270,7 @@ func TestHandleForeclosedApp_NoOpWhenFullyDrained(t *testing.T) { mock.Anything, app.ID, app.ForecloseBlock, ).Return(false, nil).Once() - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + require.NoError(t, s.handleForeclosedApp(context.Background(), app, expectEmptyForeclosedObservation(r, app))) } // TestHandleForeclosedApp_SurfacesDrainCheckError verifies the surrounding @@ -126,7 +287,7 @@ func TestHandleForeclosedApp_SurfacesDrainCheckError(t *testing.T) { mock.Anything, app.ID, app.ForecloseBlock, ).Return(false, dbErr).Once() - err := s.handleForeclosedApp(context.Background(), app) + err := s.handleForeclosedApp(context.Background(), app, expectEmptyForeclosedObservation(r, app)) require.Error(t, err) assert.ErrorIs(t, err, dbErr) } @@ -144,35 +305,33 @@ func TestHandleForeclosedApp_SurfacesDrainCheckError(t *testing.T) { // expectation registered; testify/mock panics on an unexpected call, so // either reach attempt fails the test loudly. func TestHandleForeclosedApp_DefersWhenStillBackfilling(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - - app := prtForeclosedApp(1, 100) - app.LastEpochCheckBlock = 50 // scanner is well below the foreclose block - - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) -} - -// TestHandleForeclosedApp_ProceedsAfterBackfillCatchesUp verifies the -// guard does not over-defer. Once LastEpochCheckBlock reaches the -// foreclose block, the gate is consulted normally; on a "drained=false" -// response the function returns nil silently (no terminal action — see -// TestHandleForeclosedApp_NoTransitionWhenDrained). -func TestHandleForeclosedApp_ProceedsAfterBackfillCatchesUp(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - - app := prtForeclosedApp(1, 100) - app.LastEpochCheckBlock = app.ForecloseBlock // exact-boundary case: caught up - - r.On("HasUndrainedEpochsBeforeBlock", - mock.Anything, app.ID, app.ForecloseBlock, - ).Return(false, nil).Once() - r.On("HasUnreconciledClaimsBeforeBlock", - mock.Anything, app.ID, app.ForecloseBlock, - ).Return(false, nil).Once() - - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + for _, test := range []struct { + name string + epochCursor uint64 + inputCursor uint64 + }{ + {name: "sealed epochs still landing", epochCursor: 50, inputCursor: 100}, + {name: "same-block open inputs still landing", epochCursor: 100, inputCursor: 99}, + } { + t.Run(test.name, func(t *testing.T) { + s, r := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := prtForeclosedApp(1, 100) + app.LastEpochCheckBlock = test.epochCursor + app.LastInputCheckBlock = test.inputCursor + + require.NoError(t, s.handleForeclosedApp(t.Context(), app, expectEmptyForeclosedObservation(r, app))) + require.Contains(t, output.String(), "sealed epochs and inputs") + require.Contains(t, output.String(), "last_epoch_check_block="+new(big.Int).SetUint64(test.epochCursor).String()) + require.Contains(t, output.String(), "last_input_check_block="+new(big.Int).SetUint64(test.inputCursor).String()) + require.Contains(t, output.String(), "foreclose_block=100") + r.AssertNotCalled(t, "HasUndrainedEpochsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + r.AssertNotCalled(t, "HasUnreconciledClaimsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) + }) + } } // TestHandleForeclosedApp_SurfacesReconciliationCheckError verifies the @@ -191,25 +350,25 @@ func TestHandleForeclosedApp_SurfacesReconciliationCheckError(t *testing.T) { mock.Anything, app.ID, app.ForecloseBlock, ).Return(false, dbErr).Once() - err := s.handleForeclosedApp(context.Background(), app) + err := s.handleForeclosedApp(context.Background(), app, expectEmptyForeclosedObservation(r, app)) require.Error(t, err) assert.ErrorIs(t, err, dbErr) } // TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass models -// the race where checkEpochs takes its CLAIM_COMPUTED snapshot before the -// validator computes a later epoch, but forecloseComputedEpochs sees that later -// epoch in the same tick. If the epoch already has an on-chain EpochSealed -// transaction hash, it must stay CLAIM_COMPUTED for the next reconciliation -// pass instead of being terminalized to CLAIM_FORECLOSED. +// an epoch that appears between the observation query and the local drain +// query. An on-chain EpochSealed transaction hash requires reconciliation on +// the next pass; the local drain must not mark that epoch CLAIM_FORECLOSED. func TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) app := prtForeclosedApp(1, 100) client := ðClientMock{} - client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(120)}, nil).Once() s.client = client + s.defaultBlock = model.DefaultBlock_Finalized claimTx := common.HexToHash("0xbeef") r.On("HasUndrainedEpochsBeforeBlock", @@ -219,23 +378,25 @@ func TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass(t *test mock.Anything, app.ID, app.ForecloseBlock, ).Return(true, nil).Once() r.On("ListEpochs", - mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false, + mock.Anything, app.Name, repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false, ).Return([]*model.Epoch{}, uint64(0), nil).Once() r.On("ListEpochs", - mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false, + mock.Anything, app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false, ).Return([]*model.Epoch{ {Index: 1, ClaimTransactionHash: &claimTx}, }, uint64(1), nil).Once() - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + require.NoError(t, s.handleForeclosedApp(context.Background(), app, func() (uint64, error) { + return s.getDefaultBlockNumber(context.Background()) + })) r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(1)) client.AssertExpectations(t) } -// TestForecloseComputedEpochs_TerminalizesEachComputedEpoch verifies the -// foreclose step: every CLAIM_COMPUTED epoch without an on-chain sealed-event +// TestForeclosePendingClaimEpochs_TerminalizesEachPendingEpoch verifies the +// foreclose step: every pending claim epoch without an on-chain sealed-event // transaction is transitioned to CLAIM_FORECLOSED. -func TestForecloseComputedEpochs_TerminalizesEachComputedEpoch(t *testing.T) { +func TestForeclosePendingClaimEpochs_TerminalizesEachPendingEpoch(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) @@ -243,25 +404,37 @@ func TestForecloseComputedEpochs_TerminalizesEachComputedEpoch(t *testing.T) { claimTx := common.HexToHash("0xbeef") r.On("ListEpochs", mock.Anything, app.Name, - mock.MatchedBy(func(f repository.EpochFilter) bool { - return len(f.Status) == 1 && f.Status[0] == model.EpochStatus_ClaimComputed - }), + repository.EpochFilter{Status: []model.EpochStatus{ + model.EpochStatus_Open, model.EpochStatus_Closed, model.EpochStatus_InputsProcessed, + model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged, + }}, repository.Pagination{}, false, ).Return([]*model.Epoch{ + {Index: 0, Status: model.EpochStatus_Open, FirstBlock: 100, LastBlock: 150}, + {Index: 1, Status: model.EpochStatus_Closed}, + {Index: 2, Status: model.EpochStatus_InputsProcessed}, {Index: 3}, - {Index: 4}, + {Index: 4, Status: model.EpochStatus_ClaimStaged}, {Index: 5, ClaimTransactionHash: &claimTx}, - }, uint64(3), nil).Once() + {Index: 6, Status: model.EpochStatus_ClaimSubmitted}, + {Index: 7, Status: model.EpochStatus_Open, FirstBlock: 101}, + }, uint64(8), nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(0)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(1)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(2)).Return(nil).Once() r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(3)).Return(nil).Once() r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(4)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(6)).Return(nil).Once() - require.NoError(t, s.forecloseComputedEpochs(context.Background(), app)) + require.NoError(t, s.foreclosePendingClaimEpochs(context.Background(), app)) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(5)) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(7)) } -// TestForecloseComputedEpochs_PropagatesUpdateError verifies a failed +// TestForeclosePendingClaimEpochs_PropagatesUpdateError verifies a failed // terminalization surfaces so the Tick retries rather than silently dropping // a still-non-terminal epoch. -func TestForecloseComputedEpochs_PropagatesUpdateError(t *testing.T) { +func TestForeclosePendingClaimEpochs_PropagatesUpdateError(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) @@ -271,7 +444,7 @@ func TestForecloseComputedEpochs_PropagatesUpdateError(t *testing.T) { Return([]*model.Epoch{{Index: 7}}, uint64(1), nil).Once() r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(7)).Return(dbErr).Once() - err := s.forecloseComputedEpochs(context.Background(), app) + err := s.foreclosePendingClaimEpochs(context.Background(), app) require.Error(t, err) assert.ErrorIs(t, err, dbErr) } diff --git a/internal/prt/join_log_test.go b/internal/prt/join_log_test.go new file mode 100644 index 000000000..7648dae39 --- /dev/null +++ b/internal/prt/join_log_test.go @@ -0,0 +1,68 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" +) + +func TestJoinTransactionSuccessDiagnostic(t *testing.T) { + for _, sendErr := range []error{nil, errors.New("send failed")} { + name := "sent" + if sendErr != nil { + name = "failed" + } + t.Run(name, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + factory := &adapterFactoryMock{} + s := newRootBondTestService(common.HexToAddress("0x600"), factory) + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + repo := s.repository.(*prtRepositoryMock) + repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index, + epoch.TournamentAddress.Hex(), epoch.Commitment.Hex()).Return(nil, nil).Once() + adapter := &tournamentAdapterMock{} + factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(20)) + adapter.On("CommitmentStanding", opts, [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, nil).Once() + adapter.On("Descriptor", opts). + Return(TournamentDescriptor{Height: model.Log2EpochComputationHashLeafCount}, nil).Once() + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + adapter.On("JoinTournament", mock.Anything, [32]byte(*epoch.MachineHash), + mock.Anything, mock.Anything, mock.Anything).Return(tx, sendErr).Once() + + joined, err := s.reactToTournament(t.Context(), app, epoch, 20) + require.False(t, joined) + if sendErr != nil { + require.ErrorIs(t, err, sendErr) + require.NotContains(t, output.String(), "Sent tournament join transaction") + require.Empty(t, s.pendingTransactions) + } else { + require.NoError(t, err) + require.Contains(t, output.String(), `level=INFO msg="Sent tournament join transaction"`) + require.Contains(t, output.String(), "application="+app.Name) + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), "tournament="+epoch.TournamentAddress.Hex()) + require.Contains(t, output.String(), "tx="+tx.Hash().Hex()) + } + repo.AssertExpectations(t) + factory.AssertExpectations(t) + adapter.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/legacy_fees_test.go b/internal/prt/legacy_fees_test.go new file mode 100644 index 000000000..72a6b4acc --- /dev/null +++ b/internal/prt/legacy_fees_test.go @@ -0,0 +1,135 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" +) + +func TestCreateWiresLegacyFees(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(fmt.Sprint(legacy), func(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "private_key") + viper.Set(config.PRT_AUTH_PRIVATE_KEY, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + t.Cleanup(func() { + viper.Set(config.PRT_AUTH_KIND, nil) + viper.Set(config.PRT_AUTH_PRIVATE_KEY, nil) + }) + raw, err := json.Marshal(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: true, + }) + require.NoError(t, err) + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return(raw, nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + if legacy { + client.On("SuggestGasPrice", mock.Anything).Return(big.NewInt(101), nil).Once() + client.On("SuggestGasPrice", mock.Anything).Return(big.NewInt(102), nil).Once() + } + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: time.Second, + BlockchainLegacyEnabled: legacy, + }, + EthClient: client, Repository: repo, AdapterFactory: &adapterFactoryMock{}, + }) + require.NoError(t, err) + s := svc.(*Service) + for sequence := int64(1); sequence <= 2; sequence++ { + opts, err := s.txOptsFactory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Zero(t, opts.GasLimit, "keep estimation enabled") + if legacy { + require.Equal(t, big.NewInt(100+sequence), opts.GasPrice) + } else { + require.Nil(t, opts.GasPrice) + } + } + if !legacy { + client.AssertNotCalled(t, "SuggestGasPrice", mock.Anything) + } + client.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestCreateWiresLegacyFeesWithDefaultClient(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "private_key") + viper.Set(config.PRT_AUTH_PRIVATE_KEY, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + t.Cleanup(func() { + viper.Set(config.PRT_AUTH_KIND, nil) + viper.Set(config.PRT_AUTH_PRIVATE_KEY, nil) + }) + backend := &prtLegacyFeeRPC{} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + endpoint, err := config.ToURLFromString(httpServer.URL) + require.NoError(t, err) + raw, err := json.Marshal(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: true, + }) + require.NoError(t, err) + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return(raw, nil).Once() + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainHttpEndpoint: endpoint, BlockchainHttpRequestTimeout: time.Second, + FeatureClaimSubmissionEnabled: true, BlockchainLegacyEnabled: true, + }, + Repository: repo, + }) + require.NoError(t, err) + s := svc.(*Service) + client, ok := s.client.(*ethclient.Client) + require.True(t, ok) + t.Cleanup(client.Close) + factory, ok := s.adapterFactory.(*DefaultAdapterFactory) + require.True(t, ok) + require.Same(t, client, factory.client) + for sequence := int64(1); sequence <= 2; sequence++ { + opts, err := s.txOptsFactory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Zero(t, opts.GasLimit, "keep estimation enabled") + require.Equal(t, big.NewInt(100+sequence), opts.GasPrice) + } + require.EqualValues(t, 2, backend.prices.Load()) + repo.AssertExpectations(t) +} + +type prtLegacyFeeRPC struct { + prices atomic.Int32 +} + +func (*prtLegacyFeeRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // RPC registration requires eth_chainId. + return (*hexutil.Big)(big.NewInt(42)), nil +} + +func (r *prtLegacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} diff --git a/internal/prt/missing_transaction_test.go b/internal/prt/missing_transaction_test.go new file mode 100644 index 000000000..9b1b92130 --- /dev/null +++ b/internal/prt/missing_transaction_test.go @@ -0,0 +1,286 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func expectMissingTournamentTransaction(client *ethClientMock, hash common.Hash) { + client.On("TransactionByHash", mock.Anything, hash). + Return((*types.Transaction)(nil), false, fmt.Errorf("lookup: %w", ethereum.NotFound)).Once() + client.On("TransactionReceipt", mock.Anything, hash). + Return((*types.Receipt)(nil), fmt.Errorf("receipt: %w", ethereum.NotFound)).Once() +} + +func TestMissingTournamentTransactionExpiresAfterBlockBudget(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + for _, start := range []uint64{0, 100, math.MaxUint64 - 64} { + t.Run(fmt.Sprintf("%s/from_%d", action, start), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + tx := pendingTournamentTransaction{Action: action, Hash: common.HexToHash("0xbeef"), EpochIndex: 3} + s.pendingTransactions[app.ID] = tx + s.pendingTransactions[app.ID+1] = tx + client := ðClientMock{} + s.client = client + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + + // Pin the 64-block policy independently of the implementation constant. + // Neither repeated ticks nor a much older observation head may age it. + for i, head := range []uint64{start, start, start + 63, start + 64} { + expectMissingTournamentTransaction(client, tx.Hash) + epoch, recovery, err := s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Nil(t, epoch) + require.False(t, recovery, "do not broadcast a refund on the release tick") + require.Equal(t, i != 3, s.hasNonRecoveryMutationInFlight(app.ID)) + require.Equal(t, tx, s.pendingTransactions[app.ID+1]) + require.Empty(t, repo.Calls, "release cannot change epoch or application state") + if i != 3 { + require.Empty(t, logs.String(), "a normal bounded wait is not an operational error") + } + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Contains(t, logs.String(), "level=WARN") + require.Contains(t, logs.String(), "action="+string(action)) + require.Contains(t, logs.String(), tx.Hash.Hex()) + client.AssertExpectations(t) + }) + } + } +} + +func TestMissingTournamentTransactionWaitRestartsWhenKnown(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + tx := pendingTournamentTransaction{Action: tournamentActionJoin, Hash: common.HexToHash("0xbeef")} + s.pendingTransactions[app.ID] = tx + client := ðClientMock{} + s.client = client + + expectMissingTournamentTransaction(client, tx.Hash) + _, _, err := s.progressTournamentResult(t.Context(), app, 0, 100) + require.NoError(t, err) + // Even a very old transaction must stay tracked while it is known pending. + client.On("TransactionByHash", mock.Anything, tx.Hash).Return((*types.Transaction)(nil), true, nil).Once() + _, _, err = s.progressTournamentResult(t.Context(), app, 0, 1_000) + require.NoError(t, err) + require.Equal(t, tx, s.pendingTransactions[app.ID]) + for i, head := range []uint64{1_001, 1_064, 1_065} { + expectMissingTournamentTransaction(client, tx.Hash) + _, _, err = s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Equal(t, i != 2, s.hasNonRecoveryMutationInFlight(app.ID)) + } + require.Empty(t, repo.Calls) + client.AssertExpectations(t) +} + +func TestMissingTournamentTransactionWaitRestartsOnHeadRegression(t *testing.T) { + s, _ := newPRTServiceMock() + app := prtRevertTestApp() + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: tournamentActionStage, Hash: hash} + client := ðClientMock{} + s.client = client + for i, head := range []uint64{100, 99, 100, 162, 163} { + expectMissingTournamentTransaction(client, hash) + _, _, err := s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Equal(t, i != 4, s.hasNonRecoveryMutationInFlight(app.ID)) + } + client.AssertExpectations(t) +} + +func TestMissingTournamentTransactionErrorsDoNotReleaseSlot(t *testing.T) { + for _, test := range []struct { + name string + err error + lookup bool + }{ + {"lookup failure", errors.New("provider unavailable"), true}, + {"receipt failure", errors.New("provider unavailable"), false}, + {"lookup canceled", context.Canceled, true}, + {"receipt deadline", context.DeadlineExceeded, false}, + } { + t.Run(test.name, func(t *testing.T) { + s, _ := newPRTServiceMock() + app := prtRevertTestApp() + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: tournamentActionAccept, Hash: hash} + client := ðClientMock{} + s.client = client + expectMissingTournamentTransaction(client, hash) + _, _, err := s.progressTournamentResult(t.Context(), app, 0, 100) + require.NoError(t, err) + tracked := s.pendingTransactions[app.ID] + lookupErr := test.err + if !test.lookup { + lookupErr = ethereum.NotFound + client.On("TransactionReceipt", mock.Anything, hash).Return((*types.Receipt)(nil), test.err).Once() + } + client.On("TransactionByHash", mock.Anything, hash).Return((*types.Transaction)(nil), false, lookupErr).Once() + _, _, err = s.progressTournamentResult(t.Context(), app, 0, 164) + require.ErrorIs(t, err, test.err) + require.Equal(t, tracked, s.pendingTransactions[app.ID]) + client.AssertExpectations(t) + }) + } +} + +func TestMissingTournamentTransactionUsesFreshEpochAfterRelease(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage.IsFinished = false + s, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: action, Hash: hash, EpochIndex: epoch.Index - 1} + client := ðClientMock{} + s.client = client + for _, head := range []uint64{100, 164} { + expectMissingTournamentTransaction(client, hash) + join, recovery, err := s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Nil(t, join) + require.False(t, recovery) + require.Empty(t, repo.Calls) + require.Empty(t, consensus.Calls) + } + // Another participant accepted the old epoch. Select the new epoch; + // do not replay the expired action from the old in-memory record. + join, _, err := s.progressTournamentResult(t.Context(), app, 0, 165) + require.NoError(t, err) + require.Same(t, epoch, join) + require.Empty(t, s.pendingTransactions) + client.AssertExpectations(t) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + }) + } +} + +func TestMissingStageObservesExternalStageAfterRelease(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + s, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, true), true) + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 165 + repo.On("UpdateEpochReconciledStaged", mock.Anything, app.ID, epoch.Index, uint64(10)).Return(nil).Once() + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionStage, Hash: hash, EpochIndex: epoch.Index, + } + client := ðClientMock{} + s.client = client + for _, head := range []uint64{100, 164} { + expectMissingTournamentTransaction(client, hash) + _, _, err := s.progressTournamentResult(t.Context(), app, head, head) + require.NoError(t, err) + require.Empty(t, repo.Calls) + require.Empty(t, consensus.Calls) + } + _, _, err := s.progressTournamentResult(t.Context(), app, 165, 165) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Empty(t, s.pendingTransactions, "the external stage must not trigger another stage broadcast") + client.AssertExpectations(t) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestMissingStageRetriesAfterFreshSnapshotWithEstimationEnabled(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + applyPRTStateProof(epoch, repotest.KeccakStateProof(common.HexToHash("0x1234"))) + s, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, false), true) + app := prtRevertTestApp() + oldHash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionStage, Hash: oldHash, EpochIndex: epoch.Index, + } + client := ðClientMock{} + s.client = client + for _, head := range []uint64{100, 164} { + expectMissingTournamentTransaction(client, oldHash) + _, _, err := s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Empty(t, repo.Calls) + require.Empty(t, consensus.Calls) + } + newTx := types.NewTx(&types.LegacyTx{Nonce: 8}) + consensus.On("StageTournamentResult", mock.Anything, epoch.Index, mock.Anything). + Run(func(args mock.Arguments) { + require.Zero(t, args.Get(0).(*bind.TransactOpts).GasLimit, "a retry must keep gas estimation enabled") + }).Return(newTx, nil).Once() + _, _, err := s.progressTournamentResult(t.Context(), app, 0, 165) + require.NoError(t, err) + require.Equal(t, pendingTournamentTransaction{ + Action: tournamentActionStage, Hash: newTx.Hash(), EpochIndex: epoch.Index, + }, s.pendingTransactions[app.ID]) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status, "broadcast is not stage confirmation") + client.AssertExpectations(t) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestMissingTournamentTransactionDefersForeclosedBondRecovery(t *testing.T) { + app := prtForeclosedApp(1, 100) + app.IConsensusAddress = common.HexToAddress("0x200") + owned := common.HexToAddress("0x600") + factory := &adapterFactoryMock{} + s := newRootBondTestService(owned, factory) + s.submissionEnabled = true + tournamentAddress := common.HexToAddress("0x300") + s.queueRootBondRecovery(app.ID, 3, tournamentAddress) + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionAccept, Hash: hash, EpochIndex: 3, + } + client := ðClientMock{} + s.client = client + for _, head := range []uint64{100, 164} { + client.On("BlockNumber", mock.Anything).Return(head, nil).Once() + expectMissingTournamentTransaction(client, hash) + require.NoError(t, s.recoverForeclosedRootBonds(t.Context(), app)) + require.Empty(t, factory.Calls) + require.Nil(t, s.rootBondRecoveries[app.ID][0].TxHash) + } + require.Empty(t, s.pendingTransactions) + // Recovery starts only after a new tick reads current bond ownership. + client.On("BlockNumber", mock.Anything).Return(uint64(165), nil).Once() + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(165))). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(165))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + refund := types.NewTx(&types.LegacyTx{Nonce: 1}) + tournament.On("TryRecoveringBond", mock.Anything).Return(refund, nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + require.NoError(t, s.recoverForeclosedRootBonds(t.Context(), app)) + require.Equal(t, refund.Hash(), *s.rootBondRecoveries[app.ID][0].TxHash) + client.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + factory.AssertExpectations(t) +} diff --git a/internal/prt/observation_health.go b/internal/prt/observation_health.go index d6d62953e..69cb7d687 100644 --- a/internal/prt/observation_health.go +++ b/internal/prt/observation_health.go @@ -7,6 +7,7 @@ import ( "context" "errors" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/model" ) @@ -34,7 +35,7 @@ func (s *Service) Ready() bool { func (s *Service) recordTournamentObservationFailure( ctx context.Context, app *model.Application, head, windowEnd uint64, err error, ) { - if errors.Is(ctx.Err(), context.Canceled) && errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { return } diff --git a/internal/prt/observation_health_test.go b/internal/prt/observation_health_test.go index 434120ac0..8c347962d 100644 --- a/internal/prt/observation_health_test.go +++ b/internal/prt/observation_health_test.go @@ -192,13 +192,13 @@ func TestTournamentObservationHealthIgnoresPendingAndMissingTransactions(t *test f.client.On("BlockNumber", mock.Anything).Return(head, nil).Once() f.client.On("TransactionByHash", mock.Anything, hash). Return((*types.Transaction)(nil), pending, lookupError).Once() + if !pending { + f.client.On("TransactionReceipt", mock.Anything, hash). + Return((*types.Receipt)(nil), ethereum.NotFound).Once() + } reschedule, err := f.s.Tick(t.Context()) require.False(t, reschedule) - if pending { - require.NoError(t, err) - } else { - require.ErrorIs(t, err, ethereum.NotFound) - } + require.NoError(t, err) require.True(t, f.s.Ready()) require.Empty(t, f.s.observationFailures) } @@ -235,6 +235,7 @@ func TestTournamentObservationHealthPrunesIneligibleApplications(t *testing.T) { } func TestTournamentObservationHealthIgnoresShutdownCancellation(t *testing.T) { + dbErr := errors.New("database unavailable") for _, test := range []struct { name string stopping bool @@ -242,6 +243,9 @@ func TestTournamentObservationHealthIgnoresShutdownCancellation(t *testing.T) { counted bool }{ {shutdownCancellationCase, true, context.Canceled, false}, + {"joined shutdown cancellations", true, errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), false}, + {"shutdown database failure", true, dbErr, true}, + {"shutdown mixed database failure", true, fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), true}, {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, {"shutdown joined deadline", true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, {runningCancellationCase, false, context.Canceled, true}, diff --git a/internal/prt/observation_shutdown_test.go b/internal/prt/observation_shutdown_test.go new file mode 100644 index 000000000..b4a34d665 --- /dev/null +++ b/internal/prt/observation_shutdown_test.go @@ -0,0 +1,314 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + shutdownCancellationCase = "shutdown cancellation" + shutdownDeadlineCase = "shutdown deadline" + shutdownMixedDeadlineCase = "shutdown cancellation and deadline" + runningCancellationCase = "running cancellation" +) + +func TestTournamentPublicationShutdown(t *testing.T) { + dbErr := errors.New("database write failed") + for _, test := range []struct { + name string + cause error + wantErr bool + }{ + {"canceled write", fmt.Errorf("write: %w", context.Canceled), false}, + {"database failure", dbErr, true}, + {"mixed failure", errors.Join(context.Canceled, dbErr), true}, + {"deadline", context.DeadlineExceeded, true}, + } { + for _, throughServe := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/serve=%t", test.name, throughServe), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + f := newObserverCheckpointFixture(t) + var logs bytes.Buffer + require.NoError(t, service.InitTickServiceTemplate(&f.s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: "prt", Logger: slog.New(slog.NewTextHandler(&logs, nil))}, + }, f.s)) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.repo.On("ListApplications", ctx, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + root := f.tournament(epoch, *epoch.TournamentAddress, 0, 2, 0, 100, &TournamentEvents{}, nil) + f.children(epoch, root) + f.repo.On("StoreTournamentEvents", ctx, f.app.ID, mock.Anything, uint64(100)). + Run(func(mock.Arguments) { cancel() }).Return(test.cause).Once() + pending := pendingTournamentTransaction{Action: tournamentActionStage, Hash: common.HexToHash("0x999")} + f.s.pendingTransactions[f.app.ID] = pending + + if throughServe { + require.ErrorIs(t, f.s.Serve(ctx), context.Canceled) + require.Equal(t, test.wantErr, strings.Contains(logs.String(), "level=ERROR msg=Tick"), logs.String()) + } else { + reschedule, err := f.s.Tick(ctx) + require.False(t, reschedule) + if test.wantErr { + require.ErrorIs(t, err, test.cause) + } else { + require.NoError(t, err) + } + } + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock, "an interrupted write must not publish its cursor") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Equal(t, pending, f.s.pendingTransactions[f.app.ID], "failed observation must not release a pending action") + require.Equal(t, test.wantErr, len(f.s.observationFailures) == 1) + }) + } + } +} + +func TestTournamentObservationShutdownLogs(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + stopping bool + cause error + wantLog bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"joined shutdown cancellations", true, errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), false}, + {"shutdown database failure", true, dbErr, true}, + {"shutdown mixed database failure", true, fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {shutdownMixedDeadlineCase, true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + for _, operation := range []string{ + "new descriptor", "stored descriptor", "new standing", "stored standing", "new result", "stored result", + "projection load", "children list", "epochs list", "acceptance receipt", "acceptance update", + } { + t.Run(test.name+"/"+operation, func(t *testing.T) { + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, factory: &adapterFactoryMock{}, + consensus: &daveConsensusAdapterMock{}, client: ðClientMock{}, app: prtRevertTestApp()} + f.app.LastTournamentCheckBlock = 50 + f.s.adapterFactory, f.s.client = f.factory, f.client + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.client.AssertExpectations(t) + }) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + err := runObservationWithIOError(ctx, t, f, operation, test.cause) + require.ErrorIs(t, err, test.cause) + require.Equal(t, test.wantLog, strings.Contains(output.String(), "level=ERROR"), output.String()) + if test.wantLog { + require.Contains(t, output.String(), "application="+f.app.Name) + } + }) + } + } +} + +func runObservationWithIOError(ctx context.Context, t *testing.T, f *observerCheckpointFixture, operation string, cause error) error { + t.Helper() + epoch := checkpointEpoch(0, "0x100") + if operation == "epochs list" { + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), cause).Once() + _, err := f.s.checkEpochs(ctx, f.app, 100) + return err + } + if strings.HasPrefix(operation, "acceptance") { + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + tournament := &model.Tournament{Address: *epoch.TournamentAddress, Snapshot: model.TournamentSnapshot{FinishedAtBlock: 50}} + if operation == "acceptance update" { + f.app.LastTournamentCheckBlock = 100 + f.acceptance(epoch, tournament, cause) + } else { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Once() + f.client.On("TransactionReceipt", mock.Anything, *epoch.ClaimTransactionHash). + Return((*types.Receipt)(nil), cause).Once() + } + _, err := f.s.reconcileAcceptedEpochs(ctx, f.app, []*model.Epoch{epoch}, f.consensus, 100) + return err + } + + address := *epoch.TournamentAddress + adapter := &tournamentAdapterMock{} + t.Cleanup(func() { adapter.AssertExpectations(t) }) + var stored *model.Tournament + if !strings.HasPrefix(operation, "new ") { + stored = &model.Tournament{Address: address, Level: 1, MaxLevel: 3, Kind: model.TournamentKindNonLeaf} + } + var loadError error + if operation == "projection load" { + loadError = cause + } + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(stored, loadError).Once() + if loadError == nil { + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + descriptor := TournamentDescriptor{BaseCycle: big.NewInt(0), Level: 1, Kind: model.TournamentKindNonLeaf} + var descriptorError error + if strings.HasSuffix(operation, "descriptor") { + descriptorError = cause + } + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(descriptor, descriptorError).Once() + if descriptorError == nil { + var standingError error + if strings.HasSuffix(operation, "standing") { + standingError = cause + } + standing := TournamentStanding{State: model.TournamentStandingAwaitingClosure} + if strings.HasSuffix(operation, "result") { + standing = TournamentStanding{State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: 90} + adapter.On("InnerResult", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(InnerResult{}, cause).Once() + } + adapter.On("Standing", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(standing, standingError).Once() + } + if operation == "children list" { + expectTournamentAuxiliaryReads(adapter, mock.MatchedBy(resultCallOptsAtBlock(100)), 1, model.TournamentStandingAwaitingClosure) + adapter.On("RetrieveAllEvents", mock.Anything).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(50))).Return(zeroStructuralEventCounts(), nil).Once() + adapter.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(50))). + Return(canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0), nil).Once() + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, address) + f.repo.On("ListTournaments", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Tournament{}, uint64(0), cause).Once() + } + } + _, err := f.s.gatherTournamentData(ctx, f.app, epoch, 1, nil, nil, address, 3, 100) + return err +} + +func TestTournamentSubmissionShutdownLogs(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, action := range []struct { + name string + run func(*Service, context.Context, *model.Application, *model.Epoch, error) error + }{ + {"join", func(s *Service, ctx context.Context, app *model.Application, epoch *model.Epoch, err error) error { + return s.handleJoinTournamentRevert(ctx, app, epoch, nil, err) + }}, + {"stage", (*Service).handleStageTournamentResultRevert}, + {"accept", (*Service).handleAcceptTournamentResultRevert}, + } { + for _, test := range []struct { + name string + stopping bool + cause error + wantLog bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"shutdown mixed failure", true, errors.Join(context.Canceled, dbErr), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {shutdownMixedDeadlineCase, true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + t.Run(action.name+"/"+test.name, func(t *testing.T) { + s, _ := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + err := action.run(s, ctx, prtRevertTestApp(), prtRevertTestEpoch(), test.cause) + require.ErrorIs(t, err, test.cause) + require.Equal(t, test.wantLog, strings.Contains(output.String(), "level=ERROR"), output.String()) + }) + } + } +} + +func TestTournamentJoinReadsShutdownLogs(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, operation := range []string{"commitment record", "commitment standing", "bond value"} { + for _, test := range []struct { + name string + stopping bool + cause error + wantLog bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"shutdown mixed failure", true, errors.Join(context.Canceled, dbErr), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {shutdownMixedDeadlineCase, true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + t.Run(operation+"/"+test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app, epoch := prtRevertTestApp(), resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + var recordErr error + if operation == "commitment record" { + recordErr = test.cause + } + repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index, + epoch.TournamentAddress.Hex(), epoch.Commitment.Hex()).Return((*model.Commitment)(nil), recordErr).Once() + if recordErr == nil { + factory := &adapterFactoryMock{} + adapter := &tournamentAdapterMock{} + s.adapterFactory = factory + factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + var standingErr error + if operation == "commitment standing" { + standingErr = test.cause + } + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + adapter.On("CommitmentStanding", opts, [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, standingErr).Once() + if standingErr == nil { + adapter.On("Descriptor", opts). + Return(TournamentDescriptor{Height: model.Log2EpochComputationHashLeafCount}, nil).Once() + adapter.On("BondValue", opts).Return((*big.Int)(nil), test.cause).Once() + } + t.Cleanup(func() { factory.AssertExpectations(t); adapter.AssertExpectations(t) }) + } + _, err := s.reactToTournament(ctx, app, epoch, 100) + require.ErrorIs(t, err, test.cause) + require.Equal(t, test.wantLog, strings.Contains(output.String(), "level=ERROR"), output.String()) + repo.AssertExpectations(t) + }) + } + } +} diff --git a/internal/prt/passive_observer_test.go b/internal/prt/passive_observer_test.go index 3420329cf..ade9f2759 100644 --- a/internal/prt/passive_observer_test.go +++ b/internal/prt/passive_observer_test.go @@ -7,36 +7,52 @@ import ( "math/big" "testing" - . "github.com/cartesi/rollups-node/internal/model" - "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" ) func TestPassiveObservationIncludesLocalFailuresAndTerminalEpochs(t *testing.T) { - for _, status := range []ApplicationStatus{ - ApplicationStatus_OK, ApplicationStatus_Failed, ApplicationStatus_Corrupted, ApplicationStatus_Diverged, - } { - for _, epochStatus := range []EpochStatus{ - EpochStatus_Closed, EpochStatus_InputsProcessed, EpochStatus_ClaimAccepted, EpochStatus_ClaimForeclosed, + for _, status := range model.ApplicationStatusAllValues { + for _, epochStatus := range []model.EpochStatus{ + model.EpochStatus_Closed, model.EpochStatus_InputsProcessed, model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimStaged, model.EpochStatus_ClaimAccepted, model.EpochStatus_ClaimForeclosed, } { t.Run(status.String()+"/"+epochStatus.String(), func(t *testing.T) { f := newObserverCheckpointFixture(t) - f.s.defaultBlock = DefaultBlock_Finalized + f.s.defaultBlock = model.DefaultBlock_Finalized f.app.Status = status // These applications must remain passive even with signing enabled. - f.s.submissionEnabled = status != ApplicationStatus_OK + f.s.submissionEnabled = status != model.ApplicationStatus_OK epoch := checkpointEpoch(0, "0x100") - snapshot := resultTestSnapshot(epoch, false) epoch.Status = epochStatus - epoch.Commitment, epoch.MachineHash, epoch.TxBufferDataBlock = nil, nil, nil + pendingClaim := epochStatus == model.EpochStatus_ClaimComputed || epochStatus == model.EpochStatus_ClaimStaged + if pendingClaim { + // A complete claim ensures that invalid local data cannot be + // the reason an unhealthy application avoids submission. + applyPRTStateProof(epoch, repotest.KeccakStateProof(common.HexToHash("0x1234"))) + _, err := epoch.StateProof() + require.NoError(t, err) + } + snapshot := resultTestSnapshot(epoch, epochStatus == model.EpochStatus_ClaimStaged) + if epochStatus == model.EpochStatus_ClaimStaged { + epoch.StagedAtBlock = new(uint64(95)) + snapshot.sealed.StagingBlockNumber = *epoch.StagedAtBlock + snapshot.accept.IsClaimStagingPeriodOver = true + } + if !pendingClaim { + epoch.Commitment, epoch.MachineHash, epoch.TxBufferDataBlock = nil, nil, nil + } f.repo.On("ListApplications", mock.Anything, repository.ApplicationFilter{ - Enabled: new(true), ConsensusType: new(Consensus_PRT), - }, repository.Pagination{}, false).Return([]*Application{f.app}, uint64(1), nil).Once() + Enabled: new(true), ConsensusType: new(model.Consensus_PRT), + }, repository.Pagination{}, false).Return([]*model.Application{f.app}, uint64(1), nil).Once() f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). Return(&types.Header{Number: big.NewInt(100)}, nil).Once() f.epochs(epoch) @@ -46,7 +62,10 @@ func TestPassiveObservationIncludesLocalFailuresAndTerminalEpochs(t *testing.T) mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { return len(batches) == 1 && batches[0].Tournament.Snapshot.AsOfBlock == 100 }), uint64(100)).Return(nil).Once() - if status == ApplicationStatus_OK { + if status == model.ApplicationStatus_OK { + if pendingClaim { + f.unaccepted(epoch, 90) + } opts := mock.MatchedBy(resultCallOptsAtBlock(100)) f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() @@ -58,14 +77,69 @@ func TestPassiveObservationIncludesLocalFailuresAndTerminalEpochs(t *testing.T) require.NoError(t, err) require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) require.Equal(t, status, f.app.Status) + require.Equal(t, epochStatus, epoch.Status) require.Empty(t, f.s.pendingTransactions) f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + if status != model.ApplicationStatus_OK { + // Tick must stop after observation, before any result-action + // query. An unexpected broadcast also fails the strict mocks. + f.consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.Anything) + f.consensus.AssertNotCalled(t, "CanStageTournamentResult", mock.Anything) + f.consensus.AssertNotCalled(t, "CanAcceptStagedTournamentResult", mock.Anything) + } }) } } } +func TestHealthyReaderPublishesBeforeRecordingStagedClaim(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = false + epoch := checkpointEpoch(0, "0x100") + snapshot := resultTestSnapshot(epoch, true) + snapshot.sealed.StagingBlockNumber = 95 + snapshot.accept.IsClaimStagingPeriodOver = true + f.repo.On("ListApplications", mock.Anything, repository.ApplicationFilter{ + Enabled: new(true), ConsensusType: new(model.Consensus_PRT), + }, repository.Pagination{}, false).Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + f.unaccepted(epoch, 90) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(95)). + Run(func(mock.Arguments) { require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) }).Return(nil).Once() + + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(95)), epoch.StagedAtBlock) + require.Empty(t, f.s.pendingTransactions) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + func TestLosingRootPublishesBeforeLocalDivergence(t *testing.T) { f := newObserverCheckpointFixture(t) epoch := checkpointEpoch(0, "0x100") @@ -76,32 +150,32 @@ func TestLosingRootPublishesBeforeLocalDivergence(t *testing.T) { f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() adapter := &tournamentAdapterMock{} f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() - f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return((*Tournament)(nil), nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return((*model.Tournament)(nil), nil).Once() adapter.On("Descriptor", opts).Return(TournamentDescriptor{ - BaseCycle: big.NewInt(0), Kind: TournamentKindLeaf, StartInstant: 10, + BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, StartInstant: 10, }, nil).Once() adapter.On("Standing", opts).Return(TournamentStanding{ - State: TournamentStandingRootWinner, HasCandidate: true, Candidate: foreign, FinishedAt: 90, + State: model.TournamentStandingRootWinner, HasCandidate: true, Candidate: foreign, FinishedAt: 90, }, nil).Once() - expectTournamentAuxiliaryReads(adapter, opts, RootLevel, TournamentStandingRootWinner) + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, model.TournamentStandingRootWinner) adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { // A newly found clone includes its full history, even below the app cursor. return opts.Start == 10 && opts.End != nil && *opts.End == 100 })).Return(&TournamentEvents{}, nil).Once() adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() f.emptyParticipants(epoch, address) - var published *Tournament + var published *model.Tournament f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)). Run(func(args mock.Arguments) { batches := args.Get(2).([]*repository.TournamentEventBatch) require.Len(t, batches, 1) published = batches[0].Tournament require.Equal(t, foreign, *published.Snapshot.WinnerCommitment) - require.Equal(t, ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) }).Return(nil).Once() f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()). - Return(&Tournament{Snapshot: TournamentSnapshot{FinishedAtBlock: 90, WinnerCommitment: &foreign}}, nil).Once() - f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, ApplicationStatus_Diverged, mock.Anything). + Return(&model.Tournament{Snapshot: model.TournamentSnapshot{FinishedAtBlock: 90, WinnerCommitment: &foreign}}, nil).Once() + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, mock.Anything). Run(func(mock.Arguments) { require.NotNil(t, published) require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) @@ -109,7 +183,7 @@ func TestLosingRootPublishesBeforeLocalDivergence(t *testing.T) { deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) require.ErrorContains(t, err, "inconsistent commitment") require.True(t, deferActions) - require.Equal(t, ApplicationStatus_Diverged, f.app.Status) + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) require.Empty(t, f.s.pendingTransactions) adapter.AssertExpectations(t) } diff --git a/internal/prt/pending.go b/internal/prt/pending.go index 82355e61c..c750c90f3 100644 --- a/internal/prt/pending.go +++ b/internal/prt/pending.go @@ -5,9 +5,11 @@ package prt import ( "context" + "errors" "fmt" - . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ) @@ -20,32 +22,49 @@ const ( tournamentActionAccept tournamentAction = "accept" ) +// Match the claimer's 64-block recovery horizon, but count latest blocks from +// the first missing lookup, not finalized blocks from submission. This releases +// stale local exclusion only; it cannot ensure delivery before a join deadline. +const maxMissingTournamentTransactionBlocks uint64 = 64 + // pendingTournamentTransaction is a local exclusion slot, not a delivery queue. -// It is lost on restart. Pending and missing transactions have no retry deadline. +// It is lost on restart. Known-pending transactions have no retry deadline. type pendingTournamentTransaction struct { - Action tournamentAction - Hash common.Hash - EpochIndex uint64 + Action tournamentAction + Hash common.Hash + EpochIndex uint64 + FirstMissingBlock *uint64 } // waitForTournamentTransaction blocks new actions for this tick, including when -// a mined transaction releases its slot. The next tick must read fresh state. +// a mined or missing transaction releases its slot. The next tick reads fresh state. // Receipts provide diagnostics; they do not change stored claim state. -func (s *Service) waitForTournamentTransaction(ctx context.Context, app *Application) (bool, error) { +func (s *Service) waitForTournamentTransaction(ctx context.Context, app *model.Application, latestBlock uint64) (bool, error) { tx, exists := s.pendingTransactions[app.ID] if !exists { return false, nil } _, pending, err := s.client.TransactionByHash(ctx, tx.Hash) - if err != nil { + missing := errors.Is(err, ethereum.NotFound) + if err != nil && !missing { return true, fmt.Errorf("checking %s transaction %s: %w", tx.Action, tx.Hash, err) } - if pending { + if !missing { + tx.FirstMissingBlock = nil + s.pendingTransactions[app.ID] = tx + } + if !missing && pending { s.Logger.Debug("Tournament transaction is still pending", "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash) return true, nil } + // A transaction lookup can be missing while its receipt is available. + // Require both lookups to report NotFound before aging the local slot. receipt, err := s.client.TransactionReceipt(ctx, tx.Hash) + if missing && errors.Is(err, ethereum.NotFound) { + s.waitForMissingTournamentTransaction(app, tx, latestBlock) + return true, nil + } if err != nil { return true, fmt.Errorf("reading %s transaction receipt %s: %w", tx.Action, tx.Hash, err) } @@ -68,3 +87,22 @@ func (s *Service) waitForTournamentTransaction(ctx context.Context, app *Applica } return true, nil } + +func (s *Service) waitForMissingTournamentTransaction( + app *model.Application, tx pendingTournamentTransaction, latestBlock uint64, +) { + if tx.FirstMissingBlock == nil || latestBlock < *tx.FirstMissingBlock { + tx.FirstMissingBlock = new(latestBlock) + s.pendingTransactions[app.ID] = tx + return + } + age := latestBlock - *tx.FirstMissingBlock + if age < maxMissingTournamentTransactionBlocks { + return + } + delete(s.pendingTransactions, app.ID) + s.Logger.Warn("Tournament transaction remains missing; releasing local slot for fresh chain reconciliation", + "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash, + "missing_since_block", *tx.FirstMissingBlock, "latest_block", latestBlock, "age_blocks", age, + "timeout_blocks", maxMissingTournamentTransactionBlocks) +} diff --git a/internal/prt/pending_test.go b/internal/prt/pending_test.go index 0362850bf..56f442b14 100644 --- a/internal/prt/pending_test.go +++ b/internal/prt/pending_test.go @@ -30,7 +30,14 @@ func TestPendingTournamentTransactionRetainsOrReleasesSlot(t *testing.T) { wantRevert bool }{ {name: "pending", pending: true}, - {name: "missing transaction", lookupError: ethereum.NotFound, wantError: true}, + {name: "missing transaction with receipt", lookupError: ethereum.NotFound, wantRelease: true}, + {name: "missing transaction with reverted receipt", lookupError: ethereum.NotFound, + mutate: func(r *types.Receipt) *types.Receipt { r.Status = types.ReceiptStatusFailed; return r }, + wantRelease: true, wantRevert: true}, + {name: "missing transaction with nil receipt", lookupError: ethereum.NotFound, + mutate: func(*types.Receipt) *types.Receipt { return nil }, wantError: true}, + {name: "missing transaction with wrong receipt hash", lookupError: ethereum.NotFound, + mutate: func(r *types.Receipt) *types.Receipt { r.TxHash = common.Hash{}; return r }, wantError: true}, {name: "missing receipt", receiptError: ethereum.NotFound, wantError: true}, {name: "nil receipt", mutate: func(*types.Receipt) *types.Receipt { return nil }, wantError: true}, {name: "wrong hash", mutate: func(r *types.Receipt) *types.Receipt { r.TxHash = common.Hash{}; return r }, wantError: true}, @@ -51,7 +58,7 @@ func TestPendingTournamentTransactionRetainsOrReleasesSlot(t *testing.T) { client := ðClientMock{} client.On("TransactionByHash", mock.Anything, tx.Hash). Return((*types.Transaction)(nil), test.pending, test.lookupError).Once() - if !test.pending && test.lookupError == nil { + if !test.pending { receipt := &types.Receipt{TxHash: tx.Hash, BlockNumber: big.NewInt(21), Status: types.ReceiptStatusSuccessful} if test.mutate != nil { receipt = test.mutate(receipt) diff --git a/internal/prt/prt.go b/internal/prt/prt.go index 149f17ba5..687518574 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -53,12 +53,14 @@ type prtRepository interface { ListMatches(ctx context.Context, nameOrAddress string, filter repository.MatchFilter, pagination repository.Pagination, descending bool) ([]*Match, uint64, error) + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) } // EthClientInterface defines the methods we need from ethclient.Client type EthClientInterface interface { + SuggestGasPrice(ctx context.Context) (*big.Int, error) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) ChainID(ctx context.Context) (*big.Int, error) BlockNumber(ctx context.Context) (uint64, error) @@ -85,6 +87,9 @@ func (f *DefaultAdapterFactory) CreateDaveConsensusAdapter(addr common.Address) return NewDaveConsensusAdapter(addr, f.client) } +// getObservableApplications includes unhealthy apps so tournament and dispute +// observation can continue. Later health gates stop local claim reconciliation +// and transaction submission; they must not remove these apps from observation. func getObservableApplications(ctx context.Context, r prtRepository) ([]*Application, uint64, error) { filter := repository.ApplicationFilter{ Enabled: new(true), @@ -386,110 +391,18 @@ func (s *Service) updateTournamentStanding( return nil } -func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentBlock uint64) error { - if app.LastTournamentCheckBlock >= mostRecentBlock { - s.Logger.Debug("No new blocks since last tournament check", "application", app.Name, - "last_tournament_check_block", app.LastTournamentCheckBlock, "most_recent_block", mostRecentBlock) - return nil // nothing to do - } - - epochs, _, err := getAllClaimComputedEpochs(ctx, s.repository, app.Name) - if err != nil { - s.Logger.Error("failed to list epochs", "application", app.Name, "error", err) - return err - } - if len(epochs) == 0 { - s.Logger.Debug("No epochs with claim computed status", "application", app.Name) - return nil // nothing to do - } - - consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) +// checkEpochs commits a complete event window before accepting any epoch. +// A failed RPC read or database write leaves the shared cursor unchanged. +// The first return value asks the caller to defer later PRT actions. +func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentBlock uint64) (bool, error) { + epochs, consensus, err := s.observeApplicationTournaments(ctx, app, mostRecentBlock) if err != nil { - s.Logger.Error("failed to bind dave consensus contract", "application", app.Name, - "consensus_address", app.IConsensusAddress.String(), "error", err) - return err + return true, err } - - for _, epoch := range epochs { - if epoch.TournamentAddress == nil || epoch.Commitment == nil || - epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { - return s.setApplicationCorrupted(ctx, app, - "epoch %d has missing required fields for ClaimComputed status", epoch.Index) - } - - if epoch.ClaimTransactionHash == nil { // epoch not claimed on-chain yet - err = s.fetchTournamentData(ctx, app, epoch, RootLevel, nil, nil, *epoch.TournamentAddress, mostRecentBlock) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to fetch root tournament data", err, - "application", app.Name, "epoch", epoch.Index, - "tournament", epoch.TournamentAddress.String()) - return err - } - // if this epoch is not claimed on-chain yet, all other epochs with higher index should not be claimed either, so we can - // stop processing here. - break - } - - receipt, err := s.client.TransactionReceipt(ctx, *epoch.ClaimTransactionHash) - if err != nil { - s.Logger.Error("failed to fetch transaction receipt for epoch", "application", app.Name, - "epoch", epoch.Index, "tx", epoch.ClaimTransactionHash, "error", err) - return err - } - - if receipt.Status != 1 { - return fmt.Errorf("epoch %d: EpochSealed transaction hash points to failed transaction", epoch.Index) - } - - var event *idaveconsensus.IDaveConsensusEpochSealed - for _, vLog := range receipt.Logs { - event, err = consensus.ParseEpochSealed(*vLog) - if err != nil { - continue // Skip logs that don't match - } - break - } - if event == nil { - return fmt.Errorf("epoch %d: failed to find EpochSealed event in receipt logs", epoch.Index) - } - - if epoch.Index != event.EpochNumber.Uint64()-1 { - return s.setApplicationDiverged(ctx, app, "Epoch %d has inconsistent index between off-chain (%d) and on-chain (%d)", - epoch.Index, epoch.Index, event.EpochNumber.Uint64()-1) - } - if *epoch.MachineHash != event.InitialMachineStateHash { - return s.setApplicationDiverged(ctx, app, "Epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", - epoch.Index, epoch.MachineHash.String(), hexutil.Encode(event.InitialMachineStateHash[:])) - } - if *epoch.TxBufferDataBlock != event.OutputsMerkleRoot { - return s.setApplicationDiverged(ctx, app, "Epoch %d has inconsistent claim hash between off-chain (%s) and on-chain (%s)", - epoch.Index, epoch.TxBufferDataBlock.String(), hexutil.Encode(event.OutputsMerkleRoot[:])) - } - - err = s.fetchTournamentData(ctx, app, epoch, RootLevel, nil, nil, *epoch.TournamentAddress, mostRecentBlock) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to fetch tournament data", err, - "application", app.Name, "epoch", epoch.Index, - "tournament", epoch.TournamentAddress.String()) - return err - } - - s.Logger.Info("Found finalized epoch. OutputsMerkleRoot matched. Setting claim as accepted", - "application", app.Name, - "epoch", epoch.Index, - "event_block_number", event.Raw.BlockNumber, - "outputs_merkle_root", fmt.Sprintf("%x", event.OutputsMerkleRoot), - "tx", epoch.ClaimTransactionHash, - ) - - epoch.Status = EpochStatus_ClaimAccepted - err = s.repository.UpdateEpochStatus(ctx, app.Name, epoch) - if err != nil { - s.Logger.Error("failed to update epoch status to claim accepted", "application", app.Name, "epoch", epoch.Index, "error", err) - return err - } + if app.Status != ApplicationStatus_OK { + return true, nil } - return nil + return s.reconcileAcceptedEpochs(ctx, app, epochs, consensus, mostRecentBlock) } // observeApplicationTournaments indexes chain facts without consulting local @@ -1200,34 +1113,47 @@ func (s *Service) handleJoinTournamentRevert( return err } -func (s *Service) validateApplication(ctx context.Context, app *Application) error { +func (s *Service) validateApplication(ctx context.Context, app *Application, confirmedBlock uint64) error { s.Logger.Debug("Syncing PRT tournaments", "application", app.Name) - mostRecentBlock, err := s.client.BlockNumber(ctx) + s.warnZeroStagingPeriod(app) + deferActions, err := s.checkEpochs(ctx, app, confirmedBlock) if err != nil { - s.Logger.Error("failed to fetch latest block number", "application", app.Name, "error", err) return err } - err = s.checkEpochs(ctx, app, mostRecentBlock) - if err != nil { - return err + if deferActions { + return nil } + latestBlock := confirmedBlock if s.submissionEnabled { - err = s.trySettle(ctx, app, mostRecentBlock) + latestBlock, err = s.client.BlockNumber(ctx) if err != nil { - return err + return fmt.Errorf("fetching latest block for PRT actions: %w", err) } - // trySettle may have marked the app FAILED (returning nil, per the - // appstatus contract). Stop this tick's work instead of broadcasting - // a bond-carrying JoinTournament for an app that was just halted. - if app.Status != ApplicationStatus_OK { - return nil + if latestBlock < confirmedBlock { + return fmt.Errorf("latest block %d is behind configured block %d", latestBlock, confirmedBlock) } - err = s.reactToTournament(ctx, app, mostRecentBlock) + } + joinEpoch, recoveryAllowed, err := s.progressTournamentResult(ctx, app, confirmedBlock, latestBlock) + if err != nil { + return err + } + if !s.submissionEnabled || app.Status != ApplicationStatus_OK { + return nil + } + if joinEpoch != nil { + joined, err := s.reactToTournament(ctx, app, joinEpoch, latestBlock) if err != nil { return err } + // The current claim's join action has priority over bond recovery. + recoveryAllowed = joined } - return nil + if !recoveryAllowed { + return nil + } + // Bond recovery is existing in-memory transaction maintenance. It does not + // confirm database state or hold new tournament actions until finality. + return s.recoverRootBonds(ctx, app, latestBlock) } // isDaveConsensusError matches a typed Solidity error declared in the diff --git a/internal/prt/recovery_selection_test.go b/internal/prt/recovery_selection_test.go new file mode 100644 index 000000000..629d75dce --- /dev/null +++ b/internal/prt/recovery_selection_test.go @@ -0,0 +1,249 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func queueRecoverableOlderBond(t *testing.T, f *prtBlockPolicyFixture) *tournamentAdapterMock { + t.Helper() + address := common.HexToAddress("0x900") + f.s.queueRootBondRecovery(f.app.ID, f.epoch.Index-1, address) + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(CurrentSealedEpoch{EpochNumber: f.epoch.Index}, nil).Maybe() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(tournament, nil).Maybe() + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(canonicalBondRecovery(BondDispositionRecoverable, f.s.txOptsFactory.From(), 1), nil).Maybe() + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 9}), nil).Maybe() + return tournament +} + +func TestRecoveryDefersUntilCurrentClaimIsReady(t *testing.T) { + for _, status := range []EpochStatus{EpochStatus_Open, EpochStatus_Closed, EpochStatus_InputsProcessed} { + t.Run(status.String(), func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.epoch.Status = status + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + + require.NoError(t, f.tick(100)) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + require.Nil(t, f.s.rootBondRecoveries[f.app.ID][0].TxHash) + }) + } +} + +func TestRecoveryDefersAfterMinedTournamentTransaction(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.epoch.Status = EpochStatus_ClaimStaged + f.epoch.StagedAtBlock = new(uint64(10)) + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.s.pendingTransactions[f.app.ID] = pendingTournamentTransaction{ + Action: action, Hash: tx.Hash(), EpochIndex: f.epoch.Index, + } + f.client.On("TransactionByHash", mock.Anything, tx.Hash()).Return(tx, false, nil).Once() + f.client.On("TransactionReceipt", mock.Anything, tx.Hash()).Return(&types.Receipt{ + TxHash: tx.Hash(), BlockNumber: big.NewInt(99), Status: types.ReceiptStatusSuccessful, + }, nil).Once() + f.expectHead(100) + f.client.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once() + // The next tick must read fresh state and send the eligible accept. + snapshot := resultTestSnapshot(f.epoch, true) + snapshot.accept.IsClaimStagingPeriodOver = true + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + f.consensus.On("AcceptStagedTournamentResult", mock.Anything, f.epoch.Index). + Return(types.NewTx(&types.LegacyTx{Nonce: 2}), nil).Once() + + require.NoError(t, f.tick(100)) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + require.Empty(t, f.s.pendingTransactions) + require.NoError(t, f.tick(100)) + require.Equal(t, tournamentActionAccept, f.s.pendingTransactions[f.app.ID].Action) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + }) + } +} + +func TestFailedRootAllowsRecoveryWithoutPreparedClaim(t *testing.T) { + for _, name := range []string{"missing", "open", "closed", "inputs processed"} { + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + switch name { + case "missing": + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index).Unset() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index). + Return((*Epoch)(nil), nil) + case "open": + f.epoch.Status = EpochStatus_Open + case "closed": + f.epoch.Status = EpochStatus_Closed + case "inputs processed": + f.epoch.Status = EpochStatus_InputsProcessed + } + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + + require.NoError(t, f.tick(100)) + bond.AssertNumberOfCalls(t, "TryRecoveringBond", 1) + f.assertNoPermanentStatusWrite(t) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + }) + } +} + +func TestRecoveryDefersWhenJoinBroadcastIsDeferred(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + f.repo.On("GetCommitment", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index, + f.epoch.TournamentAddress.Hex(), f.epoch.Commitment.Hex()).Return((*Commitment)(nil), nil).Once() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(tournament, nil).Once() + tournament.On("CommitmentStanding", mock.Anything, [32]byte(*f.epoch.Commitment)). + Return(CommitmentStanding{}, nil).Once() + tournament.On("Descriptor", mock.Anything). + Return(TournamentDescriptor{Height: Log2EpochComputationHashLeafCount}, nil).Once() + tournament.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + tournament.On("JoinTournament", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return((*types.Transaction)(nil), errors.New("nonce too low")).Once() + + require.NoError(t, f.tick(100)) + require.Empty(t, f.s.pendingTransactions) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + tournament.AssertExpectations(t) +} + +func TestRecoveryDefersWhenResultBroadcastIsDeferred(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionStage, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + staged := action == tournamentActionAccept + if staged { + f.epoch.Status = EpochStatus_ClaimStaged + f.epoch.StagedAtBlock = new(uint64(10)) + } + snapshot := resultTestSnapshot(f.epoch, staged) + snapshot.accept.IsClaimStagingPeriodOver = staged + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + if staged { + f.consensus.On("AcceptStagedTournamentResult", mock.Anything, f.epoch.Index). + Return((*types.Transaction)(nil), errors.New("nonce too low")).Once() + } else { + f.consensus.On("StageTournamentResult", mock.Anything, f.epoch.Index, mock.Anything). + Return((*types.Transaction)(nil), errors.New("nonce too low")).Once() + } + + require.NoError(t, f.tick(100)) + require.Empty(t, f.s.pendingTransactions) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + candidates := f.s.rootBondRecoveries[f.app.ID] + wantCandidates := 1 + if staged { + // Acceptance queues this root before broadcast. A soft error must + // retain it without allowing a refund to take the next nonce. + wantCandidates++ + } + require.Len(t, candidates, wantCandidates) + require.Equal(t, f.epoch.Index-1, candidates[0].EpochIndex) + for _, candidate := range candidates { + require.Nil(t, candidate.TxHash) + } + f.assertNoPermanentStatusWrite(t) + }) + } +} + +func TestRecoveryRunsDuringKnownSafeTournamentWait(t *testing.T) { + for _, staged := range []bool{false, true} { + name := "joined and waiting for tournament" + if staged { + name = "waiting for staging period" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, staged) + if staged { + f.epoch.Status = EpochStatus_ClaimStaged + f.epoch.StagedAtBlock = new(uint64(10)) + } else { + snapshot.stage.IsFinished = false + f.repo.On("GetCommitment", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index, + f.epoch.TournamentAddress.Hex(), f.epoch.Commitment.Hex()).Return(&Commitment{}, nil).Once() + } + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + + require.NoError(t, f.tick(100)) + bond.AssertNumberOfCalls(t, "TryRecoveringBond", 1) + }) + } +} + +func TestPendingRecoveryIsPolledAndReportsTournamentYield(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + // No result snapshot is required while the already-sent refund is pending. + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index).Unset() + f.factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Unset() + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + hash := tx.Hash() + f.s.rootBondRecoveries = map[int64][]*rootBondRecovery{f.app.ID: {{ + EpochIndex: f.epoch.Index - 1, Tournament: common.HexToAddress("0x900"), TxHash: &hash, + }}} + f.expectHead(100) + f.client.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once() + f.client.On("TransactionByHash", mock.Anything, hash).Return(tx, true, nil).Once() + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + + require.NoError(t, f.tick(100)) + require.Contains(t, output.String(), "Tournament actions wait for pending root bond recovery") + require.Contains(t, output.String(), "level=INFO") + require.Contains(t, output.String(), hash.Hex()) + f.consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.Anything) +} + +func TestRecoveryRunsWhenJoinIsAwaitingEventSync(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + f.repo.On("GetCommitment", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index, + f.epoch.TournamentAddress.Hex(), f.epoch.Commitment.Hex()).Return((*Commitment)(nil), nil).Once() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(tournament, nil).Once() + tournament.On("CommitmentStanding", mock.Anything, [32]byte(*f.epoch.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: *f.epoch.MachineHash}, nil).Once() + + require.NoError(t, f.tick(100)) + bond.AssertNumberOfCalls(t, "TryRecoveringBond", 1) + tournament.AssertNotCalled(t, "JoinTournament", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + tournament.AssertExpectations(t) +} diff --git a/internal/prt/service.go b/internal/prt/service.go index 132e2685e..1ddb0f3cb 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -14,17 +14,21 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/internal/errutil" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/cartesi/rollups-node/pkg/service" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" ) type CreateInfo struct { - Config config.PrtConfig - Logger *slog.Logger - Repository repository.Repository + Config config.PrtConfig + Logger *slog.Logger + Repository repository.Repository + EthClient EthClientInterface + AdapterFactory AdapterFactory } type Service struct { @@ -43,21 +47,25 @@ type Service struct { rootBondRecoveries map[int64][]*rootBondRecovery observationHealthMu sync.RWMutex // Ready runs concurrently with the observation loop. observationFailures map[int64]tournamentObservationFailure + + discoveredForeclosedRootBonds map[int64]common.Address // Current root only; cleared on restart with the recovery queue. } const PrtConfigKey = "prt" -type PersistentConfig struct { - DefaultBlock DefaultBlock - ClaimSubmissionEnabled bool - ChainID uint64 -} +type PersistentConfig = config.PersistentSubmitterConfig func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { var err error + if c == nil { + return nil, errors.New("invalid CreateInfo is nil") + } if err = ctx.Err(); err != nil { return nil, err // This returns context.Canceled or context.DeadlineExceeded. } + if c.Repository == nil { + return nil, fmt.Errorf("repository on prt service Create is nil") + } s := &Service{} tickCfg := &service.TickServiceConfigs{ @@ -74,56 +82,62 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro return nil, err } - authOpt, err := config.HTTPAuthorizationOption() - if err != nil { - return nil, err - } - - ethClient, err := ethutil.NewEthClient(ctx, c.Config.BlockchainHttpEndpoint.Raw(), s.Logger, - ethutil.RetryConfig{ - MaxRetries: c.Config.BlockchainHttpMaxRetries, - RetryMinWait: c.Config.BlockchainHttpRetryMinWait, - RetryMaxWait: c.Config.BlockchainHttpRetryMaxWait, - RequestTimeout: c.Config.BlockchainHttpRequestTimeout, - }, authOpt) - if err != nil { - return nil, err + ethClient := c.EthClient + if ethClient == nil { + authOpt, err := config.HTTPAuthorizationOption() + if err != nil { + return nil, err + } + ethClient, err = ethutil.NewEthClient(ctx, c.Config.BlockchainHttpEndpoint.Raw(), s.Logger, + ethutil.RetryConfig{ + MaxRetries: c.Config.BlockchainHttpMaxRetries, + RetryMinWait: c.Config.BlockchainHttpRetryMinWait, + RetryMaxWait: c.Config.BlockchainHttpRetryMaxWait, + RequestTimeout: c.Config.BlockchainHttpRequestTimeout, + }, authOpt) + if err != nil { + return nil, err + } } chainID, err := ethClient.ChainID(ctx) if err != nil { return nil, err } - if chainID.Uint64() != c.Config.BlockchainId { - return nil, fmt.Errorf("EthClient chainId mismatch: network %d != provided %d", - chainID.Uint64(), c.Config.BlockchainId) + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err } s.repository = c.Repository - if s.repository == nil { - return nil, fmt.Errorf("repository on prt service Create is nil") - } nodeConfig, err := s.setupPersistentConfig(ctx, &c.Config) if err != nil { return nil, err } - if chainID.Uint64() != nodeConfig.ChainID { - return nil, fmt.Errorf("NodeConfig chainId mismatch: network %d != config %d", - chainID.Uint64(), nodeConfig.ChainID) - } s.client = ethClient s.submissionEnabled = nodeConfig.ClaimSubmissionEnabled + s.defaultBlock = nodeConfig.DefaultBlock s.filter = ethutil.Filter{ MinChunkSize: ethutil.DefaultMinChunkSize, MaxChunkSize: new(big.Int).SetUint64(c.Config.BlockchainMaxBlockRange), Logger: s.Logger, } - s.adapterFactory = NewDefaultAdapterFactory(ethClient, s.filter) - s.currentEpochIndex = map[int64]uint64{} - s.settleInFlight = map[int64]*common.Hash{} - s.joinInFlight = map[int64]*common.Hash{} + if c.AdapterFactory != nil { + s.adapterFactory = c.AdapterFactory + } else { + concreteClient, ok := ethClient.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("EthClient must be *ethclient.Client when AdapterFactory is not provided") + } + s.adapterFactory = NewDefaultAdapterFactory(concreteClient, s.filter) + } + + s.pendingTransactions = map[int64]pendingTournamentTransaction{} + s.disputeWarnings = map[common.Address]struct{}{} + s.zeroStagingWarnings = map[int64]struct{}{} + s.rootBondRecoveries = map[int64][]*rootBondRecovery{} + s.observationFailures = map[int64]tournamentObservationFailure{} if s.submissionEnabled { s.submissionTimeout = c.Config.BlockchainHttpRequestTimeout @@ -132,7 +146,11 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro } s.txOptsFactory, err = auth.GetPrtTransactOptsFactory(ctx, chainID) if err != nil { - return nil, err + return nil, fmt.Errorf("PRT submission is enabled and requires signer configuration "+ + "(CARTESI_PRT_AUTH_*); the standalone node always starts PRT: %w", err) + } + if c.Config.BlockchainLegacyEnabled { + s.txOptsFactory = ethutil.WithLegacyFees(s.txOptsFactory, ethClient) } s.Logger.Info("PRT submitter identity", "address", s.txOptsFactory.From()) } @@ -146,98 +164,152 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro // reported as an operational failure. DeadlineExceeded and cancellations while // the service is running remain errors. func (s *Service) logErrorUnlessShutdown(ctx context.Context, message string, err error, args ...any) { - if errors.Is(ctx.Err(), context.Canceled) && errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { return } args = append(args, "error", err) s.Logger.Error(message, args...) } -// Tick executes the Validator main logic of producing claims and/or proofs -// for processed epochs of all running applications. +// Tick observes PRT tournaments and maintains their pending actions. func (s *Service) Tick(ctx context.Context) (bool, error) { // Check for shutdown before starting work, consistent with the advancer. - if ctx.Err() != nil { - return false, nil + if err := ctx.Err(); err != nil { + if errors.Is(err, context.Canceled) { + return false, nil + } + return false, err } - apps, _, err := getAllRunningApplications(ctx, s.repository) + apps, _, err := getObservableApplications(ctx, s.repository) if err != nil { // Only suppress context errors during shutdown; surface real DB errors. - if errors.Is(err, context.Canceled) { - s.Logger.Warn("Tick interrupted by shutdown", "error", err) + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "error", err) return false, nil } - return false, fmt.Errorf("failed to get running applications. %w", err) + return false, fmt.Errorf("failed to get observable applications: %w", err) } + s.pruneTournamentObservationFailures(apps) + // Resolve observation policy once, but only when a path needs it. In + // particular, queued foreclosed bond recovery does not require finality. + observationBlock := sync.OnceValues(func() (uint64, error) { + return s.getDefaultBlockNumber(ctx) + }) // validate each application errs := []error{} for idx := range apps { - if ctx.Err() != nil { + if err := ctx.Err(); err != nil { + if !errors.Is(err, context.Canceled) { + errs = append(errs, err) + } return false, errors.Join(errs...) } app := apps[idx] - // Foreclosed apps: run the drain path (reconcile accepted epochs, - // foreclose the rest) instead of normal tournament work. EVM reader is - // the sole writer of ForecloseBlock; the app keeps health status OK and - // remains enabled for L1 observation. + // Foreclosed apps still observe tournaments. Only healthy apps also + // drain local claims and recover their root tournament bonds. + // EVM reader is the sole writer of ForecloseBlock. if app.ForecloseBlock != 0 { - if ferr := s.handleForeclosedApp(ctx, app); ferr != nil { - if errors.Is(ferr, context.Canceled) { + if ferr := s.handleForeclosedApp(ctx, app, observationBlock); ferr != nil { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(ferr) { continue } - errs = append(errs, ferr) + errs = append(errs, fmt.Errorf("draining foreclosed PRT application %s: %w", app.IApplicationAddress, ferr)) } continue } - if err := s.validateApplication(ctx, app); err != nil { + confirmedBlock, err := observationBlock() + if err != nil { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + return false, errors.Join(errs...) + } + errs = append(errs, fmt.Errorf("fetching configured block for application %s: %w", app.IApplicationAddress, err)) + continue + } + if err := s.validateApplication(ctx, app, confirmedBlock); err != nil { // During shutdown, in-flight L1 requests see context cancellation. // Suppress these to avoid spurious ERR log entries. - if errors.Is(err, context.Canceled) { - s.Logger.Warn("Tick interrupted by shutdown", + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "application", app.IApplicationAddress, "error", err) continue } - errs = append(errs, err) + errs = append(errs, fmt.Errorf("validating PRT application %s: %w", app.IApplicationAddress, err)) } } return false, errors.Join(errs...) } -// handleForeclosedApp drains a foreclosed DaveConsensus application's epochs to -// a terminal state. Foreclosure is a lifecycle fact (foreclose_block); the -// application keeps health status OK and stays enabled for L1 observation. +// handleForeclosedApp observes a foreclosed application's tournaments and, +// while local processing is healthy, drains its epochs to a terminal state. // // Once the app has ingested its pre-foreclosure sealed epochs and the advancer // has processed their inputs, each pre-foreclosure epoch is reconciled read-only -// against the chain: an epoch whose root tournament settled with our commitment +// against the chain: an epoch whose accepted root result has our commitment // becomes CLAIM_ACCEPTED, and a mismatch marks the app DIVERGED (reproducing the // on-chain divergence). Every remaining epoch can no longer be accepted once the -// app is foreclosed, so it is terminalized to CLAIM_FORECLOSED. No Settle/Join -// transactions are sent. A freshly bootstrapped node therefore reaches the same -// epoch states a node that ran in real time would have. -func (s *Service) handleForeclosedApp(ctx context.Context, app *Application) error { +// app is foreclosed, so it is terminalized to CLAIM_FORECLOSED. Join, stage, +// and accept transactions are not sent. Root bond recovery is independent of +// consensus foreclosure and local claim reconciliation. A freshly bootstrapped +// node therefore reaches the same epoch states as a node that ran in real time. +func (s *Service) handleForeclosedApp(ctx context.Context, app *Application, observationBlock func() (uint64, error)) error { if app.ForecloseBlock == 0 { return nil } + if err := ctx.Err(); err != nil { + return err + } + mostRecentBlock, observationErr := observationBlock() + var epochs []*Epoch + var consensus DaveConsensusAdapter + if observationErr == nil { + epochs, consensus, observationErr = s.observeApplicationTournaments(ctx, app, mostRecentBlock) + } + if app.Status != ApplicationStatus_OK { + return observationErr + } + if err := ctx.Err(); err != nil { + return errors.Join(observationErr, err) + } + var discoveryErr error + if observationErr == nil { + discoveryErr = s.discoverForeclosedRootBond(ctx, app, epochs, consensus, mostRecentBlock) + } + if err := ctx.Err(); err != nil { + return errors.Join(observationErr, discoveryErr, err) + } + // Queued payment maintenance does not depend on a finalized-head read. + // A transaction wait or recovery failure must not prevent the database-only + // claim drain. Join the errors after both independent operations complete. + recoveryErr := s.recoverForeclosedRootBonds(ctx, app) + if observationErr != nil || ctx.Err() != nil { + return errors.Join(observationErr, discoveryErr, recoveryErr, ctx.Err()) + } + return errors.Join(discoveryErr, recoveryErr, + s.drainForeclosedClaims(ctx, app, epochs, consensus, mostRecentBlock)) +} + +func (s *Service) drainForeclosedClaims( + ctx context.Context, app *Application, epochs []*Epoch, consensus DaveConsensusAdapter, mostRecentBlock uint64, +) error { // Bootstrap-readiness guard. The drain gate below answers "given the // rows currently in the local input table, is there any pre-foreclosure // input still status=NONE?". For a freshly registered PRT app against // an already-foreclosed contract, evmreader's checkForForeclosure writes // foreclose_block before checkForEpochsAndInputs has had a chance to // ingest the historical sealed epochs (and their inputs) — so the gate - // would see an empty table and return false. PRT's input ingestion is - // driven by EpochSealed scans, so the relevant scanner cursor is - // last_epoch_check_block (not last_input_check_block, which the Dave - // path never writes) — ForeclosureScanCaughtUp branches on consensus - // type to consult it. + // would see an empty table and return false. Dave writes both scanner + // cursors. ForeclosureScanCaughtUp requires last_epoch_check_block AND + // last_input_check_block to reach foreclose_block: sealed-epoch coverage + // alone does not prove that open-epoch inputs have been ingested. if !app.ForeclosureScanCaughtUp() { s.Logger.Info( - "Foreclosed PRT application still ingesting pre-foreclosure sealed epochs", + "Foreclosed PRT application still ingesting pre-foreclosure sealed epochs and inputs", "application", app.Name, "address", app.IApplicationAddress, "last_epoch_check_block", app.LastEpochCheckBlock, + "last_input_check_block", app.LastInputCheckBlock, "foreclose_block", app.ForecloseBlock, ) return nil @@ -268,35 +340,59 @@ func (s *Service) handleForeclosedApp(ctx context.Context, app *Application) err return nil } - // Read-only reconciliation: accept epochs whose root tournament settled with + // Read-only reconciliation: accept epochs whose root result has // our commitment, and surface any divergence. This sends no transactions. - mostRecentBlock, err := s.client.BlockNumber(ctx) + deferActions, err := s.reconcileAcceptedEpochs(ctx, app, epochs, consensus, mostRecentBlock) if err != nil { - return fmt.Errorf("fetching latest block for foreclosed app %s: %w", - app.IApplicationAddress, err) - } - if err := s.checkEpochs(ctx, app, mostRecentBlock); err != nil { // A divergence detected here marks the app DIVERGED and returns the // reason; propagate it like the normal validation path does. return err } + if deferActions { + return nil + } - // Claim-computed epochs without an on-chain claim transaction can never be - // accepted now that the app is foreclosed: terminalize them to CLAIM_FORECLOSED. - return s.forecloseComputedEpochs(ctx, app) + // Pending claim epochs without an acceptance transaction can never be + // accepted now that the app is foreclosed. Make them CLAIM_FORECLOSED. + return s.foreclosePendingClaimEpochs(ctx, app) } -// forecloseComputedEpochs transitions every unaccepted CLAIM_COMPUTED epoch of a -// foreclosed application to CLAIM_FORECLOSED. Epochs that already have a -// ClaimTransactionHash have an on-chain EpochSealed event to reconcile; leave -// them CLAIM_COMPUTED so the next checkEpochs pass can accept or reject them. -func (s *Service) forecloseComputedEpochs(ctx context.Context, app *Application) error { - epochs, _, err := getAllClaimComputedEpochs(ctx, s.repository, app.Name) +func (s *Service) recoverForeclosedRootBonds(ctx context.Context, app *Application) error { + if !s.submissionEnabled || len(s.rootBondRecoveries[app.ID]) == 0 { + return nil + } + mostRecentBlock, err := s.client.BlockNumber(ctx) if err != nil { - return fmt.Errorf("listing computed epochs for foreclosed app %s: %w", + return fmt.Errorf("fetching latest block for foreclosed app root bond recovery %s: %w", + app.IApplicationAddress, err) + } + blocked, err := s.waitForTournamentTransaction(ctx, app, mostRecentBlock) + if err != nil { + return fmt.Errorf("checking tournament transaction for foreclosed app %s: %w", app.IApplicationAddress, err) + } + if blocked { + return nil + } + if err := s.recoverRootBonds(ctx, app, mostRecentBlock); err != nil { + return fmt.Errorf("recovering queued root bonds for foreclosed app %s: %w", app.IApplicationAddress, err) + } + return nil +} + +// foreclosePendingClaimEpochs terminalizes remaining pre-foreclosure epochs. +// The caller must first confirm ingestion and input processing are complete. +// Epochs with an acceptance transaction stay pending for claim reconciliation. +func (s *Service) foreclosePendingClaimEpochs(ctx context.Context, app *Application) error { + filter := repository.EpochFilter{Status: NonTerminalEpochStatuses()} + epochs, _, err := s.repository.ListEpochs(ctx, app.Name, filter, repository.Pagination{}, false) + if err != nil { + return fmt.Errorf("listing pending claim epochs for foreclosed app %s: %w", app.IApplicationAddress, err) } for _, epoch := range epochs { + if epoch.FirstBlock > app.ForecloseBlock { + continue + } if epoch.ClaimTransactionHash != nil { s.Logger.Debug("Skipping foreclose terminalization for epoch with on-chain claim transaction", "application", app.Name, @@ -324,24 +420,27 @@ func (s *Service) setupPersistentConfig( ctx context.Context, c *config.PrtConfig, ) (*PersistentConfig, error) { + requested := PersistentConfig{ + DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId, + ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, + } + if err := requested.Validate(); err != nil { + return nil, fmt.Errorf("invalid prt config: %w", err) + } config, err := repository.LoadNodeConfig[PersistentConfig](ctx, s.repository, PrtConfigKey) if config == nil && errors.Is(err, repository.ErrNotFound) { nc := NodeConfig[PersistentConfig]{ - Key: PrtConfigKey, - Value: PersistentConfig{ - DefaultBlock: c.BlockchainDefaultBlock, - ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, - ChainID: c.BlockchainId, - }, + Key: PrtConfigKey, + Value: requested, } s.Logger.Info("Initializing PRT persistent config", "config", nc.Value) - err = repository.SaveNodeConfig(ctx, s.repository, &nc) - if err != nil { - return nil, err + config, err = repository.InitializeNodeConfig(ctx, s.repository, &nc) + } + if err == nil { + if err := config.Value.CheckRequested(requested); err != nil { + return nil, fmt.Errorf("prt persistent config: %w", err) } - return &nc.Value, nil - } else if err == nil { - s.Logger.Info("PRT service was already configured. Using previous persistent config", "config", config.Value) + s.Logger.Info("PRT persistent config matches requested config", "config", config.Value) return &config.Value, nil } diff --git a/internal/prt/service_config_test.go b/internal/prt/service_config_test.go new file mode 100644 index 000000000..ea15498a3 --- /dev/null +++ b/internal/prt/service_config_test.go @@ -0,0 +1,103 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "encoding/json" + "fmt" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSetupPersistentConfigChecksInitializedValue(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, concurrent := range []bool{false, true} { + t.Run(fmt.Sprintf("enabled=%t/concurrent=%t", enabled, concurrent), func(t *testing.T) { + s, repo := newPRTServiceMock() + requested := PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: enabled, + } + raw, err := json.Marshal(requested) + require.NoError(t, err) + stored := requested + if concurrent { + stored.ClaimSubmissionEnabled = !enabled + } + storedRaw, err := json.Marshal(stored) + require.NoError(t, err) + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey). + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound).Once() + repo.On("InitializeNodeConfigRaw", mock.Anything, PrtConfigKey, raw).Return(nil).Once() + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey). + Return(storedRaw, time.Time{}, time.Time{}, nil).Once() + got, err := s.setupPersistentConfig(t.Context(), &config.PrtConfig{ + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: enabled, + }) + if concurrent { + require.Nil(t, got) + require.ErrorContains(t, err, "claim submission mode mismatch") + } else { + require.NoError(t, err) + require.Equal(t, requested, *got) + } + repo.AssertNumberOfCalls(t, "SaveNodeConfigRaw", 0) + repo.AssertExpectations(t) + }) + } + } +} + +func TestCreateRejectsConfigBeforeSignerResolution(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "invalid") + t.Cleanup(func() { viper.Set(config.PRT_AUTH_KIND, nil) }) + for _, test := range []struct { + name string + raw string + requestedEnabled bool + want string + }{ + {"chain mismatch", `{"ChainID":2,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, true, + "chain ID mismatch: database=2, configured=42"}, + {"policy mismatch", `{"ChainID":42,"DefaultBlock":"LATEST","ClaimSubmissionEnabled":true}`, true, + "observation policy mismatch: database=LATEST, configured=FINALIZED"}, + {"enable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":false}`, true, + "claim submission mode mismatch: database=false, configured=true"}, + {"disable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, false, + "claim submission mode mismatch: database=true, configured=false"}, + {"missing mode", `{"ChainID":42,"DefaultBlock":"FINALIZED"}`, true, "non-null ClaimSubmissionEnabled"}, + {"null mode", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":null}`, true, + "non-null ClaimSubmissionEnabled"}, + {"null config", `null`, true, "non-null DefaultBlock and ChainID"}, + {"invalid policy", `{"ChainID":42,"DefaultBlock":"invalid","ClaimSubmissionEnabled":true}`, true, "invalid DefaultBlock"}, + } { + t.Run(test.name, func(t *testing.T) { + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return([]byte(test.raw), nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + info := &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: test.requestedEnabled, BlockchainHttpRequestTimeout: time.Second, + }, + EthClient: client, Repository: repo, + } + svc, err := Create(t.Context(), info) + require.Nil(t, svc) + require.ErrorContains(t, err, test.want) + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/startup_signer_test.go b/internal/prt/startup_signer_test.go new file mode 100644 index 000000000..9cfd03a3e --- /dev/null +++ b/internal/prt/startup_signer_test.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "encoding/json" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestCreateExplainsRequiredPRTSigner(t *testing.T) { + for _, enabled := range []bool{true, false} { + name := "submission disabled" + if enabled { + name = "submission enabled" + } + t.Run(name, func(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "mnemonic_file") + viper.Set(config.PRT_AUTH_MNEMONIC, "") + viper.Set(config.PRT_AUTH_MNEMONIC_FILE, filepath.Join(t.TempDir(), "missing-mnemonic")) + t.Cleanup(func() { + viper.Set(config.PRT_AUTH_KIND, nil) + viper.Set(config.PRT_AUTH_MNEMONIC, nil) + viper.Set(config.PRT_AUTH_MNEMONIC_FILE, nil) + }) + raw, err := json.Marshal(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: enabled, + }) + require.NoError(t, err) + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return(raw, nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: enabled, BlockchainHttpRequestTimeout: time.Second, + }, + Repository: repo, EthClient: client, AdapterFactory: &adapterFactoryMock{}, + }) + if enabled { + require.Nil(t, svc) + require.ErrorIs(t, err, os.ErrNotExist, "preserve the underlying credential error") + require.ErrorContains(t, err, "PRT submission is enabled and requires signer configuration (CARTESI_PRT_AUTH_*)") + require.ErrorContains(t, err, "the standalone node always starts PRT") + } else { + require.NoError(t, err, "reader mode must not require signer credentials") + require.Nil(t, svc.(*Service).txOptsFactory) + } + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/supervisor_readiness_test.go b/internal/prt/supervisor_readiness_test.go new file mode 100644 index 000000000..2bd73a8a2 --- /dev/null +++ b/internal/prt/supervisor_readiness_test.go @@ -0,0 +1,63 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSupervisorReportsPRTObservationReadiness(t *testing.T) { + s, repo := newPRTServiceMock() + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: config.ServicePrt, Logger: s.Logger}, PollInterval: time.Hour, + }, s)) + started := make(chan struct{}) + // Stop the first tick before it changes observer eligibility. This test + // isolates the supervisor's use of the PRT-specific readiness method. + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Run(func(mock.Arguments) { close(started) }). + Return([]*model.Application{}, uint64(0), errors.New("application list unavailable")).Once() + supervisor, err := service.NewSupervisor(t.Context(), &service.SupervisorConfigs{ + BaseConfigs: service.BaseConfigs{Name: "node", Logger: s.Logger}, + Factories: []service.FactoryFunction{ + func(context.Context, service.Supervisor) (service.SupervisedService, error) { return s, nil }, + }, + }) + require.NoError(t, err) + finished := make(chan error, 1) + go func() { finished <- supervisor.Serve() }() + t.Cleanup(func() { + supervisor.Stop() + select { + case err := <-finished: + require.NoError(t, err) + case <-time.After(time.Second): + t.Error("supervisor did not stop") + } + }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("PRT service did not start") + } + require.Empty(t, supervisor.NotReady()) + app := prtRevertTestApp() + for head := uint64(100); head < 100+tournamentObservationFailureThreshold; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, errors.New("observation unavailable")) + } + require.Equal(t, []string{config.ServicePrt}, supervisor.NotReady()) + s.clearTournamentObservationFailure(app.ID) + require.Empty(t, supervisor.NotReady()) + repo.AssertExpectations(t) +} diff --git a/internal/prt/tick_policy_test.go b/internal/prt/tick_policy_test.go new file mode 100644 index 000000000..1cf3221c0 --- /dev/null +++ b/internal/prt/tick_policy_test.go @@ -0,0 +1,313 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestPRTTickSharesObservationHeadAndKeepsActionHeadsFresh(t *testing.T) { + s, repo := newPRTServiceMock() + s.defaultBlock = model.DefaultBlock_Finalized + s.submissionEnabled = true + client := ðClientMock{} + factory := &adapterFactoryMock{} + s.client, s.adapterFactory = client, factory + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + apps := []*model.Application{prtRevertTestApp(), prtRevertTestApp()} + apps[1].ID++ + apps[1].Name = "delayed-app" + apps[1].IApplicationAddress = common.HexToAddress("0x900") + apps[1].IConsensusAddress = common.HexToAddress("0x901") + apps[1].ClaimStagingPeriod = 300 + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return(apps, uint64(2), nil).Twice() + for i, app := range apps { + consensus := &daveConsensusAdapterMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Twice() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), nil).Twice() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + // No tournament window is published while the EVM reader catches up. + // Only the independent latest action snapshot can be read at this point. + repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index). + Return((*model.Epoch)(nil), nil).Twice() + for tick := range 2 { + opts := mock.MatchedBy(resultCallOptsAtBlock(120 + uint64(tick*2+i))) + consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + } + t.Cleanup(func() { consensus.AssertExpectations(t) }) + } + for tick := range 2 { + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(int64(100 + tick))}, nil).Once() + for appIndex := range apps { + client.On("BlockNumber", mock.Anything).Return(uint64(120+tick*2+appIndex), nil).Once() + } + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + } + require.Equal(t, 1, strings.Count(output.String(), "Application has no claim staging delay")) + require.Contains(t, output.String(), "without sentries") + require.NotContains(t, output.String(), "application=delayed-app") + require.Empty(t, s.pendingTransactions) + client.AssertExpectations(t) + factory.AssertExpectations(t) + repo.AssertExpectations(t) +} + +func TestPRTTickWithoutApplicationsDoesNotReadHead(t *testing.T) { + s, repo := newPRTServiceMock() + s.client = ðClientMock{} + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), nil).Once() + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.Empty(t, s.client.(*ethClientMock).Calls) + repo.AssertExpectations(t) +} + +func TestPRTTickSuppressesHeadReadCancellationDuringShutdown(t *testing.T) { + s, repo := newPRTServiceMock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + s.client = client + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{prtRevertTestApp()}, uint64(1), nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Run(func(mock.Arguments) { cancel() }).Return((*types.Header)(nil), context.Canceled).Once() + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + require.NoError(t, err) + client.AssertExpectations(t) + repo.AssertExpectations(t) +} + +func TestPRTTickPreservesDependencyCancellationAndDeadlines(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, branch := range []string{"application list", "configured head", "active application", "foreclosed application"} { + t.Run(branch, func(t *testing.T) { + for _, tc := range []struct { + name string + shutdown bool + cause error + quiet bool + }{ + {name: "shutdown cancellation", shutdown: true, cause: context.Canceled, quiet: true}, + {name: "wrapped shutdown", shutdown: true, cause: fmt.Errorf("read: %w", context.Canceled), quiet: true}, + {name: "joined shutdown", shutdown: true, + cause: errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), quiet: true}, + {name: "dependency canceled while runtime active", cause: context.Canceled}, + {name: "database failure during shutdown", shutdown: true, cause: dbErr}, + {name: "mixed database failure during shutdown", shutdown: true, cause: errors.Join(context.Canceled, dbErr)}, + {name: "nested database failure during shutdown", shutdown: true, + cause: fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr))}, + { + name: "joined deadline during shutdown", + shutdown: true, + cause: errors.Join(context.Canceled, context.DeadlineExceeded), + }, + } { + t.Run(tc.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + s.client = client + app := prtRevertTestApp() + if branch == "foreclosed application" { + app.ForecloseBlock = 100 + } + listCall := repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false).Once() + var failureCall *mock.Call + if branch == "application list" { + failureCall = listCall.Return([]*model.Application(nil), uint64(0), tc.cause) + } else { + listCall.Return([]*model.Application{app}, uint64(1), nil) + headCall := client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Once() + if branch == "configured head" { + failureCall = headCall.Return((*types.Header)(nil), tc.cause) + } else { + headCall.Return(&types.Header{Number: big.NewInt(100)}, nil) + failureCall = repo.On("ListEpochs", mock.Anything, app.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch(nil), uint64(0), tc.cause).Once() + } + } + failureCall.Run(func(mock.Arguments) { + if tc.shutdown { + cancel() + } + }) + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if tc.quiet { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.cause, "preserve every cause") + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + client.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } + }) + } +} + +func TestPRTTickRetainsEarlierApplicationErrorDuringShutdown(t *testing.T) { + for _, foreclosed := range []bool{false, true} { + name := "active application" + if foreclosed { + name = "foreclosed second application" + } + t.Run(name, func(t *testing.T) { + s, repo := newPRTServiceMock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + s.client = client + first, second, unvisited := prtRevertTestApp(), prtRevertTestApp(), prtRevertTestApp() + second.ID++ + second.Name = "shutdown-app" + second.IApplicationAddress = common.HexToAddress("0x900") + unvisited.ID += 2 + unvisited.Name = "unvisited-app" + unvisited.IApplicationAddress = common.HexToAddress("0x901") + if foreclosed { + second.ForecloseBlock = 100 + } + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{first, second, unvisited}, uint64(3), nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + firstError := errors.New("first application epoch read failed") + repo.On("ListEpochs", mock.Anything, first.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch(nil), uint64(0), firstError).Once() + repo.On("ListEpochs", mock.Anything, second.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Run(func(mock.Arguments) { cancel() }). + Return([]*model.Epoch(nil), uint64(0), context.Canceled).Once() + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + require.ErrorIs(t, err, firstError) + require.NotErrorIs(t, err, context.Canceled) + require.Contains(t, err.Error(), first.IApplicationAddress.Hex()) + repo.AssertNotCalled(t, "ListEpochs", mock.Anything, unvisited.Name, mock.Anything, mock.Anything, mock.Anything) + client.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestPRTTickConfiguredHeadFailureDoesNotBlockForeclosedBondRecovery(t *testing.T) { + live := prtRevertTestApp() + foreclosed := prtForeclosedApp(live.ID+1, 100) + foreclosed.LastEpochCheckBlock = 99 // Recovery precedes the incomplete-ingestion gate. + foreclosed.IConsensusAddress = common.HexToAddress("0x800") + owned := common.HexToAddress("0x600") + factory := &adapterFactoryMock{} + s := newRootBondTestService(owned, factory) + s.defaultBlock = model.DefaultBlock_Finalized + s.submissionEnabled = true + repo := s.repository.(*prtRepositoryMock) + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{live, foreclosed}, uint64(2), nil).Once() + tournamentAddress := common.HexToAddress("0x300") + s.queueRootBondRecovery(foreclosed.ID, 3, tournamentAddress) + client := ðClientMock{} + headError := errors.New("finalized head unavailable") + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return((*types.Header)(nil), headError).Once() + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + s.client = client + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(120))). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + factory.On("CreateDaveConsensusAdapter", foreclosed.IConsensusAddress).Return(consensus, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(120))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + recoveryTx := types.NewTx(&types.LegacyTx{}) + tournament.On("TryRecoveringBond", mock.Anything).Return(recoveryTx, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.ErrorIs(t, err, headError) + require.Equal(t, 2, strings.Count(err.Error(), headError.Error()), + "each application reports its failed passive observation") + require.Equal(t, recoveryTx.Hash(), *s.rootBondRecoveries[foreclosed.ID][0].TxHash) + client.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + factory.AssertExpectations(t) + repo.AssertExpectations(t) +} + +func TestPRTTickPublishesObservationBeforePendingBondMaintenance(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = true + epoch := checkpointEpoch(3, "0x100") + epoch.Status = model.EpochStatus_ClaimAccepted + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + f.client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + published := false + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Address == *epoch.TournamentAddress + }), uint64(100)).Run(func(mock.Arguments) { published = true }).Return(nil).Once() + transaction := types.NewTx(&types.LegacyTx{Nonce: 1}) + hash := transaction.Hash() + f.s.rootBondRecoveries[f.app.ID] = []*rootBondRecovery{{ + EpochIndex: epoch.Index, Tournament: *epoch.TournamentAddress, TxHash: &hash, + }} + f.client.On("TransactionByHash", mock.Anything, hash). + Run(func(mock.Arguments) { + require.True(t, published, "a pending refund must not stop passive state publication") + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }).Return(transaction, true, nil).Once() + + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.True(t, published) + require.Equal(t, hash, *f.s.rootBondRecoveries[f.app.ID][0].TxHash) + require.Empty(t, f.s.pendingTransactions, "the observer must not submit tournament actions") +} diff --git a/internal/prt/validation_test.go b/internal/prt/validation_test.go index afa495e28..32bf0d1e9 100644 --- a/internal/prt/validation_test.go +++ b/internal/prt/validation_test.go @@ -7,7 +7,6 @@ import ( "bytes" "context" "errors" - "io" "log/slog" "math/big" "strings" @@ -67,59 +66,50 @@ func TestLogErrorUnlessShutdown(t *testing.T) { } } -func TestTrySettleOperationDeadlineDoesNotCancelServiceContext(t *testing.T) { - s, app := newValidationService(t) - ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) - defer cancel() - - err := s.trySettle(ctx, app, 1) - - require.ErrorIs(t, err, context.DeadlineExceeded) - require.NoError(t, t.Context().Err()) -} - -func TestTrySettleShutdownCancelsOperationContext(t *testing.T) { - s, app := newValidationService(t) - parentCtx, parentCancel := context.WithCancel(context.Background()) - ctx, cancel := context.WithTimeout(parentCtx, time.Second) - defer cancel() - - time.AfterFunc(50*time.Millisecond, parentCancel) - start := time.Now() - err := s.trySettle(ctx, app, 1) - - require.ErrorIs(t, err, context.Canceled) - require.ErrorIs(t, ctx.Err(), context.Canceled) - require.Less(t, time.Since(start), 500*time.Millisecond) -} - -func TestReactToTournamentOperationDeadlineDoesNotCancelServiceContext(t *testing.T) { - s, app := newValidationService(t) - ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) - defer cancel() - - err := s.reactToTournament(ctx, app, 1) - - require.ErrorIs(t, err, context.DeadlineExceeded) - require.NoError(t, t.Context().Err()) -} - -func TestReactToTournamentShutdownCancelsOperationContext(t *testing.T) { - s, app := newValidationService(t) - parentCtx, parentCancel := context.WithCancel(context.Background()) - ctx, cancel := context.WithTimeout(parentCtx, time.Second) - defer cancel() - - time.AfterFunc(50*time.Millisecond, parentCancel) - start := time.Now() - err := s.reactToTournament(ctx, app, 1) +func TestPRTOperationContextCancellation(t *testing.T) { + for _, test := range []struct { + name string + staged bool + shutdown bool + }{ + {name: "AcceptResult/OperationDeadline", staged: true}, + {name: "AcceptResult/Shutdown", staged: true, shutdown: true}, + {name: "ReactToTournament/OperationDeadline"}, + {name: "ReactToTournament/Shutdown", shutdown: true}, + } { + t.Run(test.name, func(t *testing.T) { + s, app, epoch := newValidationService(t, test.staged) + parentCtx, parentCancel := context.WithCancel(t.Context()) + defer parentCancel() + timeout := 50 * time.Millisecond + if test.shutdown { + timeout = time.Second + timer := time.AfterFunc(50*time.Millisecond, parentCancel) + defer timer.Stop() + } + ctx, cancel := context.WithTimeout(parentCtx, timeout) + defer cancel() - require.ErrorIs(t, err, context.Canceled) - require.ErrorIs(t, ctx.Err(), context.Canceled) - require.Less(t, time.Since(start), 500*time.Millisecond) + start := time.Now() + var err error + if test.staged { + _, _, err = s.progressTournamentResult(ctx, app, 1, 1) + } else { + _, err = s.reactToTournament(ctx, app, epoch, 1) + } + if test.shutdown { + require.ErrorIs(t, err, context.Canceled) + require.ErrorIs(t, ctx.Err(), context.Canceled) + require.Less(t, time.Since(start), 500*time.Millisecond) + } else { + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NoError(t, parentCtx.Err()) + } + }) + } } -func newValidationService(t *testing.T) (*Service, *model.Application) { +func newValidationService(t *testing.T, staged bool) (*Service, *model.Application, *model.Epoch) { t.Helper() app := repotest.NewApplicationBuilder(). @@ -127,8 +117,12 @@ func newValidationService(t *testing.T) (*Service, *model.Application) { Build() app.IConsensusAddress = common.HexToAddress("0x3") + status := model.EpochStatus_ClaimComputed + if staged { + status = model.EpochStatus_ClaimStaged + } epoch := repotest.NewEpochBuilder(app.ID). - WithStatus(model.EpochStatus_ClaimComputed). + WithStatus(status). WithMachineHash(common.HexToHash("0x6")). WithTxBufferDataBlock(common.HexToHash("0x8")). Build() @@ -137,9 +131,12 @@ func newValidationService(t *testing.T) (*Service, *model.Application) { epoch.TournamentAddress = &tournamentAddress epoch.Commitment = &commitment epoch.CommitmentProof = []common.Hash{common.HexToHash("0x7")} - epoch.TxBufferProof = []common.Hash{} + if staged { + epoch.StagedAtBlock = new(uint64) + *epoch.StagedAtBlock = 1 + } - repo := &prtRepositoryMock{} + s, repo := newPRTServiceMock() repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), uint64(0)). Return(epoch, nil) repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), uint64(0), @@ -148,19 +145,41 @@ func newValidationService(t *testing.T) (*Service, *model.Application) { Maybe() consensusAdapter := &daveConsensusAdapterMock{} - consensusAdapter.On("CanSettle", mock.Anything). - Return(CanSettleResult{IsFinished: true, EpochNumber: big.NewInt(0)}, nil) - consensusAdapter.On("IsEpochSettled", mock.Anything, uint64(0)). - Return(false, nil) - consensusAdapter.On("Settle", mock.Anything, big.NewInt(0), [32]byte(common.HexToHash("0x8")), [][32]byte{}). - Return((*types.Transaction)(nil), func(opts *bind.TransactOpts, _ *big.Int, _ [32]byte, _ [][32]byte) error { + consensusAdapter.On("GetCurrentSealedEpoch", mock.Anything).Return(CurrentSealedEpoch{ + EpochNumber: 0, + Tournament: tournamentAddress, + IsTournamentResultStaged: true, + StagingBlockNumber: 1, + StagedPostEpochMachineStateHash: *epoch.MachineHash, + StagedPostEpochOutputsMerkleRoot: *epoch.TxBufferDataBlock, + }, nil) + consensusAdapter.On("CanStageTournamentResult", mock.Anything).Return(CanStageTournamentResult{ + IsFinished: true, + IsTournamentResultStaged: true, + EpochNumber: 0, + WinnerCommitment: commitment, + WinnerPostEpochMachineStateHash: *epoch.MachineHash, + }, nil) + consensusAdapter.On("CanAcceptStagedTournamentResult", mock.Anything).Return(CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: true, + IsClaimStagingPeriodOver: true, + EpochNumber: 0, + StagedPostEpochMachineStateHash: *epoch.MachineHash, + StagedPostEpochOutputsMerkleRoot: *epoch.TxBufferDataBlock, + }, nil) + consensusAdapter.On("AcceptStagedTournamentResult", mock.Anything, uint64(0)). + Return((*types.Transaction)(nil), func(opts *bind.TransactOpts, _ uint64) error { <-opts.Context.Done() return opts.Context.Err() }) tournamentAdapter := &tournamentAdapterMock{} - tournamentAdapter.On("IsCommitmentJoined", mock.Anything, [32]byte(commitment)). - Return(false, nil) + if !staged { + tournamentAdapter.On("Descriptor", mock.Anything). + Return(TournamentDescriptor{Height: model.Log2EpochComputationHashLeafCount}, nil).Once() + } + tournamentAdapter.On("CommitmentStanding", mock.Anything, [32]byte(commitment)). + Return(CommitmentStanding{}, nil) tournamentAdapter.On("BondValue", mock.Anything). Return(big.NewInt(0), nil) tournamentAdapter.On("JoinTournament", @@ -182,23 +201,11 @@ func newValidationService(t *testing.T) (*Service, *model.Application) { adapterFactory.On("CreateTournamentAdapter", tournamentAddress). Return(tournamentAdapter, nil) - s := &Service{ - TickServiceTemplate: service.TickServiceTemplate{ - BaseTemplate: service.BaseTemplate{ - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - }, - }, - repository: repo, - adapterFactory: adapterFactory, - submissionEnabled: true, - submissionTimeout: time.Second, - txOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{ - From: common.HexToAddress("0x9"), - }), - currentEpochIndex: map[int64]uint64{}, - settleInFlight: map[int64]*common.Hash{}, - joinInFlight: map[int64]*common.Hash{}, - } - s.currentEpochIndex[app.ID] = 0 - return s, app + s.adapterFactory = adapterFactory + s.submissionEnabled = true + s.submissionTimeout = time.Second + s.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{ + From: common.HexToAddress("0x9"), + }) + return s, app, epoch } From ebdebb99264f6bea8fc22d1496728005136d6b87 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:19 -0300 Subject: [PATCH 39/41] test(integration): cover v3 consensus flows Adapt end-to-end workflows to v3 deployment, direct InputBox access, machine proofs, and staging. Update the withdrawal application fixture for the USD account layout. Cover Authority, Quorum, and PRT lifecycles, staging and sentry timing, foreclosure and replay, same-block inputs, restarts, and snapshot policy. Keep the shared Anvil and input-relay fixtures here. Verify address-only send and file-based execute without database access. Reject a mined no-op at a code-free address with manual gas, then prove valid execution and node indexing. Keep decimal text and hex JSON. Passive dispute observation and deposit refunds have separate following integration commits so reviewers can inspect those scenarios separately. --- Makefile | 6 +- test/dapps/erc20-withdrawal/install.sh | 14 +- test/integration/anvil_helpers_test.go | 129 +++++++++-- test/integration/cli_helpers_test.go | 65 ++++-- test/integration/cli_helpers_unit_test.go | 127 +++++++++++ test/integration/divergent_claim_test.go | 143 +++++------- .../echo_authority_staging_test.go | 46 +++- .../echo_authority_transactions_test.go | 118 ++++++++++ test/integration/echo_prt_sentries_test.go | 215 ++++++++++++++++++ test/integration/echo_prt_staging_test.go | 123 ++++++++++ test/integration/echo_prt_test.go | 6 +- test/integration/echo_quorum_test.go | 188 ++++++++++----- test/integration/foreclose_prt_test.go | 15 +- test/integration/foreclose_replay_test.go | 13 +- test/integration/foreclose_test.go | 7 +- test/integration/input_relay_helpers_test.go | 102 +++++++++ test/integration/lifecycle_test.go | 25 +- test/integration/logscanner_test.go | 9 + test/integration/logscanner_unit_test.go | 56 +++++ test/integration/multi_app_test.go | 31 ++- test/integration/multinode_helpers_test.go | 61 +---- test/integration/node_helpers_test.go | 48 ++-- test/integration/node_subprocess_test.go | 107 +++++++++ test/integration/reject_exception_prt_test.go | 4 +- test/integration/restart_test.go | 46 +++- test/integration/same_block_inputs_test.go | 152 ++++++++----- test/integration/snapshot_policy_test.go | 20 +- .../terminal_machine_states_test.go | 9 +- test/integration/testdata/InputRelay.sol | 21 ++ .../integration/testdata/input_relay_abi.json | 6 + .../testdata/input_relay_bytecode.hex | 1 + test/integration/withdrawal_lifecycle_test.go | 23 +- 32 files changed, 1526 insertions(+), 410 deletions(-) create mode 100644 test/integration/cli_helpers_unit_test.go create mode 100644 test/integration/echo_authority_transactions_test.go create mode 100644 test/integration/echo_prt_sentries_test.go create mode 100644 test/integration/echo_prt_staging_test.go create mode 100644 test/integration/input_relay_helpers_test.go create mode 100644 test/integration/logscanner_unit_test.go create mode 100644 test/integration/node_subprocess_test.go create mode 100644 test/integration/testdata/InputRelay.sol create mode 100644 test/integration/testdata/input_relay_abi.json create mode 100644 test/integration/testdata/input_relay_bytecode.hex diff --git a/Makefile b/Makefile index c2bb695d1..2d7cd3cfb 100644 --- a/Makefile +++ b/Makefile @@ -615,11 +615,11 @@ check-license: ## Verify license headers on Go source files # dependency for the check to build on the CI setup runner. INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal awskms -INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|TerminalMachineStates|MultiApp|EchoAuthorityStaging)$$ +INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|TerminalMachineStates|MultiApp|EchoAuthorityStaging|IntegrationCLI|IntegrationLogScanner)$$ INTEGRATION_SHARD_quorum := ^Test(EchoQuorum|SameBlockInputs)$$ INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt)$$ INTEGRATION_SHARD_replay := ^Test(Foreclose|ForecloseReplay|DivergentClaim)$$ -INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy)$$ +INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy|NodeSubprocess)$$ INTEGRATION_SHARD_withdrawal := ^TestWithdrawalLifecycle$$ INTEGRATION_SHARD_awskms := ^TestLocalStackAWSIntegration$$ @@ -765,7 +765,7 @@ _local-topology-%: if [ -n "$(filter awskms,$(SHARD))" ]; then \ if [ -z "$$LOCALSTACK_KMS_ENDPOINT" ]; then \ echo "ERROR: LOCALSTACK_KMS_ENDPOINT is required when SHARD includes awskms." >&2; \ - echo "Run 'make start-awslocalstack' and export the variables it prints." >&2; \ + echo "Run 'make start-awslocalstack', then eval \"\$$(make env-awslocalstack)\"." >&2; \ exit 1; \ fi; \ export LOCALSTACK_KMS_REQUIRED=true; \ diff --git a/test/dapps/erc20-withdrawal/install.sh b/test/dapps/erc20-withdrawal/install.sh index c7acd7ef7..bd7940263 100644 --- a/test/dapps/erc20-withdrawal/install.sh +++ b/test/dapps/erc20-withdrawal/install.sh @@ -79,13 +79,21 @@ zero_record() { } record_address() { - printf '%s' "${1:16:40}" + printf '%s' "${1:24:40}" } record_balance() { uint64_be_to_dec "$(reverse_bytes "${1:0:16}")" } +encode_record() { + local balance="$1" + local address="$2" + # This fixture limits arithmetic to positive int64 values. Store them + # zero-extended in the uint96 balance field required by LibUsdAccount. + printf '%s00000000%s' "$(uint64_dec_to_le "$balance")" "$address" +} + find_account_index() { local address="$1" local i record @@ -138,7 +146,7 @@ credit_account() { if (( new_balance <= 0 )); then return 1 fi - write_record "$idx" "$(uint64_dec_to_le "$new_balance")${address}00000000" + write_record "$idx" "$(encode_record "$new_balance" "$address")" } debit_account() { @@ -160,7 +168,7 @@ debit_account() { new_balance=$((balance - amount)) if (( new_balance > 0 )); then - write_record "$idx" "$(uint64_dec_to_le "$new_balance")${address}00000000" + write_record "$idx" "$(encode_record "$new_balance" "$address")" return 0 fi diff --git a/test/integration/anvil_helpers_test.go b/test/integration/anvil_helpers_test.go index 66c9670fc..846393085 100644 --- a/test/integration/anvil_helpers_test.go +++ b/test/integration/anvil_helpers_test.go @@ -9,8 +9,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "math/big" "net/http" "testing" "time" @@ -25,7 +27,13 @@ import ( // maxBlocksToMine is the sanity cap for mineForTournamentTimeout to prevent // hanging if the tournament contract reports an unreasonably large allowance. -const maxBlocksToMine = 10_000 +const ( + maxBlocksToMine = 10_000 + bondDispositionTournamentRunning = 0 + bondDispositionNoWinner = 1 + bondDispositionRecoverable = 2 + bondDispositionRecovered = 3 +) // Anvil devnet RPC helpers. @@ -115,9 +123,9 @@ func mineForTournamentTimeout( return 0, fmt.Errorf("bind tournament: %w", err) } - args, err := tournament.TournamentArguments(&bind.CallOpts{Context: ctx}) + descriptor, err := tournament.TournamentDescriptor(&bind.CallOpts{Context: ctx}) if err != nil { - return 0, fmt.Errorf("tournament arguments: %w", err) + return 0, fmt.Errorf("tournament descriptor: %w", err) } currentBlock, err := client.BlockNumber(ctx) @@ -125,10 +133,10 @@ func mineForTournamentTimeout( return 0, fmt.Errorf("block number: %w", err) } - finishBlock := args.StartInstant + args.Allowance - if finishBlock < args.StartInstant { // uint64 overflow + finishBlock := descriptor.StartInstant + descriptor.Allowance + if finishBlock < descriptor.StartInstant { // uint64 overflow return 0, fmt.Errorf("tournament timeout overflows: start=%d allowance=%d", - args.StartInstant, args.Allowance) + descriptor.StartInstant, descriptor.Allowance) } if currentBlock >= finishBlock { return 0, nil // Already past the timeout. @@ -205,19 +213,20 @@ func waitForTournamentAndCommitment( } // waitForTournamentWinner polls until the root tournament for the given epoch -// has a winner commitment. +// has a winner commitment and the contract's finish block is persisted. func waitForTournamentWinner( ctx context.Context, t testing.TB, require *require.Assertions, + client *ethclient.Client, appName string, epochIndex uint64, ) { t.Helper() tctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() var lastErr error + var winner *model.Tournament err := pollUntil(tctx, 5*time.Second, func() (bool, error) { resp, err := readTournaments(tctx, appName) if err != nil { @@ -229,17 +238,35 @@ func waitForTournamentWinner( return false, fmt.Errorf("poll tournament winner: %w", err) } tournament := findRootTournament(resp.Data, epochIndex) - return tournament != nil && tournament.WinnerCommitment != nil, nil + if tournament == nil || tournament.Snapshot.WinnerCommitment == nil || tournament.Snapshot.FinishedAtBlock == 0 { + return false, nil + } + winner = tournament + return true, nil }) if err != nil && lastErr != nil { err = fmt.Errorf("%w (last poll error: %v)", err, lastErr) } + cancel() require.NoError(err, "wait for epoch %d tournament winner", epochIndex) + + tournament, err := itournament.NewITournament(winner.Address, client) + require.NoError(err, "bind epoch %d root tournament", epochIndex) + descriptorCtx, descriptorCancel := context.WithTimeout(ctx, 30*time.Second) + defer descriptorCancel() + descriptor, err := tournament.TournamentDescriptor(&bind.CallOpts{Context: descriptorCtx}) + require.NoError(err, "read epoch %d root tournament descriptor", epochIndex) + expectedFinishedAt := descriptor.StartInstant + descriptor.Allowance + require.GreaterOrEqual(expectedFinishedAt, descriptor.StartInstant, + "epoch %d root tournament finish block must not overflow", epochIndex) + require.Equal(expectedFinishedAt, winner.Snapshot.FinishedAtBlock, + "epoch %d must persist the exact on-chain finish block", epochIndex) } -// settleTournament runs the full tournament cycle for a given epoch: -// wait for tournament+commitment, mine past the timeout, wait for winner. -func settleTournament( +// finalizePrtEpoch runs the uncontested PRT lifecycle for one epoch. It waits +// for the node commitment, finishes the root tournament, waits for the staged +// result to be accepted, and confirms recovery of the node-owned root bond. +func finalizePrtEpoch( ctx context.Context, t testing.TB, require *require.Assertions, @@ -248,7 +275,7 @@ func settleTournament( epochIndex uint64, ) { t.Helper() - defer timed(t, fmt.Sprintf("settle tournament epoch %d", epochIndex))() + defer timed(t, fmt.Sprintf("finalize PRT epoch %d", epochIndex))() t.Logf("Waiting for PRT to create a root tournament and join with a commitment for epoch %d...", epochIndex) @@ -262,10 +289,80 @@ func settleTournament( require.NoError(err, "mine for epoch %d tournament timeout", epochIndex) t.Logf(" mined %d blocks to reach timeout", blocksMined) - t.Logf("Waiting for the PRT service to settle epoch %d (uncontested single-commitment win)...", + t.Logf("Waiting for the PRT service to observe the epoch %d uncontested winner...", epochIndex) - waitForTournamentWinner(ctx, t, require, appName, epochIndex) - t.Logf(" epoch %d tournament settled — winner declared", epochIndex) + waitForTournamentWinner(ctx, t, require, client, appName, epochIndex) + t.Logf(" epoch %d tournament finished — winner declared", epochIndex) + + waitForPrtEpochAcceptedAndBondRecovered(ctx, t, require, client, appName, epochIndex, tournament.Address) +} + +// waitForPrtEpochAcceptedAndBondRecovered verifies the new Dave settlement +// boundary. A configured-block observation can skip the entire stage interval, +// so acceptance does not require a locally observed CLAIM_STAGED transition. +// The old root tournament bond must reach RECOVERED on chain. +func waitForPrtEpochAcceptedAndBondRecovered( + ctx context.Context, + t testing.TB, + require *require.Assertions, + client *ethclient.Client, + appName string, + epochIndex uint64, + tournamentAddress common.Address, +) *model.Epoch { + t.Helper() + + acceptedCtx, acceptedCancel := context.WithTimeout(ctx, claimAcceptedTimeout) + accepted, err := waitForEpochStatus( + acceptedCtx, t, appName, epochIndex, model.EpochStatus_ClaimAccepted) + acceptedCancel() + require.NoError(err, "wait for PRT epoch %d acceptance", epochIndex) + require.NotNil(accepted.ClaimTransactionHash, + "accepted PRT epoch %d must retain its EpochSealed transaction", epochIndex) + + recoveryCtx, recoveryCancel := context.WithTimeout(ctx, claimAcceptedTimeout) + defer recoveryCancel() + waitForRootBondRecovered(recoveryCtx, t, require, client, tournamentAddress) + t.Logf(" epoch %d accepted and root bond recovered", epochIndex) + return accepted +} + +func waitForRootBondRecovered( + ctx context.Context, + t testing.TB, + require *require.Assertions, + client *ethclient.Client, + tournamentAddress common.Address, +) { + t.Helper() + tournament, err := itournament.NewITournament(tournamentAddress, client) + require.NoError(err, "bind root tournament %s", tournamentAddress) + + var recoveredClaimer common.Address + var recoveredPayment *big.Int + err = pollUntil(ctx, 2*time.Second, func() (bool, error) { + recovery, err := tournament.BondRecovery(&bind.CallOpts{Context: ctx}) + if err != nil { + return false, fmt.Errorf("read root bond recovery: %w", err) + } + switch recovery.Disposition { + case bondDispositionRecovered: + recoveredClaimer = recovery.Claimer + recoveredPayment = recovery.Payment + return true, nil + case bondDispositionNoWinner: + return false, errors.New("root tournament finished without a recoverable winner") + case bondDispositionTournamentRunning, bondDispositionRecoverable: + return false, nil + default: + return false, fmt.Errorf("root tournament returned unknown bond disposition %d", recovery.Disposition) + } + }) + require.NoError(err, "wait for root bond recovery at %s", tournamentAddress) + require.Equal(common.Address{}, recoveredClaimer, + "recovered bond must clear the claimer") + require.NotNil(recoveredPayment, "recovered bond must return a payment value") + require.Zero(recoveredPayment.Sign(), "recovered bond must clear the pending payment") } // findRootTournament returns the root tournament for the given epoch index, diff --git a/test/integration/cli_helpers_test.go b/test/integration/cli_helpers_test.go index 537153478..235f7fa63 100644 --- a/test/integration/cli_helpers_test.go +++ b/test/integration/cli_helpers_test.go @@ -1,8 +1,6 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) -//go:build endtoendtests - package integration import ( @@ -14,18 +12,32 @@ import ( "fmt" "os" "os/exec" + "slices" "strconv" "time" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/jsonrpc/api" "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" ) const cliBinary = "cartesi-rollups-cli" +const ( + deployCommand = "deploy" + jsonFlag = "--json" + prtFlag = "--prt" +) + +// The devnet funds these mnemonic accounts. Account 0 submits node claims; +// account 6 submits node PRT actions. Account 5 pays for ordinary test CLI +// transactions and relay calls, which the test driver runs sequentially. +const integrationCLIAccountIndex uint32 = 5 + // cliCommandTimeout is the maximum time a single CLI command may run before // being killed. This prevents a hanging command from consuming the entire // suite timeout. @@ -95,17 +107,19 @@ func runCLI(ctx context.Context, args ...string) (string, error) { return runCLIWithEnv(ctx, nil, args...) } -// runCLIWithEnv is like runCLI but allows appending environment variables -// to the subprocess. Used for selecting a non-default signer (e.g., -// CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1 when the guardian wallet differs -// from the node's default account). +// runCLIWithEnv keeps the test driver's transactions separate from node +// transactions. Overrides are applied last so tests can select a guardian, +// depositor, or other actor. The node's environment is never changed. func runCLIWithEnv(ctx context.Context, extraEnv []string, args ...string) (string, error) { cmdCtx, cancel := context.WithTimeout(ctx, cliCommandTimeout) defer cancel() cmd := exec.CommandContext(cmdCtx, cliBinary, args...) - if len(extraEnv) > 0 { - cmd.Env = append(os.Environ(), extraEnv...) - } + cmd.Env = append(os.Environ(), + "CARTESI_AUTH_KIND=mnemonic", + "CARTESI_AUTH_MNEMONIC="+ethutil.FoundryMnemonic, + fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", integrationCLIAccountIndex), + ) + cmd.Env = append(cmd.Env, extraEnv...) out, err := cmd.Output() if err != nil { var exitErr *exec.ExitError @@ -126,11 +140,27 @@ func runCLIWithEnv(ctx context.Context, extraEnv []string, args ...string) (stri // the application address from the JSON output. // Extra CLI flags (e.g., "--salt", value, "--prt") can be appended via extraArgs. func deployApplication(ctx context.Context, appName, dappPath string, extraArgs ...string) (string, error) { - args := []string{"deploy", "application", appName, dappPath, "--json"} - args = append(args, extraArgs...) + address, _, err := deployApplicationWithConsensus(ctx, appName, dappPath, extraArgs...) + return address, err +} + +// deployApplicationWithConsensus also returns the consensus address. The +// Authority owner remains the devnet Claimer (account 0), not the test payer. +// Explicit owner flags in extraArgs take precedence over this fixture default. +func deployApplicationWithConsensus( + ctx context.Context, + appName, dappPath string, + extraArgs ...string, +) (string, string, error) { + key, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + if err != nil { + return "", "", fmt.Errorf("derive devnet Authority owner: %w", err) + } + args := slices.Concat([]string{deployCommand, "application", appName, dappPath, jsonFlag, + "--authority-owner", crypto.PubkeyToAddress(key.PublicKey).Hex()}, extraArgs) out, err := runCLI(ctx, args...) if err != nil { - return "", fmt.Errorf("deploy: %w", err) + return "", "", fmt.Errorf("deploy: %w", err) } var app struct { @@ -138,9 +168,12 @@ func deployApplication(ctx context.Context, appName, dappPath string, extraArgs IConsensusAddress string `json:"iconsensus_address"` } if err := json.Unmarshal([]byte(out), &app); err != nil { - return "", fmt.Errorf("parse deploy output: %w", err) + return "", "", fmt.Errorf("parse deploy output: %w", err) + } + if app.IApplicationAddress == "" || app.IConsensusAddress == "" { + return "", "", fmt.Errorf("deploy output missing addresses: %s", out) } - return app.IApplicationAddress, nil + return app.IApplicationAddress, app.IConsensusAddress, nil } // disableApplication sets the application status to disabled so the node @@ -178,7 +211,7 @@ func inspectApplication(ctx context.Context, appName, payload string) (*inspectR // sendInput sends a payload to the application and returns (inputIndex, blockNumber). func sendInput(ctx context.Context, appName string, payload string) (uint64, uint64, error) { - out, err := runCLI(ctx, "send", appName, payload, "--yes", "--json") + out, err := runCLI(ctx, "send", appName, payload, "--yes", jsonFlag) if err != nil { return 0, 0, fmt.Errorf("send: %w", err) } @@ -264,7 +297,7 @@ func readInput(ctx context.Context, appName string, inputIndex uint64) (*model.I // executeOutput executes a voucher on L1 via the CLI. func executeOutput(ctx context.Context, appName string, index uint64) (string, error) { - out, err := runCLI(ctx, "execute", appName, strconv.FormatUint(index, 10), "--yes", "--json") + out, err := runCLI(ctx, "execute", appName, strconv.FormatUint(index, 10), "--yes", jsonFlag) if err != nil { return "", fmt.Errorf("execute: %w", err) } diff --git a/test/integration/cli_helpers_unit_test.go b/test/integration/cli_helpers_unit_test.go new file mode 100644 index 000000000..851d600b0 --- /dev/null +++ b/test/integration/cli_helpers_unit_test.go @@ -0,0 +1,127 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" +) + +// These tests run without endtoendtests. The fake CLI checks the actual +// subprocess arguments and environment without starting a node or using L1. +func TestIntegrationCLI(t *testing.T) { + t.Run("signers", func(t *testing.T) { + installTestCLI(t, `printf '%s\n' "$CARTESI_AUTH_KIND" "$CARTESI_AUTH_MNEMONIC" \ +"$CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX" "$CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX" \ +"$CARTESI_DATABASE_CONNECTION" "$@"`) + const parentDatabase = "parent-database" + t.Setenv("CARTESI_AUTH_KIND", "private_key") + t.Setenv("CARTESI_AUTH_MNEMONIC", "parent mnemonic must not be used") + t.Setenv("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX", "0") + t.Setenv("CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX", "6") + t.Setenv("CARTESI_DATABASE_CONNECTION", parentDatabase) + for _, tc := range []struct { + name, index, database string + overrides []string + }{ + {name: "default payer", index: "5", database: parentDatabase}, + {name: "unrelated override", index: "5", database: "unavailable", overrides: []string{ + "CARTESI_DATABASE_CONNECTION=unavailable", + }}, + {name: "guardian", index: "1", database: parentDatabase, overrides: []string{ + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1", + }}, + {name: "depositor", index: "8", database: parentDatabase, overrides: []string{ + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=8", + }}, + {name: "refund payer", index: "9", database: parentDatabase, overrides: []string{ + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=9", + }}, + } { + t.Run(tc.name, func(t *testing.T) { + before := os.Environ() + var out string + var err error + if tc.overrides == nil { + out, err = runCLI(t.Context(), "send", "app", "payload with spaces") + } else { + out, err = runCLIWithEnv(t.Context(), tc.overrides, "send", "app", "payload with spaces") + } + require.NoError(t, err) + require.Equal(t, []string{"mnemonic", ethutil.FoundryMnemonic, tc.index, "6", tc.database, + "send", "app", "payload with spaces"}, strings.Split(strings.TrimSpace(out), "\n")) + require.Equal(t, before, os.Environ(), "CLI execution must not change the node environment") + }) + } + }) + + t.Run("deployment", func(t *testing.T) { + installTestCLI(t, `printf '%s\n' "$CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX" "$@" > "$CARTESI_TEST_CLI_CAPTURE" +printf '%s\n' "$CARTESI_TEST_CLI_RESULT"`) + capture := filepath.Join(t.TempDir(), "arguments") + t.Setenv("CARTESI_TEST_CLI_CAPTURE", capture) + t.Setenv("CARTESI_TEST_CLI_RESULT", `{"iapplication_address":"application","iconsensus_address":"consensus"}`) + const claimerAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + const ownerOverride = "0x0000000000000000000000000000000000001234" + for _, tc := range []struct { + name, owner string + args []string + }{ + {name: "authority", owner: claimerAddress}, + {name: "prt", owner: claimerAddress, args: []string{prtFlag}}, + {name: "external consensus", owner: claimerAddress, args: []string{"--consensus", "external"}}, + {name: "explicit owner", owner: ownerOverride, args: []string{"--authority-owner", ownerOverride}}, + {name: "owner shorthand", owner: ownerOverride, args: []string{"-O", ownerOverride}}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, withConsensus := range []bool{false, true} { + var address string + var err error + if withConsensus { + var consensus string + address, consensus, err = deployApplicationWithConsensus(t.Context(), "app", "template", tc.args...) + require.NoError(t, err) + require.Equal(t, "consensus", consensus) + } else { + address, err = deployApplication(t.Context(), "app", "template", tc.args...) + require.NoError(t, err) + } + require.Equal(t, "application", address) + captured, err := os.ReadFile(capture) + require.NoError(t, err) + args := strings.Split(strings.TrimSpace(string(captured)), "\n") + require.Equal(t, []string{"5", deployCommand, "application"}, args[:3]) + flags := pflag.NewFlagSet("application", pflag.ContinueOnError) + owner := flags.StringP("authority-owner", "O", "", "") + flags.Bool("json", false, "") + flags.Bool("prt", false, "") + flags.String("consensus", "", "") + require.NoError(t, flags.Parse(args[3:])) + require.Equal(t, []string{"app", "template"}, flags.Args()) + require.Equal(t, tc.owner, *owner) + } + }) + } + for _, result := range []string{"{", `{}`, `{"iapplication_address":"application"}`} { + t.Setenv("CARTESI_TEST_CLI_RESULT", result) + _, _, err := deployApplicationWithConsensus(t.Context(), "app", "template") + require.Error(t, err, "incomplete deployment output must fail") + } + }) +} + +func installTestCLI(t *testing.T, script string) { + t.Helper() + dir := t.TempDir() + // The fixture contains no secrets and must be executable by the subprocess. + err := os.WriteFile(filepath.Join(dir, cliBinary), []byte("#!/bin/sh\nset -eu\n"+script+"\n"), 0o755) //nolint:gosec + require.NoError(t, err) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/test/integration/divergent_claim_test.go b/test/integration/divergent_claim_test.go index 997673051..46f013a6e 100644 --- a/test/integration/divergent_claim_test.go +++ b/test/integration/divergent_claim_test.go @@ -7,8 +7,6 @@ package integration import ( "context" - "crypto/rand" - "encoding/json" "fmt" "math/big" "regexp" @@ -21,6 +19,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -36,8 +35,8 @@ import ( // 2. Send inputs 0 and 1 in distinct epochs; wait for legitimate ACCEPT. // 3. Stop the node so the attacker can race the pipeline deterministically. // 4. Send input 2 and mine past the 3rd epoch's last block. -// 5. Attacker submits a divergent claim for epoch 2 (random outputsMerkleRoot, -// reusing epoch 1's proof for valid-length argument bytes). The chain +// 5. Attacker submits a divergent claim for the third input epoch by +// reusing the second input epoch's complete machine-validity proof. The chain // emits ClaimSubmitted + ClaimStaged with the divergent machine root. // acceptClaim is intentionally NOT called — this models the realistic // attacker who pushes a single divergent claim and disappears. @@ -53,9 +52,9 @@ import ( // so the claimer cannot submit anything; only the read-only scan // pipeline runs. // 9. Re-register the same on-chain address as app B. -// 10. The reader-mode node replays inputs 0-2, finds epochs 0/1 -// legitimately accepted (reconciles), reaches CLAIM_COMPUTED for -// epoch 2, scans the chain, finds the divergent claim, and marks B +// 10. The reader-mode node replays inputs 0-2, finds the first two input +// epochs legitimately accepted (reconciles), reaches CLAIM_COMPUTED for +// the third input epoch, scans the chain, finds the divergent claim, and marks B // DIVERGED. The point of this phase is to confirm that the // divergence-detection path is independent of the submission path — // a node with no key (or a paranoid operator who has disabled @@ -112,11 +111,8 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { // divergence reasons — Authority's submit-stage-accept lifecycle means // whichever scan (ClaimSubmitted or ClaimAccepted) lands first wins, // and both are terminal. The claimer's tick wraps the transition error - // and re-logs it, so we allow-list that too. Stopping the node mid- - // tick (Phase 1.5 and Phase 2 transitions) cancels in-flight RPC - // queries, producing a handful of evmreader ERR lines that are benign - // shutdown noise. The rapid mining can race the EVM reader's block - // fetcher; tolerate transient BlockOutOfRangeError. + // and re-logs it, so we allow-list that too. Rapid mining can race the + // EVM reader's block fetcher; tolerate transient BlockOutOfRangeError. s.SetExpectedLogs(s.T(), ExpectedLog{ Pattern: regexp.MustCompile( @@ -131,12 +127,6 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { Level: LevelError, Reason: "claimer Tick wraps and re-logs the divergence-induced DIVERGED error", }, - ExpectedLog{ - Pattern: regexp.MustCompile(`service=evm-reader.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from stopping the node mid-tick; " + - "retryablehttp wraps the cancellation as `Post \"\": context canceled`", - }, ExpectedLog{ Pattern: regexp.MustCompile(`BlockOutOfRangeError`), Level: LevelError, @@ -157,7 +147,7 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { const guardianIndex = 1 withdrawalConfigJSON, _ := withdrawalConfigForGuardian(s.T(), guardianIndex) - // ─── Phase 1: deploy and run epochs 0–1 to legitimate ACCEPT ──────── + // ─── Phase 1: accept the first two input epochs ────────────── s.T().Logf("--- Phase 1: deploy %s and accept two legitimate claims ---", appAName) appAddrStr, consensusAddrStr, err := deployApplicationWithConsensus(s.ctx, @@ -171,8 +161,8 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { "fund application contract") // Inputs 0 and 1 go through the normal flow so we can observe both the - // legitimate ClaimAccepted on chain AND grab a valid-length - // outputsMerkleProof from epoch 1 to reuse for the attack. + // legitimate ClaimAccepted on chain AND grab a valid machine-state proof + // from the second input epoch to reuse for the attack. inputEpochs := make([]uint64, 0, 3) //nolint:mnd for i := 0; i < 2; i++ { //nolint:mnd payload := fmt.Sprintf("divergent-input-%d", i) @@ -187,6 +177,7 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { r.Equal(model.InputCompletionStatus_Accepted, input.Status) inputEpochs = append(inputEpochs, input.EpochIndex) + minePastEpochBoundary(s.ctx, s.T(), r, appAName, input.EpochIndex) claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) _, err = waitForEpochStatus(claimCtx, s.T(), appAName, input.EpochIndex, model.EpochStatus_ClaimAccepted) @@ -194,20 +185,20 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { r.NoError(err, "epoch %d → CLAIM_ACCEPTED", input.EpochIndex) s.T().Logf(" input %d processed; epoch %d ACCEPTED", i, input.EpochIndex) - // Mine to the next epoch boundary so input i+1 lands in a distinct epoch. - r.NoError(anvilMine(s.ctx, 15), "mine to next epoch") //nolint:mnd + // Advance beyond this input epoch before the next input. + r.NoError(anvilMine(s.ctx, 15), "advance beyond current input epoch") } - // Read epoch 1 to harvest a valid-length outputsMerkleProof — the - // IAuthority contract validates only the proof's length, not its - // semantic correctness, so we can splice it into the divergent payload. - epoch1, err := readEpoch(s.ctx, appAName, inputEpochs[1]) - r.NoError(err, "read epoch 1") - r.NotEmpty(epoch1.TxBufferProof, - "epoch 1 must have an outputs merkle proof to reuse for the attack") - epochLen := epoch1.LastBlock - epoch1.FirstBlock + 1 - s.T().Logf(" epoch length = %d blocks; epoch 1 proof = %d siblings", - epochLen, len(epoch1.TxBufferProof)) + // Read the second input epoch to harvest a complete proof for a valid accepted machine + // state. The attacker reuses this stale proof for a later epoch. The proof + // remains valid for its machine root, but it does not describe the target epoch. + proofSourceEpoch, err := readEpoch(s.ctx, appAName, inputEpochs[1]) + r.NoError(err, "read second input epoch") + r.True(proofSourceEpoch.HasCompleteStateProof(), + "second input epoch must have a complete machine-state proof to reuse for the attack") + epochLen := proofSourceEpoch.LastBlock - proofSourceEpoch.FirstBlock + 1 + s.T().Logf(" epoch length = %d blocks; proof source has %d siblings per leaf", + epochLen, len(proofSourceEpoch.TxBufferProof)) // ─── Phase 1.5: stop the node so the attacker cannot lose the race ── s.T().Log("--- Phase 1.5: stop node, then send input 2 and submit divergent claim ---") @@ -220,14 +211,14 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { s.T().Logf(" input 2 sent at block %d", block2) // Compute the epoch input 2 landed in from its block number relative - // to epoch 1. Guard against the (unexpected) case where mining timing - // drifts and input 2 falls inside epoch 1 — that would underflow the + // to the proof source epoch. Guard against the unexpected case where mining + // timing drifts and input 2 falls inside that epoch. That would underflow the // uint64 subtraction and produce a nonsense target epoch. - r.Greater(block2, epoch1.LastBlock, + r.Greater(block2, proofSourceEpoch.LastBlock, "input 2 must land past epoch %d's last block (%d); got block %d", - inputEpochs[1], epoch1.LastBlock, block2) - targetEpochIndex := inputEpochs[1] + ((block2 - epoch1.LastBlock - 1) / epochLen) + 1 - targetEpochFirstBlock := epoch1.FirstBlock + (targetEpochIndex-inputEpochs[1])*epochLen + inputEpochs[1], proofSourceEpoch.LastBlock, block2) + targetEpochIndex := inputEpochs[1] + ((block2 - proofSourceEpoch.LastBlock - 1) / epochLen) + 1 + targetEpochFirstBlock := proofSourceEpoch.FirstBlock + (targetEpochIndex-inputEpochs[1])*epochLen targetEpochLastBlock := targetEpochFirstBlock + epochLen - 1 r.GreaterOrEqual(block2, targetEpochFirstBlock, "input 2 block %d must be inside epoch %d's window [%d, %d]", @@ -260,12 +251,11 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { authorityBinding, err := iauthority.NewIAuthority(consensusAddr, client) r.NoError(err, "bind iauthority") - divergentOutputs := randomBytes32(s.T()) - proof := merkleProofToBytes32(epoch1.TxBufferProof) - s.T().Logf(" attacker submitting divergent claim: lpbn=%d outputs=0x%x proof_siblings=%d", - targetEpochLastBlock, divergentOutputs, len(proof)) + machineRoot, proof := authorityMachineValidityProof(s.T(), proofSourceEpoch) + s.T().Logf(" attacker submitting stale machine proof: lpbn=%d machine_root=%s proof_siblings=%d", + targetEpochLastBlock, machineRoot.Hex(), len(proof.TxBufferProof.Siblings)) submitTx, err := authorityBinding.SubmitClaim(attackerOpts, appAddr, - new(big.Int).SetUint64(targetEpochLastBlock), divergentOutputs, proof) + new(big.Int).SetUint64(targetEpochLastBlock), machineRoot, proof) r.NoError(err, "attacker SubmitClaim") submitReceipt, err := bind.WaitMined(s.ctx, client, submitTx) r.NoError(err, "wait for divergent submitClaim tx to mine") @@ -351,53 +341,26 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { s.T().Logf("=== Phase 2 complete: %s is DIVERGED in reader mode ===\n%s", appBName, statusB) } -// deployApplicationWithConsensus wraps deployApplication so the test also -// gets the on-chain Authority/IConsensus address — needed to bind the -// IAuthority contract for the attacker's direct submitClaim call. -func deployApplicationWithConsensus( - ctx context.Context, - appName, dappPath string, - extraArgs ...string, -) (appAddr string, consensusAddr string, err error) { - args := []string{"deploy", "application", appName, dappPath, "--json"} - args = append(args, extraArgs...) - out, err := runCLI(ctx, args...) - if err != nil { - return "", "", fmt.Errorf("deploy: %w", err) - } - var parsed struct { - IApplicationAddress string `json:"iapplication_address"` - IConsensusAddress string `json:"iconsensus_address"` - } - if err := json.Unmarshal([]byte(out), &parsed); err != nil { - return "", "", fmt.Errorf("parse deploy output: %w", err) - } - if parsed.IApplicationAddress == "" || parsed.IConsensusAddress == "" { - return "", "", fmt.Errorf("deploy output missing addresses: %s", out) - } - return parsed.IApplicationAddress, parsed.IConsensusAddress, nil -} - -// randomBytes32 returns 32 random bytes for use as a fake outputsMerkleRoot. -// The hash is deliberately arbitrary — the IAuthority contract performs no -// semantic check on it, so any 32-byte value is accepted, and the resulting -// machineMerkleRoot derived from it will not match the node's legitimate -// computation. -func randomBytes32(t testing.TB) [32]byte { +func authorityMachineValidityProof( + t testing.TB, + epoch *model.Epoch, +) (common.Hash, iauthority.MachineValidityProof) { t.Helper() - var b [32]byte - if _, err := rand.Read(b[:]); err != nil { - t.Fatalf("rand: %v", err) - } - return b -} -// merkleProofToBytes32 reshapes []common.Hash from the JSON-RPC API into the -// [][32]byte the abigen IAuthority.SubmitClaim binding expects. -func merkleProofToBytes32(in []common.Hash) [][32]byte { - out := make([][32]byte, len(in)) - for i, h := range in { - out[i] = h + stateProof, err := epoch.StateProof() + require.NoError(t, err, "epoch %d state proof", epoch.Index) + return stateProof.MachineHash, iauthority.MachineValidityProof{ + IflagsYProof: iauthority.LeafProof{ + DataBlock: stateProof.IflagsYDataBlock, + Siblings: stateProof.IflagsYProof, + }, + HtifTohostProof: iauthority.LeafProof{ + DataBlock: stateProof.HtifTohostDataBlock, + Siblings: stateProof.HtifTohostProof, + }, + TxBufferProof: iauthority.LeafProof{ + DataBlock: stateProof.TxBufferDataBlock, + Siblings: stateProof.TxBufferProof, + }, } - return out } diff --git a/test/integration/echo_authority_staging_test.go b/test/integration/echo_authority_staging_test.go index 34f230014..660f58b38 100644 --- a/test/integration/echo_authority_staging_test.go +++ b/test/integration/echo_authority_staging_test.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "testing" "time" @@ -16,9 +17,12 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) +const claimStagingPeriodFlag = "--claim-staging-period" + // EchoAuthorityStagingSuite exercises the non-fast-path claim flow by // deploying an Authority application with claimStagingPeriod >= 2. With a // non-zero staging period the chain forces COMPUTED → SUBMITTED → STAGED → @@ -60,24 +64,46 @@ func (s *EchoAuthorityStagingSuite) TearDownTest() { s.CheckLogs(s.T()) } -// TestEchoAuthorityStagingPath deploys with --claim-staging-period 5 and -// runs the full lifecycle. The 5-block period is large enough to make the -// STAGED state visible in node logs (the claim sits in STAGED until anvil -// advances 5 blocks past the staging tx) but small enough to keep the test -// short. The chain-side acceptClaim() will revert with -// ClaimStagingPeriodNotOverYet until the period elapses, which the claimer -// treats as transient until the next tick — the existing retry loop drives -// the transition once the period clears. +// TestEchoAuthorityStagingPath deploys with a non-zero claim staging period +// and runs the full lifecycle. The period is long enough for the test to +// observe CLAIM_STAGED before interval mining can make the claim acceptable. +// The test then advances the chain to the acceptance boundary. func (s *EchoAuthorityStagingSuite) TestEchoAuthorityStagingPath() { r := s.Require() dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") s.appName = uniqueAppName("echo-authority-staging") + const claimStagingPeriod uint64 = 30 runEchoLifecycleTest(s.ctx, s.T(), r, echoLifecycleConfig{ AppName: s.appName, DappPath: dappPath, Payload: "hello cartesi (staging)", - ExtraDeployArgs: []string{"--claim-staging-period", "5"}, + ExtraDeployArgs: []string{claimStagingPeriodFlag, strconv.FormatUint(claimStagingPeriod, 10)}, + PreClaimHook: func(ctx context.Context, t testing.TB, require *require.Assertions, appName string) { + input, err := readInput(ctx, appName, 0) + require.NoError(err, "read input 0 to find its epoch") + minePastEpochBoundary(ctx, t, require, appName, input.EpochIndex) + + stagedCtx, stagedCancel := context.WithTimeout(ctx, claimAcceptedTimeout) + defer stagedCancel() + epoch, err := waitForEpochStatus( + stagedCtx, t, appName, input.EpochIndex, model.EpochStatus_ClaimStaged) + require.NoError(err, "wait for authority claim to become CLAIM_STAGED") + require.NotNil(epoch.StagedAtBlock, "staged claim must record staged_at_block") + + client := newIntegrationEthClient(ctx, t) + defer client.Close() + currentBlock, err := client.BlockNumber(ctx) + require.NoError(err, "read block before claim acceptance") + acceptanceBlock := *epoch.StagedAtBlock + claimStagingPeriod + if currentBlock < acceptanceBlock { + blocksToMine := acceptanceBlock - currentBlock + require.LessOrEqual(blocksToMine, claimStagingPeriod, + "blocks to mine must fit within the configured staging period") + require.NoError(anvilMine(ctx, int(blocksToMine)), //nolint:gosec // Bounded by claimStagingPeriod. + "mine to the authority claim acceptance boundary") + } + }, }) // Pin the staging-path invariant: the epoch must have gone through @@ -128,7 +154,7 @@ func (s *EchoAuthorityStagingSuite) TestEchoAuthorityForecloseStagedClaim() { s.appName, dappPath, "--salt", uniqueSalt(), - "--claim-staging-period", claimStagingPeriod, + claimStagingPeriodFlag, claimStagingPeriod, "--withdrawal-config", withdrawalConfigJSON, ) r.NoError(err, "deploy foreclosable authority app") diff --git a/test/integration/echo_authority_transactions_test.go b/test/integration/echo_authority_transactions_test.go new file mode 100644 index 000000000..a3d905972 --- /dev/null +++ b/test/integration/echo_authority_transactions_test.go @@ -0,0 +1,118 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/model" +) + +// TestTransactionsWithoutDatabase keeps the node's database available, but gives +// each transaction command an invalid connection string. Only the node needs a +// database to process the input and publish its output proof. +func (s *EchoAuthoritySuite) TestTransactionsWithoutDatabase() { + r := s.Require() + s.appName = uniqueAppName("echo-no-cli-db") + dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") + address, err := deployApplication(s.ctx, s.appName, dappPath, "--salt", uniqueSalt()) + r.NoError(err) + r.NoError(anvilSetBalance(s.ctx, address, oneEtherWei)) + noDatabase := []string{"CARTESI_DATABASE_CONNECTION=invalid-database-connection"} + + // A wrong global InputBox must not override the Application contract. + sendEnv := append([]string{"CARTESI_CONTRACTS_INPUT_BOX_ADDRESS=0x0000000000000000000000000000000000000001"}, noDatabase...) + // Echo returns a voucher to the signer. Empty call data permits its EOA + // destination; nonempty call data would require a destination contract. + text, err := runCLIWithEnv(s.ctx, sendEnv, "send", address, "", "--yes", "--json") + r.NoError(err, "send by address without database access") + var sent cli.SendResult + r.NoError(json.Unmarshal([]byte(text), &sent)) + r.Equal("mined", sent.Status) + inputIndex, err := hexutil.DecodeUint64(sent.InputIndex) + r.NoError(err) + r.Zero(inputIndex) + r.NotEmpty(sent.TransactionHash) + + processCtx, cancelProcess := context.WithTimeout(s.ctx, inputProcessingTimeout) + defer cancelProcess() + input, err := waitForInputProcessed(processCtx, s.T(), s.appName, inputIndex) + r.NoError(err) + r.Equal(model.InputCompletionStatus_Accepted, input.Status) + minePastEpochBoundary(s.ctx, s.T(), r, s.appName, input.EpochIndex) + claimCtx, cancelClaim := context.WithTimeout(s.ctx, claimAcceptedTimeout) + defer cancelClaim() + _, err = waitForEpochStatus(claimCtx, s.T(), s.appName, input.EpochIndex, model.EpochStatus_ClaimAccepted) + r.NoError(err) + + // Select the same two fields as the documented jq export. The CLI reads + // them from the file during execution, with no database or node API lookup. + outputs, err := readOutputs(s.ctx, s.appName) + r.NoError(err) + var voucherIndex *uint64 + const voucherOutputType = "Voucher" + for _, output := range outputs.Data { + if output.DecodedData != nil && output.DecodedData.Type == voucherOutputType { + index := output.Index + voucherIndex = &index + break + } + } + r.NotNil(voucherIndex, "echo output must include a voucher") + indexText := strconv.FormatUint(*voucherIndex, 10) + text, err = runCLIWithEnv(s.ctx, noDatabase, "read", "outputs", address, indexText, "--jsonrpc") + r.NoError(err, "export proof through JSON-RPC without database access") + var exported struct { + Data map[string]json.RawMessage `json:"data"` + } + r.NoError(json.Unmarshal([]byte(text), &exported)) + r.Contains(exported.Data, "raw_data") + r.Contains(exported.Data, "output_hashes_siblings") + proof, err := json.Marshal(map[string]json.RawMessage{ + "raw_data": exported.Data["raw_data"], + "output_hashes_siblings": exported.Data["output_hashes_siblings"], + }) + r.NoError(err) + proofFile := filepath.Join(s.T().TempDir(), "output-proof.json") + r.NoError(os.WriteFile(proofFile, proof, 0o600)) + executeEnv := append([]string{"CARTESI_JSONRPC_API_URL=invalid-jsonrpc-url"}, noDatabase...) + + // A manual gas limit permits a call to an address with no code. Receipt + // status 1 must not be reported as output execution without its event. + client, err := ethclient.DialContext(s.ctx, envOrDefault("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", "http://localhost:8545")) + r.NoError(err) + defer client.Close() + const unusedAccountIndex = 9 + noCodeAddress := mnemonicAddress(s.T(), unusedAccountIndex) + code, err := client.CodeAt(s.ctx, noCodeAddress, nil) + r.NoError(err) + r.Empty(code, "the negative test requires an address with no contract code") + _, err = runCLIWithEnv(s.ctx, executeEnv, "execute", noCodeAddress.Hex(), indexText, + "--proof-file", proofFile, "--yes", "--json", "--gas-limit=500000") + r.ErrorContains(err, "no matching OutputExecuted event", "a mined no-op must not report output execution") + + text, err = runCLIWithEnv(s.ctx, executeEnv, "execute", address, indexText, "--proof-file", proofFile, "--yes", "--json") + r.NoError(err, "execute with supplied data without database or node API access") + var executed cli.TransactionResult + r.NoError(json.Unmarshal([]byte(text), &executed)) + r.Equal("mined", executed.Status) + r.NotEmpty(executed.TransactionHash) + execCtx, cancelExecution := context.WithTimeout(s.ctx, inputProcessingTimeout) + defer cancelExecution() + r.NoError(waitForExecutionRecorded(execCtx, s.T(), s.appName, *voucherIndex)) + + _, err = runCLIWithEnv(s.ctx, executeEnv, + "execute", address, indexText, "--proof-file", proofFile, "--yes", "--json", "--gas-limit=0") + r.ErrorContains(err, "OutputNotReexecutable", "the supplied proof must not permit replay") +} diff --git a/test/integration/echo_prt_sentries_test.go b/test/integration/echo_prt_sentries_test.go new file mode 100644 index 000000000..00e63cc00 --- /dev/null +++ b/test/integration/echo_prt_sentries_test.go @@ -0,0 +1,215 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strconv" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" +) + +// TestEchoPrtSentrySettlement deploys sentry slots through the CLI. External +// signers claim the locally computed state; the Go node stages and accepts it. +func (s *EchoPrtSuite) TestEchoPrtSentrySettlement() { + r := s.Require() + s.appName = uniqueAppName("echo-prt-sentries") + // The tournament helper mines many blocks while the node remains active. + s.SetExpectedLogs(s.T(), prtBlockOutOfRangeAllowlist) + const ( + claimStagingPeriod uint64 = 1000 + managerIndex uint32 = 9 + firstSentryIndex uint32 = 7 + secondSentryIndex uint32 = 8 + rotatedIndex uint32 = 4 + ) + // These accounts are distinct from the devnet claimer (0) and PRT signer (6). + manager := transactorForMnemonicIndex(s.ctx, s.T(), s.ethClient, managerIndex) + first := transactorForMnemonicIndex(s.ctx, s.T(), s.ethClient, firstSentryIndex) + second := transactorForMnemonicIndex(s.ctx, s.T(), s.ethClient, secondSentryIndex) + rotated := transactorForMnemonicIndex(s.ctx, s.T(), s.ethClient, rotatedIndex) + + runEchoLifecycleTest(s.ctx, s.T(), r, echoLifecycleConfig{ + AppName: s.appName, + DappPath: envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp"), + Payload: "prt-sentries", + ExtraDeployArgs: []string{ + prtFlag, claimStagingPeriodFlag, strconv.FormatUint(claimStagingPeriod, 10), + "--sentry-manager", manager.From.Hex(), "--sentries", first.From.Hex() + "," + second.From.Hex(), + }, + PreClaimHook: func(ctx context.Context, _ testing.TB, r *require.Assertions, appName string) { + dsn, err := config.GetDatabaseConnection() + r.NoError(err, "get database connection") + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + r.NoError(err, "open repository") + defer repo.Close() + app, err := repo.GetApplication(ctx, appName) + r.NoError(err, "read PRT application") + r.NotNil(app) + consensus, err := idaveconsensus.NewIDaveConsensus(app.IConsensusAddress, s.ethClient) + r.NoError(err, "bind Dave consensus") + period, err := consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: ctx}) + r.NoError(err, "read configured staging period") + r.Zero(period.Cmp(new(big.Int).SetUint64(claimStagingPeriod))) + originalSentries := []common.Address{first.From, second.From} + s.checkSentryInspection(app.IApplicationAddress, manager.From, originalSentries, "latest") + + // Epoch 0 is sealed empty at deployment. Epoch 1 contains the input. + s.finalizeEpochWithSentries(consensus, 0, claimStagingPeriod, first, second) + beforeRotation, err := s.ethClient.BlockNumber(ctx) + r.NoError(err, "read block before sentry rotation") + tx, err := consensus.RotateSentry(manager, first.From, rotated.From) + r.NoError(err, "rotate first sentry with the configured manager") + receipt := waitReceipt(ctx, s.T(), s.ethClient, tx) + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + oldID, err := consensus.GetSentryId(&bind.CallOpts{Context: ctx}, first.From) + r.NoError(err, "read retired sentry ID") + r.Zero(oldID.Sign()) + newID, err := consensus.GetSentryId(&bind.CallOpts{Context: ctx}, rotated.From) + r.NoError(err, "read replacement sentry ID") + r.Zero(newID.Cmp(big.NewInt(1)), "rotation must preserve the first slot") + s.checkSentryInspection(app.IApplicationAddress, manager.From, + []common.Address{rotated.From, second.From}, "latest") + s.checkSentryInspection(app.IApplicationAddress, manager.From, + originalSentries, strconv.FormatUint(beforeRotation, 10)) + s.finalizeEpochWithSentries(consensus, 1, claimStagingPeriod, rotated, second) + }, + }) +} + +func (s *EchoPrtSuite) finalizeEpochWithSentries( + consensus *idaveconsensus.IDaveConsensus, + epochIndex, claimStagingPeriod uint64, + first, second *bind.TransactOpts, +) { + s.T().Helper() + r := s.Require() + tournament := waitForTournamentAndCommitment(s.ctx, s.T(), r, s.appName, epochIndex) + _, err := mineForTournamentTimeout(s.ctx, s.ethClient, tournament.Address) + r.NoError(err, "mine past epoch %d tournament timeout", epochIndex) + waitForTournamentWinner(s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex) + + stagedCtx, cancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + staged, err := waitForEpochStatus(stagedCtx, s.T(), s.appName, epochIndex, model.EpochStatus_ClaimStaged) + cancel() + r.NoError(err, "wait for epoch %d staging", epochIndex) + r.NotNil(staged.StagedAtBlock) + r.NotNil(staged.MachineHash, "sentries need the state computed by the validator") + acceptanceBlock := *staged.StagedAtBlock + claimStagingPeriod + commitments, err := readCommitments(s.ctx, s.appName) + r.NoError(err, "read the node commitment") + commitment := findCommitmentForEpoch(commitments.Data, epochIndex) + r.NotNil(commitment) + r.NotEqual(commitment.SubmitterAddress, first.From, "the first sentry must not use the PRT signer") + r.NotEqual(commitment.SubmitterAddress, second.From, "the second sentry must not use the PRT signer") + + // Use local computation, not the staged hash read from the consensus contract. + tx, err := consensus.SubmitSentryClaim(first, new(big.Int).SetUint64(epochIndex), *staged.MachineHash) + r.NoError(err, "submit the first sentry claim for epoch %d", epochIndex) + firstReceipt := waitReceipt(s.ctx, s.T(), s.ethClient, tx) + r.Equal(types.ReceiptStatusSuccessful, firstReceipt.Status) + callOpts := &bind.CallOpts{Context: s.ctx, BlockNumber: firstReceipt.BlockNumber} + canAccept, err := consensus.CanAcceptStagedTournamentResult(callOpts) + r.NoError(err, "read acceptance conditions after the first sentry claim") + r.Zero(canAccept.EpochNumber.Cmp(new(big.Int).SetUint64(epochIndex))) + r.True(canAccept.IsTournamentResultStaged) + r.False(canAccept.DoAllSentriesAgreeWithStagedTournamentResult, "one of two sentries is not sufficient") + r.False(canAccept.IsClaimStagingPeriodOver) + r.Less(firstReceipt.BlockNumber.Uint64(), acceptanceBlock) + claimCount, err := consensus.GetSentryClaimCount(callOpts, new(big.Int).SetUint64(epochIndex), *staged.MachineHash) + r.NoError(err, "read the first sentry claim count") + r.Zero(claimCount.Cmp(big.NewInt(1))) + + tx, err = consensus.SubmitSentryClaim(second, new(big.Int).SetUint64(epochIndex), *staged.MachineHash) + r.NoError(err, "submit the second sentry claim for epoch %d", epochIndex) + secondReceipt := waitReceipt(s.ctx, s.T(), s.ethClient, tx) + r.Equal(types.ReceiptStatusSuccessful, secondReceipt.Status) + callOpts.BlockNumber = secondReceipt.BlockNumber + canAccept, err = consensus.CanAcceptStagedTournamentResult(callOpts) + r.NoError(err, "read acceptance conditions after both sentry claims") + r.Zero(canAccept.EpochNumber.Cmp(new(big.Int).SetUint64(epochIndex))) + r.True(canAccept.IsTournamentResultStaged) + r.True(canAccept.DoAllSentriesAgreeWithStagedTournamentResult) + r.False(canAccept.IsClaimStagingPeriodOver) + + // The test sends no acceptance transaction. The live Go node must accept. + accepted := waitForPrtEpochAcceptedAndBondRecovered( + s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex, tournament.Address) + r.Equal(staged.StagedAtBlock, accepted.StagedAtBlock) + receipt, err := s.ethClient.TransactionReceipt(s.ctx, *accepted.ClaimTransactionHash) + r.NoError(err, "read the node acceptance receipt") + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + r.Less(receipt.BlockNumber.Uint64(), acceptanceBlock, "all sentries must permit acceptance before the deadline") + tx, pending, err := s.ethClient.TransactionByHash(s.ctx, receipt.TxHash) + r.NoError(err, "read the node acceptance transaction") + r.False(pending) + sender, err := types.Sender(types.LatestSignerForChainID(tx.ChainId()), tx) + r.NoError(err, "recover acceptance sender") + r.Equal(commitment.SubmitterAddress, sender, "the node PRT signer must submit acceptance") +} + +type inspectedSentry struct { + ID uint64 `json:"id"` + Address string `json:"address"` +} + +type sentryInspection struct { + Manager string `json:"sentry_manager"` + Count uint64 `json:"num_sentries"` + Sentries []inspectedSentry `json:"sentries"` +} + +func (s *EchoPrtSuite) checkSentryInspection(app, manager common.Address, sentries []common.Address, block string) { + s.T().Helper() + r := s.Require() + expected := make([]inspectedSentry, len(sentries)) + for i, address := range sentries { + expected[i] = inspectedSentry{ID: uint64(i) + 1, Address: address.Hex()} + } + for _, command := range []string{"consensus", "summary"} { + args := []string{"contract", command, app.Hex(), "--block", block} + text, err := runCLI(s.ctx, append(args, "--json")...) + r.NoError(err, "inspect sentries with contract %s at %s", command, block) + data := []byte(text) + if command == "summary" { + var summary struct { + Consensus json.RawMessage `json:"consensus"` + ConsensusError string `json:"consensus_error"` + } + r.NoError(json.Unmarshal(data, &summary)) + r.Empty(summary.ConsensusError) + r.NotEmpty(summary.Consensus) + data = summary.Consensus + } + var got sentryInspection + r.NoError(json.Unmarshal(data, &got)) + r.Equal(manager.Hex(), got.Manager) + r.Equal(uint64(len(sentries)), got.Count) + r.Equal(expected, got.Sentries, "the roster must retain slot IDs and address order") + + text, err = runCLI(s.ctx, args...) + r.NoError(err, "inspect sentries in contract %s text output", command) + text = strings.Join(strings.Fields(text), " ") + r.Contains(text, "Sentry Manager "+manager.Hex()) + r.Contains(text, fmt.Sprintf("Sentries %d", len(sentries))) + for _, sentry := range expected { + r.Contains(text, fmt.Sprintf("Sentry #%d %s", sentry.ID, sentry.Address)) + } + } +} diff --git a/test/integration/echo_prt_staging_test.go b/test/integration/echo_prt_staging_test.go new file mode 100644 index 000000000..28537202a --- /dev/null +++ b/test/integration/echo_prt_staging_test.go @@ -0,0 +1,123 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "math/big" + "strconv" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// TestEchoPrtStagingPath keeps the node running through the complete staging +// interval. The test mines blocks but never submits an acceptance transaction. +func (s *EchoPrtSuite) TestEchoPrtStagingPath() { + r := s.Require() + s.appName = uniqueAppName("echo-prt-staging") + const claimStagingPeriod uint64 = 300 + + runEchoLifecycleTest(s.ctx, s.T(), r, echoLifecycleConfig{ + AppName: s.appName, + DappPath: envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp"), + Payload: "prt-staging", + ExtraDeployArgs: []string{ + prtFlag, claimStagingPeriodFlag, strconv.FormatUint(claimStagingPeriod, 10), + }, + PreClaimHook: func(ctx context.Context, _ testing.TB, r *require.Assertions, appName string) { + dsn, err := config.GetDatabaseConnection() + r.NoError(err, "get database connection") + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + r.NoError(err, "open repository") + defer repo.Close() + app, err := repo.GetApplication(ctx, appName) + r.NoError(err, "read PRT application") + consensus, err := idaveconsensus.NewIDaveConsensus(app.IConsensusAddress, s.ethClient) + r.NoError(err, "bind Dave consensus") + period, err := consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: ctx}) + r.NoError(err, "read configured staging period") + r.Zero(period.Cmp(new(big.Int).SetUint64(claimStagingPeriod)), "the configured staging period must match") + + // Epoch 0 is sealed empty at deployment. Epoch 1 contains the input. + for _, epochIndex := range []uint64{0, 1} { + s.finalizeEpochAfterStagingPeriod(consensus, epochIndex, claimStagingPeriod) + } + }, + }) +} + +func (s *EchoPrtSuite) finalizeEpochAfterStagingPeriod( + consensus *idaveconsensus.IDaveConsensus, + epochIndex, claimStagingPeriod uint64, +) { + s.T().Helper() + r := s.Require() + tournament := waitForTournamentAndCommitment(s.ctx, s.T(), r, s.appName, epochIndex) + _, err := mineForTournamentTimeout(s.ctx, s.ethClient, tournament.Address) + r.NoError(err, "mine past epoch %d tournament timeout", epochIndex) + waitForTournamentWinner(s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex) + + stagedCtx, stagedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + staged, err := waitForEpochStatus(stagedCtx, s.T(), s.appName, epochIndex, model.EpochStatus_ClaimStaged) + stagedCancel() + r.NoError(err, "wait for epoch %d staging", epochIndex) + r.NotNil(staged.StagedAtBlock, "the node must persist the staging block") + + currentBlock, err := s.ethClient.BlockNumber(s.ctx) + r.NoError(err, "read block before claim acceptance") + acceptanceBlock := *staged.StagedAtBlock + claimStagingPeriod + r.Less(currentBlock, acceptanceBlock, "the test must observe the epoch before acceptance is permitted") + callOpts := &bind.CallOpts{Context: s.ctx, BlockNumber: new(big.Int).SetUint64(currentBlock)} + sealed, err := consensus.GetCurrentSealedEpoch(callOpts) + r.NoError(err, "read staged epoch on chain") + r.Zero(sealed.EpochNumber.Cmp(new(big.Int).SetUint64(epochIndex)), "the epoch must not be accepted early") + r.True(sealed.IsTournamentResultStaged) + r.Zero(sealed.StagingBlockNumber.Cmp(new(big.Int).SetUint64(*staged.StagedAtBlock)), + "the persisted staging block must match the contract") + canAccept, err := consensus.CanAcceptStagedTournamentResult(callOpts) + r.NoError(err, "read acceptance conditions before the boundary") + r.False(canAccept.IsClaimStagingPeriodOver) + r.False(canAccept.DoAllSentriesAgreeWithStagedTournamentResult, "this test must not use the sentry fast path") + + root, err := itournament.NewITournament(tournament.Address, s.ethClient) + r.NoError(err, "bind root tournament") + recovery, err := root.BondRecovery(callOpts) + r.NoError(err, "read root bond owner before acceptance") + r.Equal(uint8(bondDispositionRecoverable), recovery.Disposition) + commitments, err := readCommitments(s.ctx, s.appName) + r.NoError(err, "read the node commitment") + commitment := findCommitmentForEpoch(commitments.Data, epochIndex) + r.NotNil(commitment) + r.Equal(commitment.SubmitterAddress, recovery.Claimer, "the node must own the root bond") + + // Only advance time. The live node must submit acceptance and recover its bond. + blocksToMine := acceptanceBlock - currentBlock + r.LessOrEqual(blocksToMine, claimStagingPeriod) + r.NoError(anvilMine(s.ctx, int(blocksToMine)), //nolint:gosec // Bounded by claimStagingPeriod. + "mine to the PRT acceptance boundary") + accepted := waitForPrtEpochAcceptedAndBondRecovered( + s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex, tournament.Address) + r.Equal(staged.StagedAtBlock, accepted.StagedAtBlock, "acceptance must preserve the staging block") + + receipt, err := s.ethClient.TransactionReceipt(s.ctx, *accepted.ClaimTransactionHash) + r.NoError(err, "read the node acceptance receipt") + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + r.GreaterOrEqual(receipt.BlockNumber.Uint64(), acceptanceBlock, "acceptance must not occur before the period ends") + tx, pending, err := s.ethClient.TransactionByHash(s.ctx, receipt.TxHash) + r.NoError(err, "read the node acceptance transaction") + r.False(pending) + sender, err := types.Sender(types.LatestSignerForChainID(tx.ChainId()), tx) + r.NoError(err, "recover acceptance sender") + r.Equal(commitment.SubmitterAddress, sender, "the node PRT signer must submit acceptance") +} diff --git a/test/integration/echo_prt_test.go b/test/integration/echo_prt_test.go index 92f89fc42..8058e17fb 100644 --- a/test/integration/echo_prt_test.go +++ b/test/integration/echo_prt_test.go @@ -70,10 +70,10 @@ func (s *EchoPrtSuite) TestEchoPrtLifecycle() { AppName: s.appName, DappPath: dappPath, Payload: "prt-hello", - ExtraDeployArgs: []string{"--prt"}, + ExtraDeployArgs: []string{prtFlag}, PreClaimHook: func(ctx context.Context, t testing.TB, require *require.Assertions, appName string) { - settleTournament(ctx, t, require, ethClient, appName, 0) - settleTournament(ctx, t, require, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 1) }, }) diff --git a/test/integration/echo_quorum_test.go b/test/integration/echo_quorum_test.go index e471294d6..885a70253 100644 --- a/test/integration/echo_quorum_test.go +++ b/test/integration/echo_quorum_test.go @@ -96,10 +96,11 @@ func (s *EchoQuorumSuite) TearDownTest() { } func (s *EchoQuorumSuite) TestEchoQuorumLifecycle() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) r := s.Require() app := s.deployQuorumEchoApp("echo-quorum-lifecycle") - epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum lifecycle)") + epoch := s.prepareQuorumEpochThroughRelay(app, "hello cartesi (quorum lifecycle)") outputsResp, err := readOutputs(s.ctx, app.appName) r.NoError(err, "read quorum lifecycle outputs") @@ -139,8 +140,8 @@ func (s *EchoQuorumSuite) TestEchoQuorumLifecycle() { submittedCancel() r.NoError(err, "wait for node to submit quorum claim") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, epoch) s.waitForQuorumAccepted(app.appName, epoch.Index) verifyClaimAndExecute(s.ctx, s.T(), r, verifyAndExecuteConfig{ @@ -154,6 +155,7 @@ func (s *EchoQuorumSuite) TestEchoQuorumLifecycle() { } func (s *EchoQuorumSuite) TestNodeVoteFirstThenOtherValidatorsStageAndAccept() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) app := s.deployQuorumEchoApp("echo-quorum-node-first") epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum node first)") @@ -162,8 +164,8 @@ func (s *EchoQuorumSuite) TestNodeVoteFirstThenOtherValidatorsStageAndAccept() { submittedCancel() s.Require().NoError(err, "wait for node to submit quorum claim") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, epoch) s.waitForQuorumAccepted(app.appName, epoch.Index) } @@ -172,11 +174,7 @@ func (s *EchoQuorumSuite) TestExternalValidatorThenNodeVoteStagesAndAccepts() { if !isNodeSelfManaged() { s.T().Skip("skipping: validator-order test requires test-managed node to slow claimer polling") } - s.SetExpectedLogs(s.T(), ExpectedLog{ - Pattern: regexp.MustCompile(`service=.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from restarting the shared node with different claimer polling", - }) + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) stopSharedNode(s.T()) startSharedNodeWithEnv(s.T(), "CARTESI_CLAIMER_POLLING_INTERVAL=3600") @@ -193,7 +191,7 @@ func (s *EchoQuorumSuite) TestExternalValidatorThenNodeVoteStagesAndAccepts() { s.Require().Equal(model.EpochStatus_ClaimComputed, epoch.Status, "node should compute the claim before the slowed claimer polling interval submits it") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) stopSharedNode(s.T()) startSharedNode(s.T()) @@ -206,11 +204,7 @@ func (s *EchoQuorumSuite) TestExternalMajorityStagesBeforeNodeVoteThenNodeAccept if !isNodeSelfManaged() { s.T().Skip("skipping: external-majority test requires test-managed node to slow claimer polling") } - s.SetExpectedLogs(s.T(), ExpectedLog{ - Pattern: regexp.MustCompile(`service=.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from restarting the shared node with different claimer polling", - }) + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) stopSharedNode(s.T()) startSharedNodeWithEnv(s.T(), "CARTESI_CLAIMER_POLLING_INTERVAL=3600") @@ -227,8 +221,8 @@ func (s *EchoQuorumSuite) TestExternalMajorityStagesBeforeNodeVoteThenNodeAccept s.Require().Equal(model.EpochStatus_ClaimComputed, epoch.Status, "node should compute the claim before the slowed claimer polling interval submits it") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, epoch) stopSharedNode(s.T()) startSharedNode(s.T()) @@ -238,23 +232,23 @@ func (s *EchoQuorumSuite) TestExternalMajorityStagesBeforeNodeVoteThenNodeAccept } func (s *EchoQuorumSuite) TestDivergentMinorityVoteDoesNotBlockAcceptance() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) app := s.deployQuorumEchoApp("echo-quorum-divergent-minority") - epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum divergent minority)") - - submittedCtx, submittedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) - epoch, err := waitForEpochStatus(submittedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimSubmitted) - submittedCancel() - s.Require().NoError(err, "wait for node to submit quorum claim") + staleEpoch, epoch := s.prepareQuorumDivergence( + app, + "hello cartesi (quorum minority source)", + "hello cartesi (quorum divergent minority)", + ) - divergentOutputs := randomOutputsMerkleRoot(s.T(), *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, divergentOutputs) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, staleEpoch) s.waitForQuorumAccepted(app.appName, epoch.Index) } func (s *EchoQuorumSuite) TestDivergentMajorityMarksApplicationDiverged() { s.SetExpectedLogs(s.T(), + anvilBlockOutOfRangeAllowlist, ExpectedLog{ Pattern: regexp.MustCompile(`claim divergence detected.*quorum_divergence_at_staging`), Level: LevelError, @@ -268,19 +262,17 @@ func (s *EchoQuorumSuite) TestDivergentMajorityMarksApplicationDiverged() { ) app := s.deployQuorumEchoApp("echo-quorum-outvoted") - epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum outvoted)") - - submittedCtx, submittedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) - epoch, err := waitForEpochStatus(submittedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimSubmitted) - submittedCancel() - s.Require().NoError(err, "wait for node to submit quorum claim") + staleEpoch, epoch := s.prepareQuorumDivergence( + app, + "hello cartesi (quorum majority source)", + "hello cartesi (quorum outvoted)", + ) - divergentOutputs := randomOutputsMerkleRoot(s.T(), *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, divergentOutputs) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, divergentOutputs) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, staleEpoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, staleEpoch) rejectedCtx, rejectedCancel := context.WithTimeout(s.ctx, 5*time.Minute) - epoch, err = waitForEpochStatus(rejectedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimRejected) + _, err := waitForEpochStatus(rejectedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimRejected) rejectedCancel() s.Require().NoError(err, "wait for outvoted quorum epoch to become CLAIM_REJECTED") @@ -298,6 +290,7 @@ func (s *EchoQuorumSuite) TestDivergentMajorityMarksApplicationDiverged() { } func (s *EchoQuorumSuite) TestForecloseQuorumClaimBeforeAcceptanceMarksClaimForeclosed() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) r := s.Require() builderEnv := os.Getenv("CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS") @@ -359,13 +352,14 @@ func (s *EchoQuorumSuite) TestForecloseQuorumClaimBeforeAcceptanceMarksClaimFore } func (s *EchoQuorumSuite) TestForecloseQuorumOutputExecutionAfterForeclosureIsRecorded() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) r := s.Require() const guardianIndex = 1 withdrawalConfigJSON, _ := withdrawalConfigForGuardian(s.T(), guardianIndex) app := s.deployQuorumEchoApp("foreclose-quorum-output", "--withdrawal-config", withdrawalConfigJSON) - epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (foreclose quorum output)") + epoch := s.prepareQuorumEpochThroughRelay(app, "hello cartesi (foreclose quorum output)") outputsResp, err := readOutputs(s.ctx, app.appName) r.NoError(err, "read outputs") @@ -377,8 +371,8 @@ func (s *EchoQuorumSuite) TestForecloseQuorumOutputExecutionAfterForeclosureIsRe submittedCancel() r.NoError(err, "wait for node to submit quorum claim") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, epoch) s.waitForQuorumAccepted(app.appName, epoch.Index) r.NoError(guardianForeclose(s.ctx, app.appName, guardianIndex), "guardian foreclose") @@ -433,7 +427,7 @@ func (s *EchoQuorumSuite) deployQuorumEchoApp(prefix string, extraApplicationArg "deploy", "quorum", "--json", "--salt", uniqueSalt(), - "--claim-staging-period", strconv.FormatUint(quorumClaimStagingPeriod, 10), + claimStagingPeriodFlag, strconv.FormatUint(quorumClaimStagingPeriod, 10), } for _, validator := range validators { quorumArgs = append(quorumArgs, "--validator", validator.Hex()) @@ -480,11 +474,32 @@ func (s *EchoQuorumSuite) deployQuorumEchoApp(prefix string, extraApplicationArg } func (s *EchoQuorumSuite) prepareQuorumEpoch(appName string, payload string) *model.Epoch { + return s.prepareQuorumEpochAtInput(appName, payload, 0) +} + +func (s *EchoQuorumSuite) prepareQuorumEpochAtInput(appName string, payload string, expectedInputIndex uint64) *model.Epoch { r := s.Require() inputIndex, blockNum, err := sendInput(s.ctx, appName, payload) r.NoError(err, "send input") - r.Equal(uint64(0), inputIndex) + return s.completeQuorumEpochPreparation(appName, inputIndex, blockNum, expectedInputIndex) +} + +func (s *EchoQuorumSuite) prepareQuorumEpochThroughRelay(app quorumAppDeployment, payload string) *model.Epoch { + inputIndex, blockNum, relayAddress := sendInputThroughRelay(s.ctx, s.T(), app.appAddress, payload) + s.T().Logf(" quorum input sent through relay %s", relayAddress) + return s.completeQuorumEpochPreparation(app.appName, inputIndex, blockNum, 0) +} + +func (s *EchoQuorumSuite) completeQuorumEpochPreparation( + appName string, + inputIndex uint64, + blockNum uint64, + expectedInputIndex uint64, +) *model.Epoch { + r := s.Require() + + r.Equal(expectedInputIndex, inputIndex) s.T().Logf(" quorum input accepted on-chain: index=%d block=%d", inputIndex, blockNum) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -498,6 +513,46 @@ func (s *EchoQuorumSuite) prepareQuorumEpoch(appName string, payload string) *mo return s.waitForEpochWithClaim(appName, input.EpochIndex) } +func (s *EchoQuorumSuite) prepareQuorumDivergence( + app quorumAppDeployment, + sourcePayload string, + targetPayload string, +) (*model.Epoch, *model.Epoch) { + sourceEpoch := s.prepareQuorumEpoch(app.appName, sourcePayload) + + sourceCtx, sourceCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + sourceEpoch, err := waitForEpochStatus( + sourceCtx, + s.T(), + app.appName, + sourceEpoch.Index, + model.EpochStatus_ClaimSubmitted, + ) + sourceCancel() + s.Require().NoError(err, "wait for node to submit source quorum claim") + + s.submitQuorumClaim(app, sourceEpoch, quorumValidatorIndexA, sourceEpoch) + s.submitQuorumClaim(app, sourceEpoch, quorumValidatorIndexB, sourceEpoch) + s.waitForQuorumAccepted(app.appName, sourceEpoch.Index) + + targetEpoch := s.prepareQuorumEpochAtInput(app.appName, targetPayload, 1) + targetCtx, targetCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + targetEpoch, err = waitForEpochStatus( + targetCtx, + s.T(), + app.appName, + targetEpoch.Index, + model.EpochStatus_ClaimSubmitted, + ) + targetCancel() + s.Require().NoError(err, "wait for node to submit target quorum claim") + s.Require().NotEqual(sourceEpoch.Index, targetEpoch.Index, "source and target claims must use different epochs") + s.Require().NotEqual(*sourceEpoch.MachineHash, *targetEpoch.MachineHash, + "source proof must diverge from the target machine root") + + return sourceEpoch, targetEpoch +} + func (s *EchoQuorumSuite) waitForEpochAvailable(appName string, epochIndex uint64) *model.Epoch { ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) defer cancel() @@ -540,7 +595,7 @@ func (s *EchoQuorumSuite) waitForEpochWithClaim(appName string, epochIndex uint6 } return false, fmt.Errorf("poll epoch %d claim: %w", epochIndex, err) } - if epoch.TxBufferDataBlock != nil && epoch.MachineHash != nil && isQuorumClaimReadyStatus(epoch.Status) { + if epoch.HasCompleteStateProof() && isQuorumClaimReadyStatus(epoch.Status) { result = epoch return true, nil } @@ -596,12 +651,12 @@ func (s *EchoQuorumSuite) minePastBlock(block uint64) { func (s *EchoQuorumSuite) submitQuorumClaim( app quorumAppDeployment, - epoch *model.Epoch, + claimedEpoch *model.Epoch, accountIndex uint32, - outputsMerkleRoot [32]byte, + proofEpoch *model.Epoch, ) common.Hash { r := s.Require() - r.NotNil(epoch.TxBufferDataBlock, "epoch %d missing outputs merkle root", epoch.Index) + machineRoot, proof := quorumMachineValidityProof(s.T(), proofEpoch) key, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, accountIndex) r.NoError(err, "derive validator key %d", accountIndex) @@ -613,20 +668,41 @@ func (s *EchoQuorumSuite) submitQuorumClaim( tx, err := app.quorum.SubmitClaim( opts, app.appAddress, - new(big.Int).SetUint64(epoch.LastBlock), - outputsMerkleRoot, - merkleProofToBytes32(epoch.TxBufferProof), + new(big.Int).SetUint64(claimedEpoch.LastBlock), + machineRoot, + proof, ) r.NoError(err, "validator %d submit quorum claim", accountIndex) receipt, err := bind.WaitMined(s.ctx, s.client, tx) r.NoError(err, "wait for validator %d quorum submit tx", accountIndex) r.Equal(uint64(1), receipt.Status, "validator %d quorum submit tx must succeed", accountIndex) - s.T().Logf(" validator mnemonic[%d] submitClaim mined in block %d tx=%s", - accountIndex, receipt.BlockNumber.Uint64(), tx.Hash().Hex()) + s.T().Logf(" validator mnemonic[%d] submitClaim mined in block %d tx=%s proof_epoch=%d", + accountIndex, receipt.BlockNumber.Uint64(), tx.Hash().Hex(), proofEpoch.Index) return tx.Hash() } +func quorumMachineValidityProof(t testing.TB, epoch *model.Epoch) (common.Hash, iquorum.MachineValidityProof) { + t.Helper() + + stateProof, err := epoch.StateProof() + require.NoError(t, err, "epoch %d state proof", epoch.Index) + return stateProof.MachineHash, iquorum.MachineValidityProof{ + IflagsYProof: iquorum.LeafProof{ + DataBlock: stateProof.IflagsYDataBlock, + Siblings: stateProof.IflagsYProof, + }, + HtifTohostProof: iquorum.LeafProof{ + DataBlock: stateProof.HtifTohostDataBlock, + Siblings: stateProof.HtifTohostProof, + }, + TxBufferProof: iquorum.LeafProof{ + DataBlock: stateProof.TxBufferDataBlock, + Siblings: stateProof.TxBufferProof, + }, + } +} + func quorumValidatorAddresses(t testing.TB) []common.Address { t.Helper() indexes := []uint32{quorumNodeValidatorIndex, quorumValidatorIndexA, quorumValidatorIndexB} @@ -638,13 +714,3 @@ func quorumValidatorAddresses(t testing.TB) []common.Address { } return addresses } - -func randomOutputsMerkleRoot(t testing.TB, legitimate common.Hash) [32]byte { - t.Helper() - for { - outputs := randomBytes32(t) - if common.Hash(outputs) != legitimate { - return outputs - } - } -} diff --git a/test/integration/foreclose_prt_test.go b/test/integration/foreclose_prt_test.go index a3e801dfe..1cfb627d2 100644 --- a/test/integration/foreclose_prt_test.go +++ b/test/integration/foreclose_prt_test.go @@ -16,6 +16,7 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" @@ -120,8 +121,8 @@ func (s *ForeclosePrtSuite) TestForeclosePrtLifecycle() { "--withdrawal-config", withdrawalConfigJSON, }, PreClaimHook: func(ctx context.Context, t testing.TB, r *require.Assertions, appName string) { - settleTournament(ctx, t, r, ethClient, appName, 0) - settleTournament(ctx, t, r, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, r, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, r, ethClient, appName, 1) }, }) s.T().Log("=== Pre-foreclosure PRT lifecycle complete ===") @@ -229,7 +230,7 @@ func (s *ForeclosePrtSuite) TestForeclosePrtBeforeTournamentSettlementStopsParti // the root tournament for the input-carrying epoch, then foreclose before // that tournament reaches a winner. if input.EpochIndex > 0 { - settleTournament(s.ctx, s.T(), r, s.ethClient, s.appName, 0) + finalizePrtEpoch(s.ctx, s.T(), r, s.ethClient, s.appName, 0) } tournament := waitForTournamentAndCommitment(s.ctx, s.T(), r, s.appName, input.EpochIndex) @@ -277,8 +278,8 @@ func (s *ForeclosePrtSuite) TestForeclosePrtOutputExecutionAfterForeclosureIsRec r.NoError(anvilSetBalance(s.ctx, appAddr, oneEtherWei), "fund application contract") - inputIndex, _, err := sendInput(s.ctx, s.appName, "foreclose PRT output execution") - r.NoError(err, "send input") + inputIndex, _, _ := sendInputThroughRelay( + s.ctx, s.T(), common.HexToAddress(appAddr), "foreclose PRT output execution") r.Equal(uint64(0), inputIndex) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -293,7 +294,7 @@ func (s *ForeclosePrtSuite) TestForeclosePrtOutputExecutionAfterForeclosureIsRec voucherIdx := firstVoucherOutputIndex(s.T(), outputsResp.Data) for epochIndex := uint64(0); epochIndex <= input.EpochIndex; epochIndex++ { - settleTournament(s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex) + finalizePrtEpoch(s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex) } claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) @@ -347,7 +348,7 @@ func (s *ForeclosePrtSuite) TestForeclosePrtReregisterReplay() { r.Equal(model.InputCompletionStatus_Accepted, inputA.Status) for epochIndex := uint64(0); epochIndex <= inputA.EpochIndex; epochIndex++ { - settleTournament(s.ctx, s.T(), r, s.ethClient, appAName, epochIndex) + finalizePrtEpoch(s.ctx, s.T(), r, s.ethClient, appAName, epochIndex) } claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) diff --git a/test/integration/foreclose_replay_test.go b/test/integration/foreclose_replay_test.go index 860f9aa26..d82802bb9 100644 --- a/test/integration/foreclose_replay_test.go +++ b/test/integration/foreclose_replay_test.go @@ -16,6 +16,7 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -152,6 +153,7 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplay() { // Wait for every epoch to reach CLAIM_ACCEPTED on chain. for _, ep := range distinctEpochs { + minePastEpochBoundary(s.ctx, s.T(), r, appAName, ep) claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) epoch, err := waitForEpochStatus(claimCtx, s.T(), appAName, ep, model.EpochStatus_ClaimAccepted) claimCancel() @@ -252,11 +254,6 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplayReaderMode() { r := s.Require() s.SetExpectedLogs(s.T(), - ExpectedLog{ - Pattern: regexp.MustCompile(`service=evm-reader.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from switching the shared node into reader mode", - }, ExpectedLog{ Pattern: regexp.MustCompile(`BlockOutOfRangeError`), Level: LevelError, @@ -287,6 +284,7 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplayReaderMode() { r.NoError(err, "wait for input") r.Equal(model.InputCompletionStatus_Accepted, input.Status) + minePastEpochBoundary(s.ctx, s.T(), r, appAName, input.EpochIndex) claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) _, err = waitForEpochStatus(claimCtx, s.T(), appAName, input.EpochIndex, model.EpochStatus_ClaimAccepted) claimCancel() @@ -365,8 +363,8 @@ func (s *ForecloseReplaySuite) TestOutputExecutionAfterForeclosureReplaysOnRereg r.NoError(anvilSetBalance(s.ctx, appAddr, oneEtherWei), "fund application contract") - inputIndex, _, err := sendInput(s.ctx, appAName, "foreclose output replay") - r.NoError(err, "send input") + inputIndex, _, _ := sendInputThroughRelay( + s.ctx, s.T(), common.HexToAddress(appAddr), "foreclose output replay") r.Equal(uint64(0), inputIndex) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -390,6 +388,7 @@ func (s *ForecloseReplaySuite) TestOutputExecutionAfterForeclosureReplaysOnRereg } r.True(voucherFound, "voucher output not found") + minePastEpochBoundary(s.ctx, s.T(), r, appAName, input.EpochIndex) claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) _, err = waitForEpochStatus(claimCtx, s.T(), appAName, input.EpochIndex, model.EpochStatus_ClaimAccepted) claimCancel() diff --git a/test/integration/foreclose_test.go b/test/integration/foreclose_test.go index eebcde93f..a187124e7 100644 --- a/test/integration/foreclose_test.go +++ b/test/integration/foreclose_test.go @@ -321,8 +321,8 @@ func (s *ForecloseSuite) TestOutputExecutionAfterForeclosureIsRecorded() { require.NoError(anvilSetBalance(s.ctx, appAddr, oneEtherWei), "fund application contract") - inputIndex, _, err := sendInput(s.ctx, s.appName, "execute after foreclosure") - require.NoError(err, "send input") + inputIndex, _, _ := sendInputThroughRelay( + s.ctx, s.T(), common.HexToAddress(appAddr), "execute after foreclosure") require.Equal(uint64(0), inputIndex) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -402,8 +402,7 @@ func (s *ForecloseSuite) TestSameBlockInputForecloseAndOutputOrdering() { "fund application contract") appAddr := common.HexToAddress(appAddrString) - inputIndex, _, err := sendInput(s.ctx, s.appName, "same-block setup") - require.NoError(err, "send setup input") + inputIndex, _, _ := sendInputThroughRelay(s.ctx, s.T(), appAddr, "same-block setup") require.Equal(uint64(0), inputIndex) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) diff --git a/test/integration/input_relay_helpers_test.go b/test/integration/input_relay_helpers_test.go new file mode 100644 index 000000000..b20fe8cf9 --- /dev/null +++ b/test/integration/input_relay_helpers_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + _ "embed" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// Generated from testdata/InputRelay.sol with solc 0.8.30, no optimizer: +// +// solc --abi --bin testdata/InputRelay.sol +// +// The static artifacts keep the integration tests independent of a local +// Solidity toolchain. Regenerate both files when InputRelay.sol changes. +// +//go:embed testdata/input_relay_abi.json +var inputRelayABIJSON string + +//go:embed testdata/input_relay_bytecode.hex +var inputRelayBytecode string + +// sendInputThroughRelay deploys a contract that forwards one input to the +// InputBox. The echo DApp then addresses its voucher to this contract. The +// relay's payable fallback accepts the non-empty voucher payload under the v3 +// application target-code rule. +func sendInputThroughRelay( + ctx context.Context, + t testing.TB, + appAddress common.Address, + payload string, +) (uint64, uint64, common.Address) { + t.Helper() + r := require.New(t) + r.NotEqual(common.Address{}, appAddress, "application address must be non-zero") + r.NotEmpty(payload, "relay input payload must be non-empty") + + client := newIntegrationEthClient(ctx, t) + defer client.Close() + inputBoxAddr := inputBoxAddress(t) + inputBox, err := iinputbox.NewIInputBox(inputBoxAddr, client) + r.NoError(err, "bind InputBox") + + parsed, err := abi.JSON(strings.NewReader(inputRelayABIJSON)) + r.NoError(err, "parse InputRelay ABI") + deployOpts := transactorForMnemonicIndex(ctx, t, client, integrationCLIAccountIndex) + deployOpts.GasLimit = 3_000_000 + relayAddress, deployTx, relay, err := bind.DeployContract( + deployOpts, + parsed, + common.FromHex(strings.TrimSpace(inputRelayBytecode)), + client, + inputBoxAddr, + ) + r.NoError(err, "deploy InputRelay") + + receiptCtx, receiptCancel := context.WithTimeout(ctx, 30*time.Second) + deployReceipt := waitReceipt(receiptCtx, t, client, deployTx) + receiptCancel() + r.Equal(types.ReceiptStatusSuccessful, deployReceipt.Status, + "InputRelay deployment reverted in tx %s", deployTx.Hash()) + + submitOpts := transactorForMnemonicIndex(ctx, t, client, integrationCLIAccountIndex) + submitOpts.GasLimit = 2_000_000 + submitTx, err := relay.Transact(submitOpts, "addInput", appAddress, []byte(payload)) + r.NoError(err, "submit input through InputRelay") + receiptCtx, receiptCancel = context.WithTimeout(ctx, 30*time.Second) + submitReceipt := waitReceipt(receiptCtx, t, client, submitTx) + receiptCancel() + r.Equal(types.ReceiptStatusSuccessful, submitReceipt.Status, + "InputRelay submission reverted in tx %s", submitTx.Hash()) + + var matchingInputs []*iinputbox.IInputBoxInputAdded + for _, rawLog := range submitReceipt.Logs { + if rawLog.Address != inputBoxAddr { + continue + } + event, err := inputBox.ParseInputAdded(*rawLog) + if err == nil && event.AppContract == appAddress { + matchingInputs = append(matchingInputs, event) + } + } + r.Len(matchingInputs, 1, "relay transaction must emit one InputAdded event for the application") + event := matchingInputs[0] + r.Equal(submitTx.Hash(), event.Raw.TxHash, "InputAdded event must belong to the relay transaction") + r.True(event.Index.IsUint64(), "input index must fit uint64") + + return event.Index.Uint64(), submitReceipt.BlockNumber.Uint64(), relayAddress +} diff --git a/test/integration/lifecycle_test.go b/test/integration/lifecycle_test.go index 696a0c6d5..fe2db133e 100644 --- a/test/integration/lifecycle_test.go +++ b/test/integration/lifecycle_test.go @@ -19,6 +19,8 @@ import ( "github.com/cartesi/rollups-node/internal/jsonrpc/api" "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/stretchr/testify/require" ) @@ -72,11 +74,13 @@ func runEchoLifecycleTest(ctx context.Context, t testing.TB, require *require.As defer timed(t, "full echo lifecycle")() deployArgs := append([]string{"--salt", uniqueSalt()}, cfg.ExtraDeployArgs...) + var appAddress common.Address func() { defer timed(t, "deploy echo-dapp")() appAddr, err := deployApplication(ctx, cfg.AppName, cfg.DappPath, deployArgs...) require.NoError(err, "deploy echo-dapp") + appAddress = common.HexToAddress(appAddr) t.Logf(" application deployed at %s", appAddr) err = anvilSetBalance(ctx, appAddr, oneEtherWei) @@ -87,10 +91,9 @@ func runEchoLifecycleTest(ctx context.Context, t testing.TB, require *require.As // --- L1 -> Machine: send input and wait for processing --- t.Logf("Sending input payload=%q to the echo-dapp on L1", cfg.Payload) - inputIndex, blockNum, err := sendInput(ctx, cfg.AppName, cfg.Payload) - require.NoError(err, "send input") + inputIndex, blockNum, relayAddress := sendInputThroughRelay(ctx, t, appAddress, cfg.Payload) require.Equal(uint64(0), inputIndex) - t.Logf(" input accepted on-chain: index=%d block=%d", inputIndex, blockNum) + t.Logf(" input accepted on-chain through relay %s: index=%d block=%d", relayAddress, inputIndex, blockNum) func() { defer timed(t, "wait for input processing")() @@ -123,6 +126,10 @@ func runEchoLifecycleTest(ctx context.Context, t testing.TB, require *require.As case "Voucher": voucherIdx = out.Index voucherFound = true + require.Equal(relayAddress, common.HexToAddress(out.DecodedData.Destination), + "voucher destination must be the input relay") + require.Equal(hexutil.Encode([]byte(cfg.Payload)), out.DecodedData.Payload, + "voucher payload must match the relayed input") case "DelegateCallVoucher": delegateVoucherFound = true case "Notice": @@ -202,11 +209,13 @@ func runRejectExceptionLifecycleTest( defer timed(t, fmt.Sprintf("full %s lifecycle", cfg.TestName))() deployArgs := append([]string{"--salt", uniqueSalt()}, cfg.ExtraDeployArgs...) + var appAddress common.Address func() { defer timed(t, fmt.Sprintf("deploy %s-loop-dapp", cfg.TestName))() appAddr, err := deployApplication(ctx, cfg.AppName, cfg.DappPath, deployArgs...) require.NoError(err, "deploy %s-loop-dapp", cfg.TestName) + appAddress = common.HexToAddress(appAddr) t.Logf(" application deployed at %s", appAddr) err = anvilSetBalance(ctx, appAddr, oneEtherWei) @@ -225,8 +234,14 @@ func runRejectExceptionLifecycleTest( const numInputs = 3 for i := range numInputs { payload := fmt.Sprintf("%s-payload-%d", cfg.TestName, i) - idx, blockNum, err := sendInput(ctx, cfg.AppName, payload) - require.NoError(err, "send input %d", i) + var idx, blockNum uint64 + if i == 0 && !terminal { + idx, blockNum, _ = sendInputThroughRelay(ctx, t, appAddress, payload) + } else { + var err error + idx, blockNum, err = sendInput(ctx, cfg.AppName, payload) + require.NoError(err, "send input %d", i) + } require.Equal(uint64(i), idx, "input index mismatch") t.Logf(" input %d sent at block %d (payload=%q)", i, blockNum, payload) } diff --git a/test/integration/logscanner_test.go b/test/integration/logscanner_test.go index 63932f9a2..a391f4fea 100644 --- a/test/integration/logscanner_test.go +++ b/test/integration/logscanner_test.go @@ -70,6 +70,15 @@ type ExpectedLog struct { Required bool // if true, absence of a matching line fails the test } +// Opt in only for tests that mine blocks in bulk. Anvil can reject historical +// reads while these tests advance the chain. Keep the state and receipt checks: +// allowing this log does not prove that the node recovered from the read error. +var anvilBlockOutOfRangeAllowlist = ExpectedLog{ + Pattern: regexp.MustCompile(`BlockOutOfRangeError: block height is [0-9]+ but requested was [0-9]+`), + Level: LevelError, + Reason: "transient Anvil historical-read error during test-controlled block mining", +} + // LogChecker is an embeddable helper for test suites that captures a time // window and an expectation list, then scans node logs per test in // TearDownTest. diff --git a/test/integration/logscanner_unit_test.go b/test/integration/logscanner_unit_test.go new file mode 100644 index 000000000..79366eb1b --- /dev/null +++ b/test/integration/logscanner_unit_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestIntegrationLogScanner(t *testing.T) { + const anvilError = "BlockOutOfRangeError: block height is 3051 but requested was 3040" + for _, tc := range []struct { + name string + message string + allow bool + matched bool + }{ + {name: "Anvil error with allowance", message: anvilError, allow: true, matched: true}, + {name: "Anvil error without allowance", message: anvilError}, + {name: "shutdown cancellation", message: "context canceled", allow: true}, + {name: "query deadline", message: "context deadline exceeded", allow: true}, + {name: "database error", message: "connection refused", allow: true}, + {name: "provider error", message: "missing trie node", allow: true}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Now().Truncate(time.Millisecond) + line := now.Format(nodeLogTimeFmt) + ` ERR Tick service=claimer error="` + tc.message + `"` + path := filepath.Join(t.TempDir(), "node.log") + require.NoError(t, os.WriteFile(path, []byte(line+"\n"), 0600)) + t.Setenv("CARTESI_TEST_NODE_LOG_FILE", path) + + var checker LogChecker + if tc.allow { + checker.SetExpectedLogs(t, anvilBlockOutOfRangeAllowlist) + } + unexpected, unmatched := scanNodeLogsBetween(t, now, now, checker.expectedLogs) + if tc.matched { + require.Empty(t, unexpected) + } else { + require.Equal(t, []string{line}, unexpected) + } + if tc.allow && !tc.matched { + require.Equal(t, []int{0}, unmatched) + } else { + require.Empty(t, unmatched) + } + }) + } +} diff --git a/test/integration/multi_app_test.go b/test/integration/multi_app_test.go index 8610e5127..36849674b 100644 --- a/test/integration/multi_app_test.go +++ b/test/integration/multi_app_test.go @@ -7,11 +7,15 @@ package integration import ( "context" + "encoding/json" "testing" "time" "github.com/cartesi/rollups-node/internal/jsonrpc/api" "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + jsonrpcclient "github.com/cartesi/rollups-node/pkg/jsonrpc/client" + "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/suite" ) @@ -90,16 +94,33 @@ func (s *MultiAppSuite) TestMultiAppIsolation() { payload2 := "input-for-app-2" defer timed(s.T(), "full multi-app isolation test")() + client := newIntegrationEthClient(s.ctx, s.T()) + defer client.Close() + rpc := jsonrpcclient.NewClient(envOrDefault("CARTESI_JSONRPC_API_URL", "http://localhost:10011/rpc")) + for _, app := range []struct{ name, address string }{ + {s.app1Name, s.app1Addr}, + {s.app2Name, s.app2Addr}, + } { + inputBox, err := ethutil.GetInputBox(s.ctx, client, common.HexToAddress(app.address)) + require.NoError(err, "read %s input box from the contract", app.name) + + var response api.SingleResponse[map[string]json.RawMessage] + err = rpc.Call(s.ctx, "cartesi_getApplication", api.GetApplicationParams{Application: app.name}, &response) + require.NoError(err, "read %s through JSON-RPC", app.name) + require.NotContains(response.Data, "data_availability") + var storedInputBox common.Address + require.NoError(json.Unmarshal(response.Data["iinputbox_address"], &storedInputBox)) + require.Equal(inputBox, storedInputBox) + } + // --- L1 -> Machine: send one input to each app and verify independent processing --- s.T().Log("Sending one input to each app — they should process independently with separate input indices...") - idx1, _, err := sendInput(s.ctx, s.app1Name, payload1) - require.NoError(err, "send input to app-1") + idx1, _, _ := sendInputThroughRelay(s.ctx, s.T(), common.HexToAddress(s.app1Addr), payload1) require.Equal(uint64(0), idx1) s.T().Logf(" app-1: input sent (index=%d)", idx1) - idx2, _, err := sendInput(s.ctx, s.app2Name, payload2) - require.NoError(err, "send input to app-2") + idx2, _, _ := sendInputThroughRelay(s.ctx, s.T(), common.HexToAddress(s.app2Addr), payload2) require.Equal(uint64(0), idx2, "app-2 should start at input index 0 independently") s.T().Logf(" app-2: input sent (index=%d) — independent counter, also starts at 0", idx2) @@ -177,8 +198,6 @@ func (s *MultiAppSuite) TestMultiAppIsolation() { // --- Consensus + L1 execution for both apps independently --- - client := newIntegrationEthClient(s.ctx, s.T()) - defer client.Close() var maxLastBlock uint64 for _, app := range []struct { name string diff --git a/test/integration/multinode_helpers_test.go b/test/integration/multinode_helpers_test.go index 063875265..b4c9f5245 100644 --- a/test/integration/multinode_helpers_test.go +++ b/test/integration/multinode_helpers_test.go @@ -12,7 +12,6 @@ import ( "net/http" "os" "os/exec" - "sync" "testing" "time" ) @@ -59,18 +58,9 @@ var multiNodeListenAddrs = []string{ ":10012", // inspect API } -type multiNodeProcess struct { - name string - cmd *exec.Cmd - done chan struct{} - - mu sync.Mutex - waitErr error -} - // multiNode is a running host multiprocess deployment. type multiNode struct { - procs []*multiNodeProcess + procs []*nodeSubprocess addrs []string logFile *os.File tail *exec.Cmd // tail -f process streaming the log to the terminal @@ -137,18 +127,11 @@ func startMultiNode(logPath string, extraEnv ...string) (*multiNode, error) { } cmd.Dir = workDir } - if err := cmd.Start(); err != nil { + proc, err := startNodeSubprocess(svc.name, cmd) + if err != nil { mn.stop(nil) return nil, fmt.Errorf("start %s: %w", svc.name, err) } - proc := &multiNodeProcess{name: svc.name, cmd: cmd, done: make(chan struct{})} - go func() { - err := cmd.Wait() - proc.mu.Lock() - proc.waitErr = err - proc.mu.Unlock() - close(proc.done) - }() fmt.Fprintf(os.Stderr, " started %s (telemetry %s, pid %d)\n", svc.name, svc.telemetryAddr, cmd.Process.Pid) mn.procs = append(mn.procs, proc) @@ -183,7 +166,7 @@ func (mn *multiNode) waitForHealth(ctx context.Context, _ testing.TB) error { if err != nil { return false, nil } - resp, err := client.Do(req) //nolint:gosec // url is a fixed localhost telemetry port + resp, err := client.Do(req) if err != nil { return false, nil } @@ -200,27 +183,13 @@ func (mn *multiNode) waitForHealth(ctx context.Context, _ testing.TB) error { func (mn *multiNode) exitedProcessError() error { for _, proc := range mn.procs { - if exited, err := proc.exitStatus(); exited { - if err != nil { - return fmt.Errorf("%s exited unexpectedly: %w", proc.name, err) - } - return fmt.Errorf("%s exited unexpectedly", proc.name) + if err := proc.exitedProcessError(); err != nil { + return err } } return nil } -func (p *multiNodeProcess) exitStatus() (bool, error) { - select { - case <-p.done: - p.mu.Lock() - defer p.mu.Unlock() - return true, p.waitErr - default: - return false, nil - } -} - // stop interrupts every service subprocess (in reverse start order) and waits // for it to exit, then stops the log tail and closes the log file. func (mn *multiNode) stop(t testing.TB) { @@ -260,21 +229,3 @@ func (mn *multiNode) stop(t testing.TB) { mn.logFile.Close() } } - -func (p *multiNodeProcess) isDone() bool { - select { - case <-p.done: - return true - default: - return false - } -} - -func (p *multiNodeProcess) wait(timeout time.Duration) bool { - select { - case <-p.done: - return true - case <-time.After(timeout): - return false - } -} diff --git a/test/integration/node_helpers_test.go b/test/integration/node_helpers_test.go index 10f0164ad..78f9f5066 100644 --- a/test/integration/node_helpers_test.go +++ b/test/integration/node_helpers_test.go @@ -72,11 +72,9 @@ func startSharedNode(t testing.TB) { } // startSharedNodeWithEnv is like startSharedNode but also lets the caller -// inject extra environment variables (e.g., -// CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false to bring the node up in -// reader mode for a single test phase). Restore default mode on test -// teardown by stopping the node and calling startSharedNode again. Under the -// multiprocess topology this starts/stops the whole service set. +// inject extra environment variables. Values must agree with saved settings; +// use startReaderNode for isolated reader-mode fixtures. Under the multiprocess +// topology this starts/stops the whole service set. func startSharedNodeWithEnv(t testing.TB, extraEnv ...string) { if sharedNode != nil { t.Fatal("cannot start node: already running") @@ -117,7 +115,7 @@ func nodePortAvailable() bool { // nodeProcess represents a running node subprocess managed by the test. type nodeProcess struct { - cmd *exec.Cmd + *nodeSubprocess logFile *os.File tail *exec.Cmd // tail -f process for live log streaming tty *os.File // /dev/tty FD used by tail; closed in stop() @@ -127,8 +125,8 @@ type nodeProcess struct { // to the given log file path. The node inherits the current environment // (database connection, blockchain endpoint, etc.) and additionally sets // fast polling intervals for test responsiveness. Any extraEnv entries are -// appended last, so they win against the suite defaults (useful for, e.g., -// CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false reader-mode tests). +// appended last, so they win against the suite defaults. Saved service settings +// must also match; environment changes alone cannot change submission mode. // // A background `tail -f` process streams the log file to the terminal so // the user can see node output in real time. This must be a separate process @@ -144,7 +142,7 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) return nil, fmt.Errorf("open log file %s: %w", logPath, err) } - cmd := exec.Command(nodeBinary) //nolint:gosec + cmd := exec.Command(nodeBinary) cmd.Stdout = logFile cmd.Stderr = logFile if workDir := os.Getenv("CARTESI_TEST_NODE_WORKDIR"); workDir != "" { @@ -162,7 +160,8 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) ) cmd.Env = append(cmd.Env, extraEnv...) - if err := cmd.Start(); err != nil { + proc, err := startNodeSubprocess(nodeBinary, cmd) + if err != nil { logFile.Close() return nil, fmt.Errorf("start node: %w", err) } @@ -171,9 +170,9 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) // We write to /dev/tty to bypass go test and gotestsum's output capture, // so the user sees node logs in real time just like the old Makefile did. // Falls back silently if /dev/tty is not available (e.g., CI, compose). - tty, ttyErr := os.OpenFile("/dev/tty", os.O_WRONLY, 0) //nolint:gosec + tty, ttyErr := os.OpenFile("/dev/tty", os.O_WRONLY, 0) if ttyErr != nil { - return &nodeProcess{cmd: cmd, logFile: logFile}, nil + return &nodeProcess{nodeSubprocess: proc, logFile: logFile}, nil } tail := exec.Command("tail", "-f", logPath) //nolint:gosec @@ -181,10 +180,10 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) tail.Stderr = tty if err := tail.Start(); err != nil { tty.Close() - return &nodeProcess{cmd: cmd, logFile: logFile}, nil + return &nodeProcess{nodeSubprocess: proc, logFile: logFile}, nil } - return &nodeProcess{cmd: cmd, logFile: logFile, tail: tail, tty: tty}, nil + return &nodeProcess{nodeSubprocess: proc, logFile: logFile, tail: tail, tty: tty}, nil } // waitForHealth polls the node's readyz endpoint until it responds 200 OK @@ -192,6 +191,9 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) func (n *nodeProcess) waitForHealth(ctx context.Context, t testing.TB) error { client := &http.Client{Timeout: 2 * time.Second} return pollUntil(ctx, 2*time.Second, func() (bool, error) { + if err := n.exitedProcessError(); err != nil { + return false, err + } req, err := http.NewRequestWithContext( ctx, "GET", "http://localhost:10000/readyz", nil) if err != nil { @@ -232,25 +234,23 @@ func (n *nodeProcess) stop(t testing.TB) { } // Send interrupt for graceful shutdown. - if err := n.cmd.Process.Signal(os.Interrupt); err != nil { - if t != nil { - t.Logf(" signal failed, killing: %v", err) + if !n.isDone() { + if err := n.cmd.Process.Signal(os.Interrupt); err != nil { + if t != nil { + t.Logf(" signal failed, killing: %v", err) + } + _ = n.cmd.Process.Kill() } - _ = n.cmd.Process.Kill() } // Wait for exit with a timeout — if the node hangs during shutdown, // fall back to SIGKILL so the test suite doesn't hang indefinitely. - done := make(chan error, 1) - go func() { done <- n.cmd.Wait() }() - select { - case <-done: - case <-time.After(30 * time.Second): + if !n.wait(30 * time.Second) { if t != nil { t.Log(" node did not exit within 30s, sending SIGKILL") } _ = n.cmd.Process.Kill() - <-done + <-n.done } n.logFile.Close() if t != nil { diff --git a/test/integration/node_subprocess_test.go b/test/integration/node_subprocess_test.go new file mode 100644 index 000000000..a944a5487 --- /dev/null +++ b/test/integration/node_subprocess_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "fmt" + "io" + "os/exec" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// nodeSubprocess waits exactly once and makes process exit visible to readiness +// and shutdown checks in both test topologies. +type nodeSubprocess struct { + name string + cmd *exec.Cmd + done chan struct{} + + mu sync.Mutex + waitErr error +} + +func startNodeSubprocess(name string, cmd *exec.Cmd) (*nodeSubprocess, error) { + if err := cmd.Start(); err != nil { + return nil, err + } + p := &nodeSubprocess{name: name, cmd: cmd, done: make(chan struct{})} + go func() { + err := cmd.Wait() + p.mu.Lock() + p.waitErr = err + p.mu.Unlock() + close(p.done) + }() + return p, nil +} + +func (p *nodeSubprocess) exitedProcessError() error { + select { + case <-p.done: + p.mu.Lock() + defer p.mu.Unlock() + if p.waitErr != nil { + return fmt.Errorf("%s exited unexpectedly: %w", p.name, p.waitErr) + } + return fmt.Errorf("%s exited unexpectedly", p.name) + default: + return nil + } +} + +func (p *nodeSubprocess) isDone() bool { + select { + case <-p.done: + return true + default: + return false + } +} + +func (p *nodeSubprocess) wait(timeout time.Duration) bool { + select { + case <-p.done: + return true + case <-time.After(timeout): + return false + } +} + +func TestNodeSubprocess(t *testing.T) { + for _, exitCode := range []int{0, 7} { + t.Run(fmt.Sprintf("exit_%d", exitCode), func(t *testing.T) { + cmd := exec.CommandContext(t.Context(), "sh", "-c", "read value; exit \"$value\"") + stdin, err := cmd.StdinPipe() + require.NoError(t, err) + p, err := startNodeSubprocess("test-node", cmd) + require.NoError(t, err) + t.Cleanup(func() { + _ = stdin.Close() + if !p.isDone() { + _ = cmd.Process.Kill() + } + require.True(t, p.wait(time.Second), "child must be reaped") + }) + require.NoError(t, p.exitedProcessError()) + require.False(t, p.wait(time.Millisecond)) + _, err = io.WriteString(stdin, fmt.Sprintf("%d\n", exitCode)) + require.NoError(t, err) + require.NoError(t, stdin.Close()) + require.True(t, p.wait(time.Second)) + require.True(t, p.isDone()) + err = p.exitedProcessError() + require.ErrorContains(t, err, "test-node exited unexpectedly") + if exitCode != 0 { + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, exitCode, exitErr.ExitCode()) + } + require.True(t, p.wait(time.Second), "repeated checks must not consume exit status") + }) + } +} diff --git a/test/integration/reject_exception_prt_test.go b/test/integration/reject_exception_prt_test.go index 74868135b..8ff18a58d 100644 --- a/test/integration/reject_exception_prt_test.go +++ b/test/integration/reject_exception_prt_test.go @@ -87,8 +87,8 @@ func (s *RejectExceptionPrtSuite) TestRejectInputPrt() { ExtraDeployArgs: []string{"--prt"}, EpochIndex: &prtEpoch, PreClaimHook: func(ctx context.Context, t testing.TB, require *require.Assertions, appName string) { - settleTournament(ctx, t, require, ethClient, appName, 0) - settleTournament(ctx, t, require, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 1) }, }) } diff --git a/test/integration/restart_test.go b/test/integration/restart_test.go index 9d6e28e93..26f2a7592 100644 --- a/test/integration/restart_test.go +++ b/test/integration/restart_test.go @@ -13,11 +13,13 @@ package integration import ( "context" + "os/exec" "regexp" "testing" "time" "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -74,6 +76,29 @@ func (s *RestartSuite) TearDownTest() { s.CheckLogs(s.T()) } +func (s *RestartSuite) TestExitedNodeFailsReadiness() { + for _, script := range []string{"exit 0", "exit 7"} { + s.Run(script, func() { + t := s.T() + r := require.New(t) + cmd := exec.CommandContext(t.Context(), "sh", "-c", script) + proc, err := startNodeSubprocess("startup-probe", cmd) + r.NoError(err) + t.Cleanup(func() { + if !proc.isDone() { + _ = cmd.Process.Kill() + } + r.True(proc.wait(time.Second)) + }) + r.True(proc.wait(time.Second)) + ctx, cancel := context.WithTimeout(s.ctx, time.Second) + defer cancel() + node := &nodeProcess{nodeSubprocess: proc} + r.ErrorContains(node.waitForHealth(ctx, t), "startup-probe exited unexpectedly") + }) + } +} + // restartConfig configures the shared restart test flow. type restartConfig struct { // ExtraDeployArgs are additional CLI flags for deploy (e.g., "--prt"). @@ -98,6 +123,7 @@ func (s *RestartSuite) runRestartTest(cfg restartConfig) { // === Phase 1: Deploy two apps and process inputs === s.T().Log("--- Phase 1: Deploy two apps and process inputs before restart ---") + var app1Address common.Address func() { defer timed(s.T(), "deploy two echo-dapps")() @@ -111,6 +137,7 @@ func (s *RestartSuite) runRestartTest(cfg restartConfig) { addr1, err := deployApplication( s.ctx, s.app1Name, dappPath, deployArgs1...) require.NoError(err, "deploy app-1") + app1Address = common.HexToAddress(addr1) s.T().Logf(" app-1 deployed at %s", addr1) s.T().Logf(" deploying app-2: name=%s", s.app2Name) @@ -127,8 +154,7 @@ func (s *RestartSuite) runRestartTest(cfg restartConfig) { }() s.T().Log("Sending one input to each app before restart...") - idx1, _, err := sendInput(s.ctx, s.app1Name, "pre-restart-1") - require.NoError(err, "send input to app-1") + idx1, _, _ := sendInputThroughRelay(s.ctx, s.T(), app1Address, "pre-restart-1") s.T().Logf(" app-1: input sent (index=%d)", idx1) idx2, _, err := sendInput(s.ctx, s.app2Name, "pre-restart-2") @@ -286,7 +312,7 @@ func (s *RestartSuite) TestRestartMultiAppAuthority() { // TestRestartMultiAppPrt tests restart with PRT (Dave) consensus. func (s *RestartSuite) TestRestartMultiAppPrt() { - // PRT settlement mines hundreds of blocks rapidly, which can cause + // PRT finalization mines hundreds of blocks rapidly, which can cause // transient BlockOutOfRangeError in the EVM reader. s.SetExpectedLogs(s.T(), ExpectedLog{ Pattern: regexp.MustCompile(`BlockOutOfRangeError`), @@ -304,19 +330,19 @@ func (s *RestartSuite) TestRestartMultiAppPrt() { defer ethClient.Close() s.runRestartTest(restartConfig{ - ExtraDeployArgs: []string{"--prt"}, + ExtraDeployArgs: []string{prtFlag}, PreClaimHook: func( ctx context.Context, t testing.TB, require *require.Assertions, _ string, ) { - // Settle tournaments for BOTH apps together. Mining blocks + // Finalize tournaments for BOTH apps together. Mining blocks // for one app's tournament timeout also advances the shared // chain, so we must ensure all apps' commitments are joined // before mining — otherwise the other app's tournament can // time out without a commitment ("finished without winners"). apps := []string{s.app1Name, s.app2Name} for _, epochIdx := range []uint64{0, 1} { - var tournaments []*model.Tournament + tournaments := make([]*model.Tournament, 0, len(apps)) for _, name := range apps { t.Logf("Waiting for %s epoch %d "+ "tournament and commitment...", @@ -336,11 +362,13 @@ func (s *RestartSuite) TestRestartMultiAppPrt() { blocksMined, apps[i], epochIdx) } } - for _, name := range apps { + for i, name := range apps { waitForTournamentWinner( - ctx, t, require, name, epochIdx) + ctx, t, require, ethClient, name, epochIdx) + waitForPrtEpochAcceptedAndBondRecovered( + ctx, t, require, ethClient, name, epochIdx, tournaments[i].Address) } - t.Logf(" epoch %d settled for both apps", epochIdx) + t.Logf(" epoch %d finalized for both apps", epochIdx) } }, }) diff --git a/test/integration/same_block_inputs_test.go b/test/integration/same_block_inputs_test.go index e96348038..e95e36ae8 100644 --- a/test/integration/same_block_inputs_test.go +++ b/test/integration/same_block_inputs_test.go @@ -16,6 +16,7 @@ import ( "math/big" "net/http" "os" + "strconv" "strings" "testing" "time" @@ -304,20 +305,25 @@ func (s *SameBlockInputsSuite) runMultipleInputsOneBlock(extraDeployArgs []strin // new open epoch — the case where the two epochs overlap on one block // (sealed.LastBlock == open.FirstBlock). // -// The seal is DaveConsensus.settle(), which the node also issues on its own. -// We submit our own settle inside the batch (reusing the validator's computed -// outputs root + proof from the DB) and bracket the two inputs with extreme gas -// prices: the pre-seal input is mined first and the post-seal input last, so -// whichever settle the block orders between them captures an input-index -// boundary of exactly one. A duplicate node settle in the same block reverts -// harmlessly, leaving the same boundary. +// The seal is DaveConsensus.acceptStagedTournamentResult(), which the node also +// issues on its own. We first let the node stage the finished tournament. We +// then stop the node before the acceptance window. We submit our own acceptance +// inside the batch and bracket it with inputs that use extreme gas prices. The +// pre-seal input is mined first. The post-seal input is mined last. The stopped +// node cannot race the controlled acceptance transaction. func (s *SameBlockInputsSuite) TestInputsBeforeAndAfterEpochSealedSameBlock() { r := s.Require() + if !isNodeSelfManaged() { + s.T().Skip("skipping: same-block seal test requires a test-managed node") + } s.SetExpectedLogs(s.T(), prtBlockOutOfRangeAllowlist) s.appName = uniqueAppName("same-block-seal-prt") + const claimStagingPeriod uint64 = 300 dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") - appAddrStr, err := deployApplication(s.ctx, s.appName, dappPath, "--salt", uniqueSalt(), "--prt") + appAddrStr, err := deployApplication(s.ctx, s.appName, dappPath, + "--salt", uniqueSalt(), "--prt", + claimStagingPeriodFlag, strconv.FormatUint(claimStagingPeriod, 10)) r.NoError(err, "deploy PRT app") appAddr := common.HexToAddress(appAddrStr) @@ -326,11 +332,11 @@ func (s *SameBlockInputsSuite) TestInputsBeforeAndAfterEpochSealedSameBlock() { "fresh PRT app should have no inputs yet (epoch 0 is sealed empty at deploy)") // Epoch 0 is sealed empty at deploy. Wait for the node to join its root - // tournament and for the validator to compute the outputs root + proof that - // settle(0) needs. This happens before we pass the timeout, so the node has - // not settled epoch 0 itself yet. + // tournament and for the validator to compute the machine-validity proof. + // This happens before we pass the timeout, so the node has not staged epoch + // 0 yet. tournament := s.mineUntilTournamentReady(0) - root, proof, consensusAddr := s.readEpochSettlementData(0) + consensusAddr := s.readPrtConsensusAddress() inputBox, err := iinputbox.NewIInputBox(inputBoxAddr, s.client) r.NoError(err, "bind input box") @@ -347,32 +353,66 @@ func (s *SameBlockInputsSuite) TestInputsBeforeAndAfterEpochSealedSameBlock() { return &opts } - // Keep mining under our control for the whole settle window so neither the - // timeout blocks nor the node's own settle get auto-mined out from under us. - setAnvilAutomine(s.ctx, s.T(), false) - defer setAnvilAutomine(s.ctx, s.T(), true) - setAnvilIntervalMining(s.ctx, s.T(), 0) - defer setAnvilIntervalMining(s.ctx, s.T(), 1) - - // Make settle(0) valid by passing epoch 0's root-tournament timeout. + // Finish the root tournament while automatic mining is still active. The + // node must stage the machine-validity proof before acceptance is possible. _, err = mineForTournamentTimeout(s.ctx, s.client, tournament.Address) r.NoError(err, "mine past epoch 0 tournament timeout") + waitForTournamentWinner(s.ctx, s.T(), r, s.client, s.appName, 0) + + stagedCtx, stagedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + stagedEpoch, err := waitForEpochStatus( + stagedCtx, s.T(), s.appName, 0, model.EpochStatus_ClaimStaged) + stagedCancel() + r.NoError(err, "wait for epoch 0 tournament result to be staged") + r.NotNil(stagedEpoch.StagedAtBlock, "staged PRT epoch must record the staging block") + + // Stop the node before entering the acceptance window. Otherwise, the PRT + // service can submit its own valid acceptance while this test assembles the + // controlled input/acceptance/input block. + stopSharedNode(s.T()) + nodeNeedsRestart := true + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + if err := anvilRPC(cleanupCtx, "evm_setAutomine", true); err != nil { + s.T().Errorf("restore Anvil automine: %v", err) + } + if err := anvilRPC(cleanupCtx, "evm_setIntervalMining", 1); err != nil { + s.T().Errorf("restore Anvil interval mining: %v", err) + } + if nodeNeedsRestart && sharedNode == nil { + startSharedNode(s.T()) + } + }() + + // Keep mining under our control for the acceptance window so neither the + // inputs nor our acceptance are mined before the complete batch is ready. + setAnvilAutomine(s.ctx, s.T(), false) + setAnvilIntervalMining(s.ctx, s.T(), 0) - root32 := [32]byte(root) - proof32 := make([][32]byte, len(proof)) - for i := range proof { - proof32[i] = [32]byte(proof[i]) + // Mine up to the block before the staging period boundary. The batch below + // creates the first block in which acceptance is valid. + currentBlock, err := s.client.BlockNumber(s.ctx) + r.NoError(err, "read block before PRT acceptance batch") + acceptanceBlock := *stagedEpoch.StagedAtBlock + claimStagingPeriod + r.Less(currentBlock, acceptanceBlock, + "epoch must remain staged before the claim staging period ends") + if currentBlock+1 < acceptanceBlock { + blocksToMine := acceptanceBlock - currentBlock - 1 + r.LessOrEqual(blocksToMine, claimStagingPeriod, + "blocks to mine must fit within the configured staging period") + r.NoError(anvilMine(s.ctx, int(blocksToMine)), //nolint:gosec // Bounded by claimStagingPeriod. + "mine to the block before the PRT staging boundary") } - // One block: pre-seal input (top gas → first), seal (mid gas), post-seal - // input (bottom gas → last). Distinct senders avoid same-account nonce - // coupling. We do not assert the seal receipt status — if the node's own - // settle wins the in-block ordering, ours reverts, but the boundary is the - // same because both settles execute between the two inputs. + // One block: pre-seal input (top gas), accept (mid gas), post-seal input + // (bottom gas → last). Distinct senders avoid same-account nonce + // coupling. The block before this one is still inside the staging window, so + // the node cannot submit an earlier valid acceptance. preTx, err := inputBox.AddInput(nextOpts(2, 1000), appAddr, []byte("before-seal")) //nolint:mnd r.NoError(err, "submit pre-seal input") - _, err = consensus.Settle(nextOpts(3, 500), big.NewInt(0), root32, proof32) //nolint:mnd - r.NoError(err, "submit settle for epoch 0") + acceptTx, err := consensus.AcceptStagedTournamentResult(nextOpts(3, 500), big.NewInt(0)) + r.NoError(err, "submit acceptance for epoch 0") postTx, err := inputBox.AddInput(nextOpts(4, 1), appAddr, []byte("after-seal")) r.NoError(err, "submit post-seal input") @@ -381,16 +421,37 @@ func (s *SameBlockInputsSuite) TestInputsBeforeAndAfterEpochSealedSameBlock() { // Both inputs must share the seal block, in order. receiptCtx, receiptCancel := context.WithTimeout(s.ctx, 30*time.Second) preReceipt := waitReceipt(receiptCtx, s.T(), s.client, preTx) + acceptReceipt := waitReceipt(receiptCtx, s.T(), s.client, acceptTx) postReceipt := waitReceipt(receiptCtx, s.T(), s.client, postTx) receiptCancel() r.Equal(uint64(1), preReceipt.Status, "pre-seal input transaction must succeed") + r.Equal(uint64(1), acceptReceipt.Status, "epoch acceptance transaction must succeed") r.Equal(uint64(1), postReceipt.Status, "post-seal input transaction must succeed") r.Equal(preReceipt.BlockNumber.Uint64(), postReceipt.BlockNumber.Uint64(), "both inputs must be mined in the same block as the seal") + r.Equal(preReceipt.BlockNumber.Uint64(), acceptReceipt.BlockNumber.Uint64(), + "acceptance transaction must be mined in the input batch") + r.Equal(acceptanceBlock, acceptReceipt.BlockNumber.Uint64(), + "acceptance must execute at the first valid staging block") + r.Less(preReceipt.TransactionIndex, acceptReceipt.TransactionIndex, + "pre-seal input must be ordered before acceptance") + r.Less(acceptReceipt.TransactionIndex, postReceipt.TransactionIndex, + "acceptance must be ordered before the post-seal input") r.Less(preReceipt.TransactionIndex, postReceipt.TransactionIndex, "pre-seal input must be ordered before post-seal input") sealBlock := preReceipt.BlockNumber.Uint64() + // Resume normal mining and restart the node. The reader must reconcile the + // externally accepted epoch. The PRT service must then recover its root bond. + setAnvilAutomine(s.ctx, s.T(), true) + setAnvilIntervalMining(s.ctx, s.T(), 1) + startSharedNode(s.T()) + nodeNeedsRestart = false + acceptedEpoch := waitForPrtEpochAcceptedAndBondRecovered( + s.ctx, s.T(), r, s.client, s.appName, 0, tournament.Address) + r.Equal(stagedEpoch.StagedAtBlock, acceptedEpoch.StagedAtBlock, + "acceptance must retain the previously observed staging block") + // The pre-seal input belongs to the just-sealed epoch 1; the post-seal input // belongs to the new open epoch 2. preCtx, preCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -457,13 +518,9 @@ func (s *SameBlockInputsSuite) mineUntilTournamentReady(epochIndex uint64) *mode return tournament } -// readEpochSettlementData reads, from the node database, the outputs merkle root -// and proof the validator computed for the epoch (used to drive settle ourselves) -// and the application's DaveConsensus address. It waits until the root has been -// computed. -func (s *SameBlockInputsSuite) readEpochSettlementData( - epochIndex uint64, -) (root common.Hash, proof []common.Hash, consensusAddr common.Address) { +// readPrtConsensusAddress reads the deployed application's DaveConsensus +// address from the repository. +func (s *SameBlockInputsSuite) readPrtConsensusAddress() common.Address { r := s.Require() dsn, err := config.GetDatabaseConnection() r.NoError(err, "get database connection") @@ -474,24 +531,7 @@ func (s *SameBlockInputsSuite) readEpochSettlementData( app, err := repo.GetApplication(s.ctx, s.appName) r.NoError(err, "get application") r.NotNil(app, "application must exist") - consensusAddr = app.IConsensusAddress - - ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) - defer cancel() - err = pollUntil(ctx, 2*time.Second, func() (bool, error) { - epoch, err := repo.GetEpoch(ctx, s.appName, epochIndex) - if err != nil { - return false, err - } - if epoch == nil || epoch.TxBufferDataBlock == nil { - return false, nil - } - root = *epoch.TxBufferDataBlock - proof = epoch.TxBufferProof - return true, nil - }) - r.NoError(err, "wait for epoch %d settlement data (outputs merkle root)", epochIndex) - return root, proof, consensusAddr + return app.IConsensusAddress } // deploySpambox deploys the Spambox helper contract and fails the test on any diff --git a/test/integration/snapshot_policy_test.go b/test/integration/snapshot_policy_test.go index 02ad3ed74..d061a6131 100644 --- a/test/integration/snapshot_policy_test.go +++ b/test/integration/snapshot_policy_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -38,12 +39,6 @@ var snapshotRestartExpectedLogs = []ExpectedLog{ Level: LevelError, Reason: "transient Anvil error during rapid block mining or post-restart catchup", }, - { - Pattern: regexp.MustCompile(`service=evm-reader.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from restarting the node mid-tick; " + - "retryablehttp wraps the cancellation as `Post \"\": context canceled`", - }, } func TestSnapshotPolicy(t *testing.T) { @@ -159,9 +154,8 @@ func (s *SnapshotPolicySuite) runSnapshotPolicyTest(cfg snapshotPolicyConfig) { // === Send first input === s.T().Logf("Sending first input (snap-%s-1)...", policyStr) - idx1, _, err := sendInput( - s.ctx, s.appName, "snap-"+policyStr+"-1") - require.NoError(err, "send first input") + idx1, _, _ := sendInputThroughRelay( + s.ctx, s.T(), common.HexToAddress(addr), "snap-"+policyStr+"-1") s.T().Logf(" input sent (index=%d)", idx1) func() { @@ -311,8 +305,8 @@ func (s *SnapshotPolicySuite) TestSnapshotPolicyEveryInputPrt() { ctx context.Context, t testing.TB, require *require.Assertions, appName string, ) { - settleTournament(ctx, t, require, ethClient, appName, 0) - settleTournament(ctx, t, require, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 1) }, }) } @@ -337,8 +331,8 @@ func (s *SnapshotPolicySuite) TestSnapshotPolicyEveryEpochPrt() { ctx context.Context, t testing.TB, require *require.Assertions, appName string, ) { - settleTournament(ctx, t, require, ethClient, appName, 0) - settleTournament(ctx, t, require, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 1) }, }) } diff --git a/test/integration/terminal_machine_states_test.go b/test/integration/terminal_machine_states_test.go index 46fc4c21a..1d39c1b07 100644 --- a/test/integration/terminal_machine_states_test.go +++ b/test/integration/terminal_machine_states_test.go @@ -8,7 +8,6 @@ package integration import ( "context" "fmt" - "regexp" "testing" "time" @@ -43,12 +42,6 @@ const ( terminalObservationRPCTimeout = 2 * time.Second ) -var terminalMachineRestartExpectedLog = ExpectedLog{ - Pattern: regexp.MustCompile(`service=(?:claimer|evm-reader).*context canceled`), - Level: LevelError, - Reason: "benign service cancellation while deliberately restarting the node", -} - func TestTerminalMachineStates(t *testing.T) { if !isNodeSelfManaged() { t.Skip("skipping: durable terminal-state test requires a test-managed node restart") @@ -125,7 +118,7 @@ func (s *TerminalMachineStatesSuite) TestUnexpectedYieldSurvivesRestart() { func (s *TerminalMachineStatesSuite) runTerminalMachineState(tc terminalMachineStateCase) { s.T().Helper() - s.SetExpectedLogs(s.T(), terminalExecutionExpectedLog, terminalMachineRestartExpectedLog) + s.SetExpectedLogs(s.T(), terminalExecutionExpectedLog) require := s.Require() s.appName = uniqueAppName(tc.namePrefix) dappPath := envOrDefault(tc.dappPathEnv, tc.defaultDappPath) diff --git a/test/integration/testdata/InputRelay.sol b/test/integration/testdata/InputRelay.sol new file mode 100644 index 000000000..f71a38181 --- /dev/null +++ b/test/integration/testdata/InputRelay.sol @@ -0,0 +1,21 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +pragma solidity ^0.8.30; + +interface IInputBoxForInputRelay { + function addInput(address app, bytes calldata payload) external returns (bytes32); +} + +contract InputRelay { + IInputBoxForInputRelay public immutable inputBox; + + constructor(address inputBox_) { + inputBox = IInputBoxForInputRelay(inputBox_); + } + + function addInput(address app, bytes calldata payload) external returns (bytes32) { + return inputBox.addInput(app, payload); + } + + fallback() external payable {} +} diff --git a/test/integration/testdata/input_relay_abi.json b/test/integration/testdata/input_relay_abi.json new file mode 100644 index 000000000..77ab39754 --- /dev/null +++ b/test/integration/testdata/input_relay_abi.json @@ -0,0 +1,6 @@ +[ + {"type":"constructor","inputs":[{"name":"inputBox_","type":"address","internalType":"address"}],"stateMutability":"nonpayable"}, + {"type":"fallback","stateMutability":"payable"}, + {"type":"function","name":"addInput","inputs":[{"name":"app","type":"address","internalType":"address"},{"name":"payload","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"nonpayable"}, + {"type":"function","name":"inputBox","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IInputBoxForInputRelay"}],"stateMutability":"view"} +] diff --git a/test/integration/testdata/input_relay_bytecode.hex b/test/integration/testdata/input_relay_bytecode.hex new file mode 100644 index 000000000..749558f3d --- /dev/null +++ b/test/integration/testdata/input_relay_bytecode.hex @@ -0,0 +1 @@ +0x60a060405234801561000f575f5ffd5b5060405161055b38038061055b833981810160405281019061003191906100c9565b8073ffffffffffffffffffffffffffffffffffffffff1660808173ffffffffffffffffffffffffffffffffffffffff1681525050506100f4565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6100988261006f565b9050919050565b6100a88161008e565b81146100b2575f5ffd5b50565b5f815190506100c38161009f565b92915050565b5f602082840312156100de576100dd61006b565b5b5f6100eb848285016100b5565b91505092915050565b6080516104496101125f395f81816098015261013e01526104495ff3fe60806040526004361061002c575f3560e01c80631789cd631461002f57806344e906111461006b5761002d565b5b005b34801561003a575f5ffd5b5061005560048036038101906100509190610223565b610095565b6040516100629190610298565b60405180910390f35b348015610076575f5ffd5b5061007f61013c565b60405161008c919061030c565b60405180910390f35b5f7f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff16631789cd638585856040518463ffffffff1660e01b81526004016100f39392919061038e565b6020604051808303815f875af115801561010f573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061013391906103e8565b90509392505050565b7f000000000000000000000000000000000000000000000000000000000000000081565b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61019182610168565b9050919050565b6101a181610187565b81146101ab575f5ffd5b50565b5f813590506101bc81610198565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f8401126101e3576101e26101c2565b5b8235905067ffffffffffffffff811115610200576101ff6101c6565b5b60208301915083600182028301111561021c5761021b6101ca565b5b9250929050565b5f5f5f6040848603121561023a57610239610160565b5b5f610247868287016101ae565b935050602084013567ffffffffffffffff81111561026857610267610164565b5b610274868287016101ce565b92509250509250925092565b5f819050919050565b61029281610280565b82525050565b5f6020820190506102ab5f830184610289565b92915050565b5f819050919050565b5f6102d46102cf6102ca84610168565b6102b1565b610168565b9050919050565b5f6102e5826102ba565b9050919050565b5f6102f6826102db565b9050919050565b610306816102ec565b82525050565b5f60208201905061031f5f8301846102fd565b92915050565b61032e81610187565b82525050565b5f82825260208201905092915050565b828183375f83830152505050565b5f601f19601f8301169050919050565b5f61036d8385610334565b935061037a838584610344565b61038383610352565b840190509392505050565b5f6040820190506103a15f830186610325565b81810360208301526103b4818486610362565b9050949350505050565b6103c781610280565b81146103d1575f5ffd5b50565b5f815190506103e2816103be565b92915050565b5f602082840312156103fd576103fc610160565b5b5f61040a848285016103d4565b9150509291505056fea2646970667358221220356222b2245360ca49c941c70259d3fddc283ef304fdf2ad9a238df74b79742664736f6c634300081e0033 diff --git a/test/integration/withdrawal_lifecycle_test.go b/test/integration/withdrawal_lifecycle_test.go index 93dddbfba..e07f9eb00 100644 --- a/test/integration/withdrawal_lifecycle_test.go +++ b/test/integration/withdrawal_lifecycle_test.go @@ -46,9 +46,9 @@ const ( withdrawalPreForecloseAmount uint64 = 25 withdrawalPostForecloseAmount uint64 = withdrawalDepositAmount - withdrawalPreForecloseAmount - defaultDevnetERC20PortalAddress = "0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1" - defaultDevnetTestERC20Address = "0x88A2120B7068E78692C8fd12E751d610B6377E4d" - defaultDevnetWithdrawalOutputBuilderAddress = "0x0745787835A019cd4dae8EDB541Fbc0647793d63" + defaultDevnetERC20PortalAddress = "0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419" + defaultDevnetTestERC20Address = "0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" + defaultDevnetWithdrawalOutputBuilderAddress = "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" accountsDriveLog2MaxNumOfAccounts = uint8(17) accountsDriveLog2LeavesPerAccount = uint8(0) @@ -241,7 +241,7 @@ func (s *WithdrawalLifecycleSuite) deployWithdrawalApp( "deploy", "quorum", "--json", "--salt", uniqueSalt(), - "--claim-staging-period", strconv.FormatUint(quorumClaimStagingPeriod, 10), + claimStagingPeriodFlag, strconv.FormatUint(quorumClaimStagingPeriod, 10), } for _, validator := range validators { quorumArgs = append(quorumArgs, "--validator", validator.Hex()) @@ -314,7 +314,7 @@ func (s *WithdrawalLifecycleSuite) finalizeWithdrawalEpoch( return finalEpoch case withdrawalConsensusPRT: for i := uint64(0); i <= targetEpochIndex; i++ { - settleTournament(s.ctx, s.T(), r, s.client, deployment.appName, i) + finalizePrtEpoch(s.ctx, s.T(), r, s.client, deployment.appName, i) } claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) epoch, err := waitForEpochStatus(claimCtx, s.T(), deployment.appName, targetEpochIndex, model.EpochStatus_ClaimAccepted) @@ -351,8 +351,8 @@ func (s *WithdrawalLifecycleSuite) finalizeQuorumEpoch( case model.EpochStatus_ClaimStaged: return s.waitForQuorumAccepted(deployment.appName, epochIndex) case model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted: - s.submitQuorumClaim(deployment, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(deployment, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(deployment, epoch, quorumValidatorIndexA) + s.submitQuorumClaim(deployment, epoch, quorumValidatorIndexB) return s.waitForQuorumAccepted(deployment.appName, epochIndex) default: s.Require().FailNowf("unexpected quorum epoch status", @@ -377,7 +377,7 @@ func (s *WithdrawalLifecycleSuite) waitForQuorumEpochWithClaim(appName string, e } return false, fmt.Errorf("poll epoch %d claim: %w", epochIndex, err) } - if epoch.TxBufferDataBlock != nil && epoch.MachineHash != nil && isQuorumClaimReadyStatus(epoch.Status) { + if epoch.HasCompleteStateProof() && isQuorumClaimReadyStatus(epoch.Status) { result = epoch return true, nil } @@ -395,11 +395,10 @@ func (s *WithdrawalLifecycleSuite) submitQuorumClaim( deployment withdrawalAppDeployment, epoch *model.Epoch, accountIndex uint32, - outputsMerkleRoot [32]byte, ) { r := s.Require() - r.NotNil(epoch.TxBufferDataBlock, "epoch %d missing outputs merkle root", epoch.Index) r.NotNil(deployment.quorum, "quorum binding is required") + machineRoot, proof := quorumMachineValidityProof(s.T(), epoch) key, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, accountIndex) r.NoError(err, "derive validator key %d", accountIndex) @@ -411,8 +410,8 @@ func (s *WithdrawalLifecycleSuite) submitQuorumClaim( opts, deployment.appAddress, new(big.Int).SetUint64(epoch.LastBlock), - outputsMerkleRoot, - merkleProofToBytes32(epoch.TxBufferProof), + machineRoot, + proof, ) r.NoError(err, "validator %d submit quorum claim", accountIndex) receipt, err := bind.WaitMined(s.ctx, s.client, tx) From 193aef80d48c8d59f88655baedef5f8a7902896a Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:19 -0300 Subject: [PATCH 40/41] test(integration): cover passive prt observation Drive dispute lifecycles with external test participants while the node observes passively. Verify the tournament drill-down API, child and match state, immutable event identities, and financial events. Cover both a matching external winner and a losing local commitment. Include sparse commitment fixtures, observer configuration and cleanup checks, and the PRT shard registration. These transaction-driving helpers are test fixtures, not active dispute support in the node. --- Makefile | 2 +- test/integration/divergent_claim_test.go | 42 ++- test/integration/foreclose_replay_test.go | 16 +- test/integration/main_test.go | 10 + .../prt_commitment_helpers_test.go | 91 +++++ test/integration/prt_commitment_test.go | 161 +++++++++ .../prt_passive_observer_actions_test.go | 136 ++++++++ .../prt_passive_observer_api_test.go | 291 ++++++++++++++++ .../prt_passive_observer_config_test.go | 142 ++++++++ .../prt_passive_observer_events_test.go | 171 ++++++++++ test/integration/prt_passive_observer_test.go | 311 ++++++++++++++++++ test/integration/reader_node_helpers_test.go | 80 +++++ 12 files changed, 1414 insertions(+), 39 deletions(-) create mode 100644 test/integration/prt_commitment_helpers_test.go create mode 100644 test/integration/prt_commitment_test.go create mode 100644 test/integration/prt_passive_observer_actions_test.go create mode 100644 test/integration/prt_passive_observer_api_test.go create mode 100644 test/integration/prt_passive_observer_config_test.go create mode 100644 test/integration/prt_passive_observer_events_test.go create mode 100644 test/integration/prt_passive_observer_test.go create mode 100644 test/integration/reader_node_helpers_test.go diff --git a/Makefile b/Makefile index 2d7cd3cfb..63dedc439 100644 --- a/Makefile +++ b/Makefile @@ -617,7 +617,7 @@ INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal awskms INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|TerminalMachineStates|MultiApp|EchoAuthorityStaging|IntegrationCLI|IntegrationLogScanner)$$ INTEGRATION_SHARD_quorum := ^Test(EchoQuorum|SameBlockInputs)$$ -INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt)$$ +INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt|PrtPassiveDisputeObserver|SparseDisputeCommitment(MatchesDenseTrees|CanonicalGeometry|RejectsOutOfRangeRequests)|PassiveObserver(CleanupPreservesRestoreOrder|Config(PreservesOriginalAndUnrelatedFields|RejectsMissingFieldsAndInvalidJSON)))$$ INTEGRATION_SHARD_replay := ^Test(Foreclose|ForecloseReplay|DivergentClaim)$$ INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy|NodeSubprocess)$$ INTEGRATION_SHARD_withdrawal := ^TestWithdrawalLifecycle$$ diff --git a/test/integration/divergent_claim_test.go b/test/integration/divergent_claim_test.go index 46f013a6e..1dd520b6c 100644 --- a/test/integration/divergent_claim_test.go +++ b/test/integration/divergent_claim_test.go @@ -62,8 +62,9 @@ import ( type DivergentClaimSuite struct { suite.Suite LogChecker - ctx context.Context - cancel context.CancelFunc + ctx context.Context + cancel context.CancelFunc + readerFixtureOwnsRestart bool } func TestDivergentClaim(t *testing.T) { @@ -81,15 +82,12 @@ func (s *DivergentClaimSuite) SetupSuite() { } func (s *DivergentClaimSuite) TearDownSuite() { - // Phase 2 brings the node up in reader mode. Subsequent suites expect - // the default (claim-submission-enabled) configuration, so always - // recycle the node here regardless of state. - if sharedNode != nil { - s.T().Log("Stopping reader-mode node before restoring default for subsequent suites...") - stopSharedNode(s.T()) + // Reader-mode cleanup restores the node after phase 2. A failure earlier + // in phase 1 can leave it stopped before that cleanup is registered. + if sharedNode == nil && !s.readerFixtureOwnsRestart { + s.T().Log("Restarting shared node in default mode for subsequent suites...") + startSharedNode(s.T()) } - s.T().Log("Restarting shared node in default mode for subsequent suites...") - startSharedNode(s.T()) s.cancel() } @@ -163,12 +161,12 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { // Inputs 0 and 1 go through the normal flow so we can observe both the // legitimate ClaimAccepted on chain AND grab a valid machine-state proof // from the second input epoch to reuse for the attack. - inputEpochs := make([]uint64, 0, 3) //nolint:mnd - for i := 0; i < 2; i++ { //nolint:mnd + inputEpochs := make([]uint64, 0, 3) + for i := 0; i < 2; i++ { payload := fmt.Sprintf("divergent-input-%d", i) idx, _, err := sendInput(s.ctx, appAName, payload) r.NoError(err, "send input %d", i) - r.Equal(uint64(i), idx) //nolint:gosec + r.Equal(uint64(i), idx) procCtx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) input, err := waitForInputProcessed(procCtx, s.T(), appAName, idx) @@ -207,7 +205,7 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { // Send input 2 — it lands at whatever block anvil mines for the tx. idx2, block2, err := sendInput(s.ctx, appAName, "divergent-input-2") r.NoError(err, "send input 2") - r.Equal(uint64(2), idx2) //nolint:mnd,gosec + r.Equal(uint64(2), idx2) s.T().Logf(" input 2 sent at block %d", block2) // Compute the epoch input 2 landed in from its block number relative @@ -278,7 +276,7 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { s.T().Log("--- Phase 1: restart node and wait for divergence-driven DIVERGED ---") startSharedNode(s.T()) - stateCtx, stateCancel := context.WithTimeout(s.ctx, 5*time.Minute) //nolint:mnd + stateCtx, stateCancel := context.WithTimeout(s.ctx, 5*time.Minute) r.NoError(waitForApplicationStatus(stateCtx, s.T(), appAName, "DIVERGED"), "A should reach DIVERGED after observing the divergent on-chain claim") stateCancel() @@ -307,12 +305,10 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { r.NoError(disableApplication(s.ctx, appAName), "disable %s before remove", appAName) r.NoError(removeApplication(s.ctx, appAName), "remove %s", appAName) - stopSharedNode(s.T()) - // CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false brings the claimer up - // in read-only mode: it computes claims locally and runs the scan path - // but never broadcasts a submitClaim tx. The divergence-detection path - // must still fire — that is the assertion of this phase. - startSharedNodeWithEnv(s.T(), "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false") + // The fixture sets matching saved/requested reader settings while stopped. + // This tests read-only replay, not a production submission-mode transition. + s.readerFixtureOwnsRestart = true + startReaderNode(s.ctx, s.T()) appBName := uniqueAppName("divergent-b") r.NoError(registerApplication(s.ctx, appBName, appAddrStr, dappPath), @@ -321,14 +317,14 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { // B has to replay all 3 inputs locally before it reaches the epoch // where the divergent claim sits. Wait for the same DIVERGED outcome. - for i := uint64(0); i < 3; i++ { //nolint:mnd + for i := uint64(0); i < 3; i++ { procCtx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) input, err := waitForInputProcessed(procCtx, s.T(), appBName, i) cancel() r.NoError(err, "B: wait for input %d", i) r.Equal(model.InputCompletionStatus_Accepted, input.Status) } - stateCtx, stateCancel = context.WithTimeout(s.ctx, 5*time.Minute) //nolint:mnd + stateCtx, stateCancel = context.WithTimeout(s.ctx, 5*time.Minute) r.NoError(waitForApplicationStatus(stateCtx, s.T(), appBName, "DIVERGED"), "B should reach DIVERGED via the read-only scan path") stateCancel() diff --git a/test/integration/foreclose_replay_test.go b/test/integration/foreclose_replay_test.go index d82802bb9..497e88ed1 100644 --- a/test/integration/foreclose_replay_test.go +++ b/test/integration/foreclose_replay_test.go @@ -299,17 +299,7 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplayReaderMode() { r.NoError(disableApplication(s.ctx, appAName), "disable A before remove") r.NoError(removeApplication(s.ctx, appAName), "remove A") - readerMode := false - defer func() { - if readerMode { - stopSharedNode(s.T()) - startSharedNode(s.T()) - } - }() - - stopSharedNode(s.T()) - startSharedNodeWithEnv(s.T(), "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false") - readerMode = true + startReaderNode(s.ctx, s.T()) appBName := uniqueAppName("foreclose-reader-b") r.NoError(registerApplication(s.ctx, appBName, appAddr, dappPath), @@ -335,10 +325,6 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplayReaderMode() { r.NoError(err, "read B status") r.Equal("OK", firstStatusLine(status)) r.Contains(status, "Enabled: true") - - stopSharedNode(s.T()) - startSharedNode(s.T()) - readerMode = false } func (s *ForecloseReplaySuite) TestOutputExecutionAfterForeclosureReplaysOnReregisteredApp() { diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 2388eb0b5..b70e726c3 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -28,6 +28,16 @@ import ( // skipped only when an external node is already running (not test-managed). func TestMain(m *testing.M) { flag.Parse() + // Cleanup failure tests use child processes with simulated callbacks only. + // Never start or stop the shared node in those children. + if os.Getenv(observerCleanupExitEnv) != "" { + run := flag.Lookup("test.run") + if run == nil || run.Value.String() != observerCleanupTestPattern { + fmt.Fprintln(os.Stderr, "cleanup subprocess must select only the cleanup test") + os.Exit(2) + } + os.Exit(m.Run()) + } if testing.Short() { fmt.Fprintln(os.Stderr, "skipping integration tests in short mode") os.Exit(0) diff --git a/test/integration/prt_commitment_helpers_test.go b/test/integration/prt_commitment_helpers_test.go new file mode 100644 index 000000000..a5b2e54de --- /dev/null +++ b/test/integration/prt_commitment_helpers_test.go @@ -0,0 +1,91 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// sparseDisputeCommitment is a test-only tree whose leaves all contain the same +// machine hash, except for an optional different final leaf. It follows Dave's +// ConfigurableCommitmentFixture SAME and RIGHTMOST_DIFFERENT families. Machine +// hashes are leaves directly; only ordered pairs of child hashes are hashed. +// +// This proves commitment membership, not validity of a machine execution. It +// supports external dispute actors without adding node dispute automation. +// There is deliberately no endtoendtests tag: its pure tests must run without +// the integration TestMain, which starts a node and connects to the devnet. +type sparseDisputeCommitment struct { + height uint64 + repeated []common.Hash // Entry h is a repeated-state subtree of height h. + final []common.Hash // Entry h has the different state at its final leaf. +} + +// The test helper uses uint64 leaf positions. All canonical heights (48, 17, +// and 27) fit this bound. Solidity supports larger coordinates independently. +const maxSparseDisputeHeight = 63 + +func newSparseDisputeCommitment(height uint64, repeatedState, finalState common.Hash) (*sparseDisputeCommitment, error) { + if height == 0 || height > maxSparseDisputeHeight { + return nil, fmt.Errorf("sparse dispute height %d is outside 1..%d", height, maxSparseDisputeHeight) + } + tree := &sparseDisputeCommitment{height: height, + repeated: make([]common.Hash, height+1), final: make([]common.Hash, height+1)} + tree.repeated[0], tree.final[0] = repeatedState, finalState + for level := uint64(1); level <= height; level++ { + left, right := tree.repeated[level-1], tree.final[level-1] + tree.repeated[level] = crypto.Keccak256Hash(left[:], left[:]) + tree.final[level] = crypto.Keccak256Hash(left[:], right[:]) + } + return tree, nil +} + +func (tree *sparseDisputeCommitment) root() common.Hash { + return tree.final[tree.height] +} + +func (tree *sparseDisputeCommitment) leafCount() uint64 { + return uint64(1) << tree.height +} + +// rightmostChildren opens a height-h subtree on the rightmost branch. With +// repeated and final-leaf-different actors, every bisection selects this branch. +// Passing h and h-1 supplies the two adjacent openings for advanceMatch; h=1 +// supplies the leaf pair for either seal method. +func (tree *sparseDisputeCommitment) rightmostChildren(height uint64) (common.Hash, common.Hash, error) { + if height == 0 || height > tree.height { + return common.Hash{}, common.Hash{}, fmt.Errorf("cannot open subtree height %d in height-%d commitment", height, tree.height) + } + return tree.repeated[height-1], tree.final[height-1], nil +} + +// proof returns the leaf and its siblings from leaf level to root level, as +// required by Commitment.getRoot. The returned slice is owned by the caller and +// uses the generated Solidity binding's bytes32[] type. The final-leaf proof +// is used for joinTournament; the second-last proof supplies the agree state +// when sealing the final-leaf divergence. +func (tree *sparseDisputeCommitment) proof(position uint64) (common.Hash, [][32]byte, error) { + if position >= tree.leafCount() { + return common.Hash{}, nil, fmt.Errorf("leaf position %d is outside height-%d commitment", position, tree.height) + } + leaf := tree.repeated[0] + if position == tree.leafCount()-1 { + leaf = tree.final[0] + } + siblings := make([][32]byte, tree.height) + for level := uint64(0); level < tree.height; level++ { + // Subtrees are numbered from left to right at each height. Only + // the last subtree can contain the different final leaf. + siblingIndex := (position >> level) ^ 1 + lastSubtreeIndex := (uint64(1) << (tree.height - level)) - 1 + siblings[level] = tree.repeated[level] + if siblingIndex == lastSubtreeIndex { + siblings[level] = tree.final[level] + } + } + return leaf, siblings, nil +} diff --git a/test/integration/prt_commitment_test.go b/test/integration/prt_commitment_test.go new file mode 100644 index 000000000..e5c57d93e --- /dev/null +++ b/test/integration/prt_commitment_test.go @@ -0,0 +1,161 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "math/big" + "strconv" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestSparseDisputeCommitmentMatchesDenseTrees(t *testing.T) { + repeated := common.HexToHash("0x1234") + for height := uint64(1); height <= 8; height++ { + for _, final := range []common.Hash{repeated, common.HexToHash("0xabcd")} { + t.Run(strconv.FormatUint(height, 10)+"/"+final.Hex(), func(t *testing.T) { + tree, err := newSparseDisputeCommitment(height, repeated, final) + require.NoError(t, err) + // This independent reference materializes only small trees. + levels := make([][]common.Hash, height+1) + levels[0] = make([]common.Hash, tree.leafCount()) + for position := range levels[0] { + levels[0][position] = repeated + } + levels[0][len(levels[0])-1] = final + for level := uint64(1); level <= height; level++ { + previous := levels[level-1] + levels[level] = make([]common.Hash, len(previous)/2) + for index := range levels[level] { + levels[level][index] = crypto.Keccak256Hash(previous[2*index][:], previous[2*index+1][:]) + } + } + require.Equal(t, levels[height][0], tree.root()) + for position := uint64(0); position < tree.leafCount(); position++ { + leaf, siblings, err := tree.proof(position) + require.NoError(t, err) + require.Equal(t, levels[0][position], leaf) + require.Equal(t, height, uint64(len(siblings))) + for level := uint64(0); level < height; level++ { + require.Equal(t, [32]byte(levels[level][(position>>level)^1]), siblings[level]) + } + require.Equal(t, tree.root(), rebuildSparseDisputeProof(leaf, position, siblings)) + } + }) + } + } +} + +func TestSparseDisputeCommitmentCanonicalGeometry(t *testing.T) { + repeated, changed := common.HexToHash("0x1234"), common.HexToHash("0xabcd") + // Canonical ArbitrationConstants.sol heights, in root-to-leaf order. + for _, height := range []uint64{48, 17, 27} { + t.Run(strconv.FormatUint(height, 10), func(t *testing.T) { + one, err := newSparseDisputeCommitment(height, repeated, repeated) + require.NoError(t, err) + two, err := newSparseDisputeCommitment(height, repeated, changed) + require.NoError(t, err) + require.NotEqual(t, one.root(), two.root()) + for _, tree := range []*sparseDisputeCommitment{one, two} { + require.Equal(t, height+1, uint64(len(tree.repeated))) + require.Equal(t, height+1, uint64(len(tree.final)), "storage is linear in height, not leaf count") + for _, position := range []uint64{0, 1, tree.leafCount() / 2, tree.leafCount() - 2, tree.leafCount() - 1} { + leaf, siblings, err := tree.proof(position) + require.NoError(t, err) + require.Equal(t, tree.root(), rebuildSparseDisputeProof(leaf, position, siblings)) + left, right, err := tree.rightmostChildren(height) + require.NoError(t, err) + require.Equal(t, tree.root(), crypto.Keccak256Hash(left[:], right[:])) + } + leaf, proof, err := tree.proof(tree.leafCount() - 1) + require.NoError(t, err) + root := tree.root() + proof[0][0] ^= 1 + require.NotEqual(t, root, rebuildSparseDisputeProof(leaf, tree.leafCount()-1, proof)) + _, fresh, err := tree.proof(tree.leafCount() - 1) + require.NoError(t, err) + require.NotEqual(t, proof[0], fresh[0], "callers cannot mutate cached subtrees through a proof") + require.Equal(t, root, tree.root()) + } + assertSparseDisputeBisection(t, one, two) + }) + } +} + +// Follow Match.create, advanceBisection, and sealDivergence without any RPC. +// The helper supplies openings; this independent state progression checks that +// their ordering fits the protocol, including odd/even final revealer parity. +func assertSparseDisputeBisection(t *testing.T, one, two *sparseDisputeCommitment) { + t.Helper() + actors := []*sparseDisputeCommitment{one, two} + otherParent := one.root() + waitingLeft, waitingRight, err := two.rightmostChildren(two.height) + require.NoError(t, err) + var revealer, position uint64 + for height := one.height; height > 1; height-- { + left, right, err := actors[revealer].rightmostChildren(height) + require.NoError(t, err) + require.Equal(t, otherParent, crypto.Keccak256Hash(left[:], right[:])) + require.Equal(t, waitingLeft, left) + require.NotEqual(t, waitingRight, right) + nextLeft, nextRight, err := actors[revealer].rightmostChildren(height - 1) + require.NoError(t, err) + require.Equal(t, right, crypto.Keccak256Hash(nextLeft[:], nextRight[:])) + otherParent, waitingLeft, waitingRight = waitingRight, nextLeft, nextRight + position += uint64(1) << (height - 1) + revealer ^= 1 + } + left, right, err := actors[revealer].rightmostChildren(1) + require.NoError(t, err) + require.Equal(t, otherParent, crypto.Keccak256Hash(left[:], right[:])) + require.Equal(t, waitingLeft, left) + require.NotEqual(t, waitingRight, right) + position++ + require.Equal(t, one.leafCount()-1, position) + require.Equal(t, (one.height-1)%2, revealer) + agree, proof, err := actors[revealer].proof(position - 1) + require.NoError(t, err) + require.Equal(t, one.repeated[0], agree) + require.Equal(t, actors[revealer].root(), rebuildSparseDisputeProof(agree, position-1, proof)) + if revealer == 0 { + require.Equal(t, one.final[0], right) + require.Equal(t, two.final[0], waitingRight) + } else { + require.Equal(t, one.final[0], waitingRight) + require.Equal(t, two.final[0], right) + } +} + +func TestSparseDisputeCommitmentRejectsOutOfRangeRequests(t *testing.T) { + for _, height := range []uint64{0, maxSparseDisputeHeight + 1, ^uint64(0)} { + _, err := newSparseDisputeCommitment(height, common.Hash{}, common.Hash{}) + require.Error(t, err) + } + tree, err := newSparseDisputeCommitment(maxSparseDisputeHeight, common.Hash{}, common.HexToHash("0x1")) + require.NoError(t, err) + for _, height := range []uint64{0, maxSparseDisputeHeight + 1} { + _, _, err := tree.rightmostChildren(height) + require.Error(t, err) + } + for _, position := range []uint64{tree.leafCount(), ^uint64(0)} { + _, proof, err := tree.proof(position) + require.Error(t, err) + require.Nil(t, proof) + } + leaf, proof, err := tree.proof(tree.leafCount() - 1) + require.NoError(t, err) + require.Equal(t, tree.root(), rebuildSparseDisputeProof(leaf, tree.leafCount()-1, proof)) +} + +func rebuildSparseDisputeProof(leaf common.Hash, position uint64, siblings [][32]byte) common.Hash { + proof := merkle.Leaf(leaf, new(big.Int).SetUint64(position)) + for _, sibling := range siblings { + proof.PushHash(sibling) + } + return proof.BuildRoot() +} diff --git a/test/integration/prt_passive_observer_actions_test.go b/test/integration/prt_passive_observer_actions_test.go new file mode 100644 index 000000000..f7865e49c --- /dev/null +++ b/test/integration/prt_passive_observer_actions_test.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func (f *passiveDisputeFixture) joinPair(layer *passiveDisputeLayer) { + for side, tree := range layer.trees { + if side == 1 && layer.descriptor.Level == 2 { + // A late join receives less allowance. Preserve this deliberate + // gap through the leaf race, so only B expires at its deadline. + f.r.NoError(anvilMine(f.ctx, passiveLeafJoinDelay)) + } + bond, err := layer.contract.BondValue(&bind.CallOpts{Context: f.ctx}) + f.r.NoError(err) + opts := *f.actors[side] + opts.Value = bond + left, right, err := tree.rightmostChildren(tree.height) + f.r.NoError(err) + finalState, proof, err := tree.proof(tree.leafCount() - 1) + f.r.NoError(err) + tx, err := layer.contract.JoinTournament(&opts, finalState, proof, left, right) + f.record(tx, err) + } +} + +func (f *passiveDisputeFixture) bisect(layer *passiveDisputeLayer) { + var responder uint64 + for height := layer.descriptor.Height; height > 1; height-- { + tree := layer.trees[responder] + left, right, err := tree.rightmostChildren(height) + f.r.NoError(err) + nextLeft, nextRight, err := tree.rightmostChildren(height - 1) + f.r.NoError(err) + tx, err := layer.contract.AdvanceMatch(f.actors[responder], layer.matchID, left, right, nextLeft, nextRight) + f.record(tx, err) + responder ^= 1 + if height == layer.descriptor.Height { + // Check a real intermediate snapshot, not only the final state. + f.assertCurrent(layer, MatchPhaseBisecting) + } + } +} + +func (f *passiveDisputeFixture) seal(layer *passiveDisputeLayer) common.Address { + responder := (layer.descriptor.Height - 1) % 2 + tree := layer.trees[responder] + left, right, err := tree.rightmostChildren(1) + f.r.NoError(err) + agree, proof, err := tree.proof(tree.leafCount() - 2) + f.r.NoError(err) + if layer.descriptor.Level == 2 { + tx, err := layer.contract.SealLeafMatch(f.actors[responder], layer.matchID, left, right, agree, proof) + f.record(tx, err) + return common.Address{} + } + tx, err := layer.contract.SealInnerMatchAndCreateInnerTournament(f.actors[responder], layer.matchID, + left, right, agree, proof) + receipt := f.record(tx, err) + for _, raw := range receipt.Logs { + if raw.Address != layer.address { + continue + } + event, err := layer.contract.ParseNewInnerTournament(*raw) + if err == nil { + f.r.Equal(layer.matchHash, common.Hash(event.MatchIdHash)) + return event.ChildTournament + } + } + f.r.FailNow("successful inner seal must emit NewInnerTournament") + return common.Address{} +} + +func (f *passiveDisputeFixture) winLeafByTimeout() { + layer := f.layers[len(f.layers)-1] + one, err := layer.contract.CommitmentStanding(&bind.CallOpts{Context: f.ctx}, layer.trees[0].root()) + f.r.NoError(err) + two, err := layer.contract.CommitmentStanding(&bind.CallOpts{Context: f.ctx}, layer.trees[1].root()) + f.r.NoError(err) + f.r.True(one.ClockRunning) + f.r.True(two.ClockRunning) + f.r.Greater(one.ClockDeadline, two.ClockDeadline+uint64(len(f.layers)), + "the winner must retain time to propagate through both parent tournaments") + f.mineTo(two.ClockDeadline) + timeout, err := layer.contract.ClassifyMatchTimeout(&bind.CallOpts{Context: f.ctx}, layer.matchID) + f.r.NoError(err) + f.r.Equal(uint8(1), timeout.Outcome, "only actor A must win, not ELIMINATE_BOTH") + f.assertCurrent(layer, MatchPhaseSealed) + left, right, err := layer.trees[0].rightmostChildren(layer.descriptor.Height) + f.r.NoError(err) + tx, err := layer.contract.WinMatchByTimeout(f.actors[0], layer.matchID, left, right) + f.record(tx, err) + f.assertCurrent(layer, MatchPhaseUninitialized) +} + +// Receipt events cannot prove that no extra reverted/no-log transaction was +// sent. Inspect all transactions targeting this fixture's tournaments as well. +func (f *passiveDisputeFixture) assertOnlyExternalTransactions() { + expected := make(map[common.Hash]struct{}, len(f.receipts)) + for _, receipt := range f.receipts { + expected[receipt.TxHash] = struct{}{} + } + addresses := make(map[common.Address]struct{}, len(f.layers)) + for _, layer := range f.layers { + addresses[layer.address] = struct{}{} + } + start := f.receipts[0].BlockNumber.Uint64() + end, err := f.client.BlockNumber(f.ctx) + f.r.NoError(err) + for height := start; height <= end; height++ { + block, err := f.client.BlockByNumber(f.ctx, new(big.Int).SetUint64(height)) + f.r.NoError(err) + for _, tx := range block.Transactions() { + if tx.To() == nil { + continue + } + if _, ok := addresses[*tx.To()]; !ok { + continue + } + f.r.Contains(expected, tx.Hash(), "the node must not send any tournament transaction") + sender, err := types.Sender(types.LatestSignerForChainID(tx.ChainId()), tx) + f.r.NoError(err) + f.r.Contains([]common.Address{f.actors[0].From, f.actors[1].From}, sender) + } + } +} diff --git a/test/integration/prt_passive_observer_api_test.go b/test/integration/prt_passive_observer_api_test.go new file mode 100644 index 000000000..cc1e742e6 --- /dev/null +++ b/test/integration/prt_passive_observer_api_test.go @@ -0,0 +1,291 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +func (f *passiveDisputeFixture) observedTournament(layer *passiveDisputeLayer) (*Tournament, *bind.CallOpts) { + f.t.Helper() + head, err := f.client.BlockNumber(f.ctx) + f.r.NoError(err) + var result *Tournament + f.r.NoError(pollUntil(f.ctx, passiveObserverPoll, func() (bool, error) { + var response api.ListResponse[*Tournament] + err := f.rpc.Call(f.ctx, "cartesi_listTournaments", api.ListTournamentsParams{Application: f.appName}, &response) + if err != nil { + return false, err + } + for _, row := range response.Data { + if row.Address == layer.address && row.Snapshot.AsOfBlock >= head { + result = row + return true, nil + } + } + return false, nil + }), "publish the complete tournament snapshot at block %d", head) + f.r.Equal(head, result.Snapshot.AsOfBlock, "no interval block may race the API assertions") + return result, &bind.CallOpts{Context: f.ctx, BlockNumber: new(big.Int).SetUint64(head)} +} + +func (f *passiveDisputeFixture) observedMatch(layer *passiveDisputeLayer) *Match { + f.t.Helper() + var response api.SingleResponse[*Match] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getMatch", api.GetMatchParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), IDHash: layer.matchHash.Hex()}, &response)) + f.r.NotNil(response.Data) + return response.Data +} + +func (f *passiveDisputeFixture) assertCurrent(layer *passiveDisputeLayer, expected MatchPhase) { + f.t.Helper() + tournament, opts := f.observedTournament(layer) + f.r.Equal(layer.descriptor.Height, tournament.Height) + f.r.Equal(layer.descriptor.Log2Stride, tournament.Log2Step) + f.r.Equal(layer.descriptor.StartInstant, tournament.StartInstant) + f.r.Equal(layer.descriptor.Allowance, tournament.Allowance) + f.r.Equal(common.Hash(layer.descriptor.InitialHash), tournament.InitialHash) + f.r.Zero(layer.descriptor.BaseCycle.Cmp(tournament.BaseCycle.ToBig())) + if layer.descriptor.Level > 0 { + f.r.Greater(tournament.BaseCycle.ToBig().BitLen(), 64, "nested cycle positions must not truncate to uint64") + f.r.NotNil(tournament.CreationEvent) + f.r.NotNil(tournament.Snapshot.InnerResult) + } else { + f.r.Nil(tournament.CreationEvent) + f.r.Nil(tournament.Snapshot.InnerResult) + } + match := f.observedMatch(layer) + if expected == MatchPhaseUninitialized { + f.r.Positive(match.DeletionBlockNumber) + f.r.GreaterOrEqual(match.Snapshot.AsOfBlock, match.DeletionBlockNumber) + f.r.LessOrEqual(match.Snapshot.AsOfBlock, tournament.Snapshot.AsOfBlock) + } else { + f.r.Equal(tournament.Snapshot.AsOfBlock, match.Snapshot.AsOfBlock) + } + f.r.Equal(expected, match.Snapshot.Phase) + f.r.Equal(layer.trees[0].root(), match.CommitmentOne) + f.r.Equal(layer.trees[1].root(), match.CommitmentTwo) + timeout, err := layer.contract.ClassifyMatchTimeout(opts, layer.matchID) + f.r.NoError(err) + outcomes := []MatchTimeoutOutcome{MatchTimeoutNone, MatchTimeoutOneWins, MatchTimeoutTwoWins, MatchTimeoutEliminateBoth} + f.r.Less(int(timeout.Outcome), len(outcomes)) + f.r.Equal(outcomes[timeout.Outcome], match.Snapshot.TimeoutOutcome) + f.r.Equal(timeout.DeferredCharge, match.Snapshot.DeferredCharge) + switch expected { + case MatchPhaseBisecting: + view, err := layer.contract.BisectingMatch(opts, layer.matchHash) + f.r.NoError(err) + f.r.Equal(uint8(1), view.ActualPhase) + f.r.NotNil(match.Snapshot.Bisection) + bisection := match.Snapshot.Bisection + f.r.NotNil(bisection.CurrentHeight) + f.r.Equal(view.Value.CurrentHeight, *bisection.CurrentHeight) + f.assertBisection(bisection, view.Value.RevealingParent, view.Value.WaitingLeft, view.Value.WaitingRight, + view.Value.SegmentStartPosition, view.Value.SegmentStartCycle, view.Value.Responder) + f.r.Nil(match.Snapshot.Sealed) + case MatchPhaseReadyToSeal: + view, err := layer.contract.ReadyToSealMatch(opts, layer.matchHash) + f.r.NoError(err) + f.r.Equal(uint8(2), view.ActualPhase) + f.r.NotNil(match.Snapshot.Bisection) + f.r.Nil(match.Snapshot.Bisection.CurrentHeight) + f.assertBisection(match.Snapshot.Bisection, view.Value.RevealingParent, view.Value.WaitingLeft, view.Value.WaitingRight, + view.Value.SegmentStartPosition, view.Value.SegmentStartCycle, view.Value.Responder) + f.r.Nil(match.Snapshot.Sealed) + case MatchPhaseSealed: + view, err := layer.contract.SealedMatch(opts, layer.matchHash) + f.r.NoError(err) + f.r.Equal(uint8(3), view.ActualPhase) + f.r.Nil(match.Snapshot.Bisection) + f.r.NotNil(match.Snapshot.Sealed) + sealed := match.Snapshot.Sealed + f.r.Equal(common.Hash(view.Value.AgreeState), sealed.AgreeState) + f.r.Equal(*f.epoch.MachineHash, sealed.AgreeState) + f.r.Zero(view.Value.DivergencePosition.Cmp(sealed.DivergencePosition.ToBig())) + f.r.Zero(view.Value.DivergenceCycle.Cmp(sealed.DivergenceCycle.ToBig())) + f.r.Equal(layer.trees[0].leafCount()-1, sealed.DivergencePosition.ToBig().Uint64()) + f.r.Greater(sealed.DivergenceCycle.ToBig().BitLen(), 64) + f.r.Equal(common.Hash(view.Value.FinalStateOne), sealed.FinalStateOne) + f.r.Equal(common.Hash(view.Value.FinalStateTwo), sealed.FinalStateTwo) + if layer.descriptor.Level == 2 { + f.r.NotNil(match.LeafSeal) + } else { + f.r.Nil(match.LeafSeal) + } + case MatchPhaseUninitialized: + f.r.Nil(match.Snapshot.Bisection) + f.r.Nil(match.Snapshot.Sealed) + f.r.NotNil(match.DeletionTxHash) + f.r.NotNil(match.DeletionLogIndex) + f.r.Equal(WinnerCommitment_ONE, match.Winner) + if layer.descriptor.Level == 2 { + f.r.Equal(MatchDeletionReason_TIMEOUT, match.DeletionReason) + f.r.NotNil(match.LeafSeal, "deletion must preserve the immutable leaf seal") + } else { + f.r.Equal(MatchDeletionReason_CHILD_TOURNAMENT, match.DeletionReason) + } + default: + f.r.FailNow("unexpected test phase", "%s", expected) + } + f.assertCommitmentClocks(layer, opts) +} + +func (f *passiveDisputeFixture) assertBisection( + observed *MatchBisectionSnapshot, parent, left, right [32]byte, position, cycle *big.Int, responder uint8, +) { + f.r.Equal(common.Hash(parent), observed.RevealingParent) + f.r.Equal(common.Hash(left), observed.WaitingLeft) + f.r.Equal(common.Hash(right), observed.WaitingRight) + f.r.Zero(position.Cmp(observed.SegmentStartPosition.ToBig())) + f.r.Zero(cycle.Cmp(observed.SegmentStartCycle.ToBig())) + sides := []CommitmentSide{CommitmentSideOne, CommitmentSideTwo} + f.r.Less(int(responder), len(sides)) + f.r.Equal(sides[responder], observed.Responder) +} + +func (f *passiveDisputeFixture) assertCommitmentClocks(layer *passiveDisputeLayer, opts *bind.CallOpts) { + for side, tree := range layer.trees { + var response api.SingleResponse[*Commitment] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getCommitment", api.GetCommitmentParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), Commitment: tree.root().Hex()}, &response)) + f.r.NotNil(response.Data) + commitment := response.Data + view, err := layer.contract.CommitmentStanding(opts, tree.root()) + f.r.NoError(err) + f.r.True(view.Joined) + f.r.Equal(f.actors[side].From, commitment.SubmitterAddress, "the original participant must remain visible") + f.r.Equal(common.Hash(view.FinalState), commitment.FinalStateHash) + f.r.Equal(view.Claimer, commitment.Snapshot.Claimer) + f.r.Equal(view.ClockRunning, commitment.Snapshot.ClockRunning) + f.r.Equal(view.ClockDeadline, commitment.Snapshot.ClockDeadline) + f.r.Equal(view.ClockAllowance, commitment.Snapshot.ClockAllowance) + f.r.Equal(opts.BlockNumber.Uint64(), commitment.Snapshot.AsOfBlock) + } +} + +func (f *passiveDisputeFixture) assertWinner(layer *passiveDisputeLayer, expected TournamentStandingState) { + tournament, opts := f.observedTournament(layer) + view, err := layer.contract.TournamentStanding(opts) + f.r.NoError(err) + f.r.Equal(expected, tournament.Snapshot.Standing) + f.r.NotNil(tournament.Snapshot.WinnerCommitment) + f.r.Equal(layer.trees[0].root(), *tournament.Snapshot.WinnerCommitment) + f.r.Equal(tournament.Snapshot.WinnerCommitment, tournament.Snapshot.Candidate) + f.r.NotNil(tournament.Snapshot.FinalStateHash) + f.r.Equal(layer.trees[0].final[0], *tournament.Snapshot.FinalStateHash) + f.r.Equal(view.FinishedAt, tournament.Snapshot.FinishedAtBlock) + f.r.Positive(view.FinishedAt) + f.r.Equal(view.WinnerExpiresAt, tournament.Snapshot.WinnerExpiresAt) + f.r.False(tournament.Snapshot.AcceptsJoins) + if layer.descriptor.Level > 0 { + inner, err := layer.contract.InnerResult(opts) + f.r.NoError(err) + f.r.NotNil(tournament.Snapshot.InnerResult) + f.r.Equal(InnerTournamentWinner, tournament.Snapshot.InnerResult.Disposition) + f.r.NotNil(tournament.Snapshot.InnerResult.ParentCommitment) + f.r.Equal(common.Hash(inner.ParentCommitment), *tournament.Snapshot.InnerResult.ParentCommitment) + f.r.Equal(inner.PausedAllowance, tournament.Snapshot.InnerResult.PausedAllowance) + f.r.Greater(view.WinnerExpiresAt, opts.BlockNumber.Uint64()+uint64(layer.descriptor.Level)) + } +} + +func (f *passiveDisputeFixture) assertRecoveredRoot() { + root := f.layers[0] + tournament, opts := f.observedTournament(root) + f.r.Equal(TournamentStandingRootWinner, tournament.Snapshot.Standing) + f.r.Equal(BondDispositionRecovered, tournament.Snapshot.BondRecovery.Disposition) + f.r.Nil(tournament.Snapshot.BondRecovery.Claimer) + f.r.Nil(tournament.Snapshot.BondRecovery.Payment) + f.assertCommitmentClocks(root, opts) + var response api.SingleResponse[*Commitment] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getCommitment", api.GetCommitmentParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: root.address.Hex(), Commitment: root.trees[0].root().Hex()}, &response)) + f.r.Equal(common.Address{}, response.Data.Snapshot.Claimer) + f.r.Equal(f.actors[0].From, response.Data.SubmitterAddress) +} + +func (f *passiveDisputeFixture) assertInnerWinnersExpire() { + var expiry uint64 + deletedMatches := make(map[common.Address]*Match, len(f.layers)-1) + for _, layer := range f.layers[1:] { + standing, err := layer.contract.TournamentStanding(&bind.CallOpts{Context: f.ctx}) + f.r.NoError(err) + f.r.Positive(standing.WinnerExpiresAt, "retain the winner long enough to propagate and accept") + expiry = max(expiry, standing.WinnerExpiresAt) + deletedMatches[layer.address] = f.observedMatch(layer) + } + // No transaction emits a log for this transition. The observer must still + // refresh current views and remove winner fields which no longer apply. + f.mineTo(expiry) + for _, layer := range f.layers[1:] { + tournament, _ := f.observedTournament(layer) + snapshot := tournament.Snapshot + deleted := deletedMatches[layer.address] + f.r.Greater(snapshot.AsOfBlock, deleted.Snapshot.AsOfBlock, "observe a later window after match deletion") + f.r.Equal(deleted, f.observedMatch(layer), "keep the certified snapshot, deletion facts, and row timestamps") + f.r.Equal(TournamentStandingInnerEliminableWinnerExpired, snapshot.Standing) + f.r.NotNil(snapshot.Candidate) + f.r.Equal(layer.trees[0].root(), *snapshot.Candidate) + f.r.Nil(snapshot.WinnerCommitment) + f.r.Nil(snapshot.FinalStateHash) + f.r.Nil(snapshot.ParentCommitment) + f.r.Zero(snapshot.WinnerExpiresAt) + f.r.Positive(snapshot.FinishedAtBlock) + f.r.NotNil(snapshot.InnerResult) + f.r.Equal(InnerTournamentEliminable, snapshot.InnerResult.Disposition) + f.r.Nil(snapshot.InnerResult.ParentCommitment) + f.r.Zero(snapshot.InnerResult.PausedAllowance) + f.r.Equal(BondDispositionRecoverable, snapshot.BondRecovery.Disposition, + "winner expiry must not hide the unrecovered bond") + } +} + +func (f *passiveDisputeFixture) assertLocalDivergence() { + var response api.SingleResponse[*Application] + f.r.NoError(pollUntil(f.ctx, passiveObserverPoll, func() (bool, error) { + if err := f.rpc.Call(f.ctx, "cartesi_getApplication", api.GetApplicationParams{Application: f.appName}, &response); err != nil { + return false, err + } + return response.Data != nil && response.Data.Status == ApplicationStatus_Diverged, nil + }), "mark a losing local commitment as diverged after publishing the winner") + f.r.True(response.Data.Enabled, "passive observation must continue while the application is diverged") + f.r.NotNil(response.Data.Reason) + f.r.Contains(*response.Data.Reason, "inconsistent commitment") + epoch, err := readEpoch(f.ctx, f.appName, 0) + f.r.NoError(err) + f.r.Equal(EpochStatus_ClaimComputed, epoch.Status) + f.r.Nil(epoch.ClaimTransactionHash) + f.r.Nil(epoch.StagedAtBlock, "the incorrect winner must never use a fabricated machine-state proof") + f.r.Equal(f.epoch.Commitment, epoch.Commitment) +} + +func (f *passiveDisputeFixture) listAdvances(layer *passiveDisputeLayer) []*MatchAdvanced { + var response api.ListResponse[*MatchAdvanced] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_listMatchAdvances", api.ListMatchAdvancesParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), IDHash: layer.matchHash.Hex(), Limit: 100}, &response)) + f.r.Equal(layer.descriptor.Height-1, uint64(len(response.Data))) + f.r.Equal(layer.descriptor.Height-1, response.Pagination.TotalCount) + for _, event := range response.Data { + var single api.SingleResponse[*MatchAdvanced] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getMatchAdvance", api.GetMatchAdvanceParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), IDHash: layer.matchHash.Hex(), + TxHash: event.TxHash.Hex(), LogIndex: hexutil.EncodeUint64(event.LogIndex)}, &single)) + f.r.Equal(event, single.Data, "get and list must expose identical advance content") + } + return response.Data +} + +func passiveEventIdentity(hash common.Hash, index uint64) string { + return fmt.Sprintf("%s/%d", hash, index) +} diff --git a/test/integration/prt_passive_observer_config_test.go b/test/integration/prt_passive_observer_config_test.go new file mode 100644 index 000000000..fbc832468 --- /dev/null +++ b/test/integration/prt_passive_observer_config_test.go @@ -0,0 +1,142 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + passiveClaimSubmissionField = "ClaimSubmissionEnabled" + passiveDefaultBlockField = "DefaultBlock" + + observerCleanupExitEnv = "CARTESI_TEST_PASSIVE_OBSERVER_CLEANUP_EXIT_AT" + observerCleanupTestPattern = "^TestPassiveObserverCleanupPreservesRestoreOrder$" +) + +// This test-only codec has no integration build tag. Its pure tests must run +// without TestMain starting a node or connecting to the development database. +func patchPassiveObserverConfig(raw []byte, overrides map[string]any) ([]byte, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, fmt.Errorf("decode passive observer config: %w", err) + } + for key, value := range overrides { + if _, exists := fields[key]; !exists { + return nil, fmt.Errorf("passive observer config is missing field %s", key) + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode passive observer config field %s: %w", key, err) + } + fields[key] = encoded + } + return json.Marshal(fields) +} + +func registerPassiveObserverCleanup(t *testing.T, stop, restore, restart func()) { + t.Helper() + t.Cleanup(func() { + restore() + restart() + }) + // Cleanup runs in reverse registration order. A fatal shutdown assertion + // must not skip restoration, so shutdown needs its own cleanup callback. + t.Cleanup(stop) +} + +func TestPassiveObserverCleanupPreservesRestoreOrder(t *testing.T) { + const ( + noFailure = "none" + setupStep = "setup" + stopStep = "stop" + restoreStep = "restore" + restartStep = "restart" + closeStep = "close repository" + ) + if exitAt := os.Getenv(observerCleanupExitEnv); exitAt != "" { + require.Contains(t, []string{noFailure, setupStep, stopStep, restoreStep}, exitAt) + var calls []string + // Report the sequence after all cleanup callbacks, including after Fatal. + t.Cleanup(func() { t.Logf("cleanup calls: %q", calls) }) + t.Cleanup(func() { calls = append(calls, closeStep) }) + step := func(name string) func() { + return func() { + calls = append(calls, name) + if name == exitAt { + t.Fatalf("injected cleanup failure at %s", name) + } + } + } + registerPassiveObserverCleanup(t, step(stopStep), step(restoreStep), step(restartStep)) + if exitAt == setupStep { + // An initial stop can fail before the reader starts. Cleanup must + // still restore the default fixture. + t.Fatalf("injected cleanup failure at %s", setupStep) + } + return + } + + executable, err := os.Executable() + require.NoError(t, err) + for _, exitAt := range []string{noFailure, setupStep, stopStep, restoreStep} { + t.Run(exitAt, func(t *testing.T) { + // Use a child test process so a real Fatal does not fail the parent. + // TestMain bypasses node startup for this exact child test selection. + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "-test.run="+observerCleanupTestPattern, "-test.v") + cmd.Env = append(os.Environ(), observerCleanupExitEnv+"="+exitAt) + output, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "cleanup child did not finish: %s", output) + if exitAt == noFailure { + require.NoError(t, err, "%s", output) + } else { + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr, "%s", output) + require.Equal(t, 1, exitErr.ExitCode(), "%s", output) + require.Contains(t, string(output), "injected cleanup failure at "+exitAt) + } + want := []string{stopStep, restoreStep, restartStep, closeStep} + if exitAt == restoreStep { + want = []string{stopStep, restoreStep, closeStep} + } + require.Contains(t, string(output), fmt.Sprintf("cleanup calls: %q\n", want)) + require.NotContains(t, string(output), "--- SKIP:") + }) + } +} + +func TestPassiveObserverConfigPreservesOriginalAndUnrelatedFields(t *testing.T) { + raw := []byte(`{"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true,` + + `"ChainID":18446744073709551615,"Other":{"value":18446744073709551616}}`) + original := bytes.Clone(raw) + updated, err := patchPassiveObserverConfig(raw, map[string]any{passiveDefaultBlockField: "LATEST", passiveClaimSubmissionField: false}) + require.NoError(t, err) + require.Equal(t, original, raw, "cleanup must retain the original payload unchanged") + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(updated, &fields)) + require.Equal(t, `"LATEST"`, string(fields[passiveDefaultBlockField])) + require.Equal(t, "false", string(fields[passiveClaimSubmissionField])) + require.Equal(t, "18446744073709551615", string(fields["ChainID"])) + require.Equal(t, `{"value":18446744073709551616}`, string(fields["Other"])) +} + +func TestPassiveObserverConfigRejectsMissingFieldsAndInvalidJSON(t *testing.T) { + for _, raw := range []string{"null", "[]", "{", `{"ChainID":31337}`} { + t.Run(raw, func(t *testing.T) { + _, err := patchPassiveObserverConfig([]byte(raw), map[string]any{passiveDefaultBlockField: "LATEST"}) + require.Error(t, err) + }) + } +} diff --git a/test/integration/prt_passive_observer_events_test.go b/test/integration/prt_passive_observer_events_test.go new file mode 100644 index 000000000..dbec1289e --- /dev/null +++ b/test/integration/prt_passive_observer_events_test.go @@ -0,0 +1,171 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" +) + +const passiveRefundEvent = "PartialBondRefund" + +func (f *passiveDisputeFixture) assertAllEvents() { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + f.r.NoError(err) + seen := make(map[string]int) + for _, layer := range f.layers { + advances := f.listAdvances(layer) + advanceByLog := make(map[string]*MatchAdvanced, len(advances)) + for _, event := range advances { + advanceByLog[passiveEventIdentity(event.TxHash, event.LogIndex)] = event + } + tournamentAddress := layer.address.Hex() + var bonds api.ListResponse[*BondEvent] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_listBondEvents", api.ListBondEventsParams{Application: f.appName, + TournamentAddress: &tournamentAddress, Limit: 100}, &bonds)) + bondByLog := make(map[string]*BondEvent, len(bonds.Data)) + for _, event := range bonds.Data { + bondByLog[passiveEventIdentity(event.TxHash, event.LogIndex)] = event + var single api.SingleResponse[*BondEvent] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getBondEvent", api.GetBondEventParams{Application: f.appName, + TxHash: event.TxHash.Hex(), LogIndex: hexutil.EncodeUint64(event.LogIndex)}, &single)) + f.r.Equal(event, single.Data) + } + match := f.observedMatch(layer) + for _, receipt := range f.receipts { + for _, raw := range receipt.Logs { + if raw.Address != layer.address { + continue + } + f.r.NotEmpty(raw.Topics) + definition, err := contractABI.EventByID(raw.Topics[0]) + f.r.NoError(err) + seen[definition.Name]++ + switch definition.Name { + case "CommitmentJoined": + f.assertJoinedEvent(layer, raw) + case "MatchCreated": + event, err := layer.contract.ParseMatchCreated(*raw) + f.r.NoError(err) + f.r.Equal(common.Hash(event.MatchIdHash), match.IDHash) + f.r.Equal(common.Hash(event.One), match.CommitmentOne) + f.r.Equal(common.Hash(event.Two), match.CommitmentTwo) + f.r.Equal(common.Hash(event.LeftOfTwo), match.LeftOfTwo) + f.r.Equal(event.EliminableAt, match.EliminableAt) + f.assertLogIdentity(raw, match.BlockNumber, match.TxHash, match.LogIndex) + case "MatchAdvanced": + event, err := layer.contract.ParseMatchAdvanced(*raw) + f.r.NoError(err) + key := passiveEventIdentity(raw.TxHash, uint64(raw.Index)) + row := advanceByLog[key] + f.r.NotNil(row) + f.r.Equal(common.Hash(event.MatchIdHash), row.IDHash) + f.r.Equal(common.Hash(event.OtherParent), row.OtherParent) + f.r.Equal(common.Hash(event.LeftNode), row.LeftNode) + f.r.Zero(event.SegmentStartPosition.Cmp(row.SegmentStartPosition.ToBig())) + f.r.Equal(event.EliminableAt, row.EliminableAt) + f.assertLogIdentity(raw, row.BlockNumber, row.TxHash, row.LogIndex) + delete(advanceByLog, key) + case "LeafMatchSealed": + event, err := layer.contract.ParseLeafMatchSealed(*raw) + f.r.NoError(err) + f.r.NotNil(match.LeafSeal) + f.r.Equal(event.EliminableAt, match.LeafSeal.EliminableAt) + f.assertLogIdentity(raw, match.LeafSeal.BlockNumber, match.LeafSeal.TxHash, match.LeafSeal.LogIndex) + case "MatchDeleted": + event, err := layer.contract.ParseMatchDeleted(*raw) + f.r.NoError(err) + f.r.Equal(common.Hash(event.MatchIdHash), match.IDHash) + f.r.Equal(uint8(1), event.WinnerCommitment) + f.r.Equal(WinnerCommitment_ONE, match.Winner) + f.r.NotNil(match.DeletionTxHash) + f.r.NotNil(match.DeletionLogIndex) + f.assertLogIdentity(raw, match.DeletionBlockNumber, *match.DeletionTxHash, *match.DeletionLogIndex) + case "NewInnerTournament": + f.assertChildCreationEvent(layer, raw) + case passiveRefundEvent, "BondRecovered": + key := passiveEventIdentity(raw.TxHash, uint64(raw.Index)) + f.assertBondEvent(layer, definition.Name, raw, bondByLog[key]) + delete(bondByLog, key) + default: + f.r.FailNow("unexpected official tournament event", "%s", definition.Name) + } + } + } + f.r.Empty(advanceByLog, "every API advance must come from a receipt log") + f.r.Empty(bondByLog, "every API bond event must come from a receipt log") + } + f.r.Equal(map[string]int{ + "CommitmentJoined": 6, "MatchCreated": 3, "MatchAdvanced": 89, "LeafMatchSealed": 1, + "MatchDeleted": 3, "NewInnerTournament": 2, passiveRefundEvent: 95, "BondRecovered": 1, + }, seen, "the live fixture must exercise all eight public tournament event types") +} + +func (f *passiveDisputeFixture) assertLogIdentity(raw *types.Log, block uint64, hash common.Hash, index uint64) { + f.t.Helper() + f.r.Equal(raw.BlockNumber, block) + f.r.Equal(raw.TxHash, hash) + f.r.Equal(uint64(raw.Index), index) +} + +func (f *passiveDisputeFixture) assertJoinedEvent(layer *passiveDisputeLayer, raw *types.Log) { + event, err := layer.contract.ParseCommitmentJoined(*raw) + f.r.NoError(err) + var response api.SingleResponse[*Commitment] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getCommitment", api.GetCommitmentParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), Commitment: common.Hash(event.Commitment).Hex()}, &response)) + f.r.NotNil(response.Data) + f.r.Equal(common.Hash(event.FinalStateHash), response.Data.FinalStateHash) + f.r.Equal(event.Submitter, response.Data.SubmitterAddress) + f.assertLogIdentity(raw, response.Data.BlockNumber, response.Data.TxHash, response.Data.LogIndex) +} + +func (f *passiveDisputeFixture) assertChildCreationEvent(layer *passiveDisputeLayer, raw *types.Log) { + event, err := layer.contract.ParseNewInnerTournament(*raw) + f.r.NoError(err) + var response api.SingleResponse[*Tournament] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getTournament", api.GetTournamentParams{Application: f.appName, + Address: event.ChildTournament.Hex()}, &response)) + f.r.NotNil(response.Data) + child := response.Data + f.r.NotNil(child.CreationEvent) + f.r.Equal(&layer.address, child.ParentTournamentAddress) + f.r.NotNil(child.ParentMatchIDHash) + f.r.Equal(common.Hash(event.MatchIdHash), *child.ParentMatchIDHash) + f.assertLogIdentity(raw, child.CreationEvent.BlockNumber, child.CreationEvent.TxHash, child.CreationEvent.LogIndex) +} + +func (f *passiveDisputeFixture) assertBondEvent(layer *passiveDisputeLayer, name string, raw *types.Log, row *BondEvent) { + f.r.NotNil(row) + f.r.Equal(layer.address, row.TournamentAddress) + f.r.Zero(row.EpochIndex) + f.assertLogIdentity(raw, row.BlockNumber, row.TxHash, row.LogIndex) + if name == passiveRefundEvent { + event, err := layer.contract.ParsePartialBondRefund(*raw) + f.r.NoError(err) + f.r.Equal(BondEventPartialRefund, row.Type) + f.r.NotNil(row.Refund) + f.r.Nil(row.Recovery) + f.r.Equal(event.Recipient, row.Refund.Recipient) + f.r.Equal(event.Success, row.Refund.Success) + f.r.Zero(event.Value.Cmp(row.Refund.Value.ToBig())) + } else { + event, err := layer.contract.ParseBondRecovered(*raw) + f.r.NoError(err) + f.r.Equal(BondEventRecovered, row.Type) + f.r.Nil(row.Refund) + f.r.NotNil(row.Recovery) + f.r.Equal(common.Hash(event.Commitment), row.Recovery.Commitment) + f.r.Equal(event.Claimer, row.Recovery.Claimer) + f.r.Equal(f.actors[0].From, row.Recovery.Claimer) + f.r.Zero(event.Payment.Cmp(row.Recovery.Payment.ToBig())) + f.r.Zero(event.Burned.Cmp(row.Recovery.Burned.ToBig())) + } +} diff --git a/test/integration/prt_passive_observer_test.go b/test/integration/prt_passive_observer_test.go new file mode 100644 index 000000000..68473d580 --- /dev/null +++ b/test/integration/prt_passive_observer_test.go @@ -0,0 +1,311 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "fmt" + "math/big" + "regexp" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + jsonrpcclient "github.com/cartesi/rollups-node/pkg/jsonrpc/client" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +const ( + passiveObserverTimeout = 10 * time.Minute + passiveObserverPoll = 250 * time.Millisecond + passiveActorOneIndex = 12 + passiveActorTwoIndex = 13 + passiveLeafJoinDelay = 64 +) + +// TestPrtPassiveDisputeObserver uses external actors, not node dispute logic. +// One actor submits exactly the node's empty-epoch commitment; the other changes +// only its final leaf. Both use valid membership proofs through the official +// three levels. Actor A wins by timeout; no VM step witness is fabricated. +// The node must expose all phases, both when its local commitment wins and when +// it loses. Only the matching winner is staged with a real machine-state proof. +func TestPrtPassiveDisputeObserver(t *testing.T) { + if !isNodeSelfManaged() { + t.Skip("passive dispute fixture requires a test-managed node to disable claim submission") + } + for _, scenario := range []struct { + name string + localWins bool + }{ + {name: "matching_external_winner", localWins: true}, + {name: "losing_local_commitment", localWins: false}, + } { + t.Run(scenario.name, func(t *testing.T) { runPassiveDisputeObserver(t, scenario.localWins) }) + } +} + +func runPassiveDisputeObserver(t *testing.T, localWins bool) { + ctx, cancel := context.WithTimeout(context.Background(), passiveObserverTimeout) + defer cancel() + client := newIntegrationEthClient(ctx, t) + defer client.Close() + startReaderNode(ctx, t) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) + defer cleanupCancel() + require.NoError(t, anvilRPC(cleanupCtx, "evm_setAutomine", true)) + require.NoError(t, anvilRPC(cleanupCtx, "evm_setIntervalMining", 1)) + }) + setAnvilIntervalMining(ctx, t, 0) + setAnvilAutomine(ctx, t, true) + var logs LogChecker + logs.StartLogCapture() + logs.SetExpectedLogs(t, prtBlockOutOfRangeAllowlist) + t.Cleanup(func() { logs.CheckLogs(t) }) + fixture := newPassiveDisputeFixture(ctx, t, client, localWins) + if !localWins { + reason := regexp.QuoteMeta(fmt.Sprintf("Epoch 0 has inconsistent commitment between off-chain (%s) and on-chain (%s)", + *fixture.epoch.Commitment, fixture.layers[0].trees[0].root())) + logs.SetExpectedLogs(t, + prtBlockOutOfRangeAllowlist, + ExpectedLog{ + Pattern: regexp.MustCompile("marking application as diverged.*application=" + regexp.QuoteMeta(fixture.appName) + + ".*" + reason), + Level: LevelError, Required: true, + Reason: "the external timeout winner differs from this application's locally computed commitment", + }, + ExpectedLog{ + Pattern: regexp.MustCompile("Tick.*service=prt.*validating PRT application " + + regexp.QuoteMeta(fixture.appAddress.Hex()) + ": " + reason), + Level: LevelError, Required: true, + Reason: "the PRT service reports this application's expected divergence transition to the service loop", + }, + ) + } + fixture.run() +} + +type passiveDisputeFixture struct { + t *testing.T + r *require.Assertions + ctx context.Context + client *ethclient.Client + rpc *jsonrpcclient.Client + appName string + appAddress common.Address + localWins bool + epoch *Epoch + consensus *idaveconsensus.IDaveConsensus + actors [2]*bind.TransactOpts + layers []*passiveDisputeLayer + receipts []*types.Receipt +} + +type passiveDisputeLayer struct { + address common.Address + contract *itournament.ITournament + descriptor itournament.ITournamentTournamentDescriptor + trees [2]*sparseDisputeCommitment + matchID itournament.MatchId + matchHash common.Hash +} + +func newPassiveDisputeFixture(ctx context.Context, t *testing.T, client *ethclient.Client, localWins bool) *passiveDisputeFixture { + t.Helper() + f := &passiveDisputeFixture{t: t, r: require.New(t), ctx: ctx, client: client, + rpc: jsonrpcclient.NewClient(envOrDefault("CARTESI_JSONRPC_API_URL", "http://localhost:10011/rpc")), + appName: uniqueAppName("prt-passive-dispute"), localWins: localWins} + f.actors[0] = transactorForMnemonicIndex(ctx, t, client, passiveActorOneIndex) + f.actors[1] = transactorForMnemonicIndex(ctx, t, client, passiveActorTwoIndex) + for _, actor := range f.actors { + f.r.NoError(anvilSetBalance(ctx, actor.From.Hex(), "0x3635c9adc5dea00000"), "fund external dispute actor") + } + _, err := deployApplication(ctx, f.appName, envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp"), + prtFlag, claimStagingPeriodFlag, "5", "--salt", uniqueSalt()) + f.r.NoError(err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) + defer cleanupCancel() + f.r.NoError(disableApplication(cleanupCtx, f.appName)) + }) + _, err = waitForEpochStatus(ctx, t, f.appName, 0, EpochStatus_ClaimComputed) + f.r.NoError(err, "compute the empty epoch without node submission") + dsn, err := config.GetDatabaseConnection() + f.r.NoError(err) + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + f.r.NoError(err) + defer repo.Close() + f.epoch, err = repo.GetEpoch(ctx, f.appName, 0) + f.r.NoError(err) + f.r.NotNil(f.epoch) + f.r.NotNil(f.epoch.MachineHash) + f.r.NotNil(f.epoch.Commitment) + f.r.NotNil(f.epoch.TournamentAddress) + f.r.Equal(f.epoch.InputIndexLowerBound, f.epoch.InputIndexUpperBound, "epoch 0 must be empty") + app, err := repo.GetApplication(ctx, f.appName) + f.r.NoError(err) + f.r.NotNil(app) + f.appAddress = app.IApplicationAddress + f.consensus, err = idaveconsensus.NewIDaveConsensus(app.IConsensusAddress, client) + f.r.NoError(err) + f.newLayer(*f.epoch.TournamentAddress) + localActor := 0 + if !localWins { + localActor = 1 + } + f.r.Equal(*f.epoch.Commitment, f.layers[0].trees[localActor].root(), "one actor must submit the node's exact commitment") + f.r.Equal(*f.epoch.MachineHash, common.Hash(f.layers[0].descriptor.InitialHash), "empty epoch state must remain unchanged") + return f +} + +func (f *passiveDisputeFixture) run() { + for level := 0; level < 3; level++ { + layer := f.layers[level] + f.joinPair(layer) + f.assertCurrent(layer, MatchPhaseBisecting) + f.bisect(layer) + f.assertCurrent(layer, MatchPhaseReadyToSeal) + child := f.seal(layer) + f.assertCurrent(layer, MatchPhaseSealed) + if child != (common.Address{}) { + f.newLayer(child) + } + } + f.winLeafByTimeout() + for level := len(f.layers) - 2; level >= 0; level-- { + parent, child := f.layers[level], f.layers[level+1] + f.assertWinner(child, TournamentStandingInnerWinner) + left, right, err := parent.trees[0].rightmostChildren(parent.descriptor.Height) + f.r.NoError(err) + tx, err := parent.contract.WinInnerTournament(f.actors[0], child.address, left, right) + f.record(tx, err) + f.assertCurrent(parent, MatchPhaseUninitialized) + } + f.assertWinner(f.layers[0], TournamentStandingRootWinner) + if f.localWins { + f.stageAndAccept() + } else { + f.assertLocalDivergence() + } + // The completed root remains observable after acceptance or divergence. + tx, err := f.layers[0].contract.TryRecoveringBond(f.actors[0]) + f.record(tx, err) + f.assertRecoveredRoot() + // The root can now retire. Its children must still receive no-event + // snapshot updates when their winner carryover windows expire. + f.assertInnerWinnersExpire() + f.assertAllEvents() + f.assertOnlyExternalTransactions() + if !f.localWins { + f.assertLocalDivergence() + } +} + +func (f *passiveDisputeFixture) newLayer(address common.Address) { + contract, err := itournament.NewITournament(address, f.client) + f.r.NoError(err) + descriptor, err := contract.TournamentDescriptor(&bind.CallOpts{Context: f.ctx}) + f.r.NoError(err) + heights := []uint64{48, 17, 27} + strides := []uint64{44, 27, 0} + level := len(f.layers) + f.r.Less(level, len(heights)) + f.r.Equal(uint64(level), descriptor.Level) + f.r.Equal(heights[level], descriptor.Height) + f.r.Equal(strides[level], descriptor.Log2Stride) + f.r.Greater(descriptor.Allowance, descriptor.Height+passiveLeafJoinDelay, + "official allowance must leave time for the complete fixture") + state := *f.epoch.MachineHash + falseState := crypto.Keccak256Hash(state[:], []byte("external incorrect final state")) + correct, err := newSparseDisputeCommitment(descriptor.Height, state, state) + f.r.NoError(err) + incorrect, err := newSparseDisputeCommitment(descriptor.Height, state, falseState) + f.r.NoError(err) + oneTree, twoTree := correct, incorrect + if !f.localWins { + oneTree, twoTree = incorrect, correct + } + one, two := oneTree.root(), twoTree.root() + f.layers = append(f.layers, &passiveDisputeLayer{address: address, contract: contract, descriptor: descriptor, + trees: [2]*sparseDisputeCommitment{oneTree, twoTree}, + matchID: itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}, matchHash: crypto.Keccak256Hash(one[:], two[:])}) +} + +func (f *passiveDisputeFixture) record(tx *types.Transaction, err error) *types.Receipt { + f.t.Helper() + f.r.NoError(err, "send external participant transaction") + f.r.NotNil(tx) + ctx, cancel := context.WithTimeout(f.ctx, time.Minute) + defer cancel() + receipt := waitReceipt(ctx, f.t, f.client, tx) + f.r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + f.receipts = append(f.receipts, receipt) + return receipt +} + +func (f *passiveDisputeFixture) mineTo(target uint64) { + f.t.Helper() + head, err := f.client.BlockNumber(f.ctx) + f.r.NoError(err) + if target <= head { + return + } + f.r.LessOrEqual(target-head, uint64(maxBlocksToMine)) + f.r.NoError(anvilRPC(f.ctx, "anvil_mine", hexutil.EncodeUint64(target-head)), "mine a bounded timeout or staging interval") +} + +func (f *passiveDisputeFixture) stageAndAccept() { + epoch := f.epoch + f.r.NotNil(epoch.IflagsYDataBlock) + f.r.NotNil(epoch.HtifTohostDataBlock) + f.r.NotNil(epoch.TxBufferDataBlock) + leaf := func(data common.Hash, siblings []common.Hash) idaveconsensus.LeafProof { + proof := make([][32]byte, len(siblings)) + for i, sibling := range siblings { + proof[i] = sibling + } + return idaveconsensus.LeafProof{DataBlock: data, Siblings: proof} + } + proof := idaveconsensus.MachineValidityProof{ + IflagsYProof: leaf(*epoch.IflagsYDataBlock, epoch.IflagsYProof), + HtifTohostProof: leaf(*epoch.HtifTohostDataBlock, epoch.HtifTohostProof), + TxBufferProof: leaf(*epoch.TxBufferDataBlock, epoch.TxBufferProof), + } + tx, err := f.consensus.StageTournamentResult(f.actors[0], new(big.Int), proof) + stagedReceipt := f.record(tx, err) + staged, err := waitForEpochStatus(f.ctx, f.t, f.appName, 0, EpochStatus_ClaimStaged) + f.r.NoError(err) + f.r.NotNil(staged.StagedAtBlock) + f.r.Equal(stagedReceipt.BlockNumber.Uint64(), *staged.StagedAtBlock) + period, err := f.consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: f.ctx}) + f.r.NoError(err) + f.r.True(period.IsUint64()) + f.mineTo(*staged.StagedAtBlock + period.Uint64()) + tx, err = f.consensus.AcceptStagedTournamentResult(f.actors[0], new(big.Int)) + acceptedReceipt := f.record(tx, err) + accepted, err := waitForEpochStatus(f.ctx, f.t, f.appName, 0, EpochStatus_ClaimAccepted) + f.r.NoError(err) + f.r.Equal(epoch.MachineHash, accepted.MachineHash) + f.r.Equal(epoch.Commitment, accepted.Commitment) + f.r.NotNil(accepted.ClaimTransactionHash) + f.r.Equal(acceptedReceipt.TxHash, *accepted.ClaimTransactionHash) + var app api.SingleResponse[*Application] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getApplication", api.GetApplicationParams{Application: f.appName}, &app)) + f.r.NotNil(app.Data) + f.r.Equal(ApplicationStatus_OK, app.Data.Status, "an identical external winner must not cause divergence") + f.r.True(app.Data.Enabled) +} diff --git a/test/integration/reader_node_helpers_test.go b/test/integration/reader_node_helpers_test.go new file mode 100644 index 000000000..d64433d84 --- /dev/null +++ b/test/integration/reader_node_helpers_test.go @@ -0,0 +1,80 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/claimer" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prt" + "github.com/cartesi/rollups-node/internal/repository/factory" +) + +// startReaderNode uses matching saved/requested settings in the isolated test +// database. This is fixture setup, not a supported production mode transition. +// Cleanup stops the reader, restores the exact saved records, and restarts the +// default node before the next test. Latest supports fixtures that pause mining. +func startReaderNode(ctx context.Context, t *testing.T) { + t.Helper() + dsn, err := config.GetDatabaseConnection() + require.NoError(t, err) + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + require.NoError(t, err) + t.Cleanup(repo.Close) + overrides := []struct { + key string + fields map[string]any + }{ + {prt.PrtConfigKey, map[string]any{passiveDefaultBlockField: model.DefaultBlock_Latest, passiveClaimSubmissionField: false}}, + {evmreader.EvmReaderConfigKey, map[string]any{passiveDefaultBlockField: model.DefaultBlock_Latest}}, + {claimer.ClaimerConfigKey, map[string]any{passiveDefaultBlockField: model.DefaultBlock_Latest, passiveClaimSubmissionField: false}}, + } + type savedConfig struct { + key string + raw, updated []byte + } + saved := make([]savedConfig, 0, len(overrides)) + registerPassiveObserverCleanup(t, func() { + if sharedNode != nil { + stopSharedNode(t) + } + }, func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + restored := true + for _, original := range saved { + if err := repo.SaveNodeConfigRaw(cleanupCtx, original.key, original.raw); err != nil { + t.Errorf("restore %s persistent config: %v", original.key, err) + restored = false + } + } + require.True(t, restored, "do not restart the default node with a partially restored config") + }, func() { startSharedNode(t) }) + // Register cleanup before stopping: an already-exited child can fail the + // stop assertion after clearing sharedNode, but still needs a default restart. + stopSharedNode(t) + for _, override := range overrides { + raw, _, _, err := repo.LoadNodeConfigRaw(ctx, override.key) + require.NoError(t, err, "the healthy test-managed node must have initialized %s config", override.key) + updated, err := patchPassiveObserverConfig(raw, override.fields) + require.NoError(t, err) + saved = append(saved, savedConfig{override.key, append([]byte(nil), raw...), updated}) + } + // Validate all originals before the first write. Cleanup is already registered + // even if a write succeeds but its reply fails. Never change the parent env. + for _, override := range saved { + require.NoError(t, repo.SaveNodeConfigRaw(ctx, override.key, override.updated)) + } + startSharedNodeWithEnv(t, "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false", + "CARTESI_BLOCKCHAIN_DEFAULT_BLOCK=latest") +} From 0baf78f41c35c7280c4c0917c0794bbcd2611ad5 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:35:20 -0300 Subject: [PATCH 41/41] test(integration): cover deposit refunds Exercise the refund CLI against foreclosed Authority, Quorum, and PRT applications. Verify the unfinalized deposit lifecycle and the resulting refund transaction through the deployed contracts. Include the refund suite in the withdrawal shard. This completes the command introduced earlier without mixing CLI implementation into the end-to-end test commit. --- Makefile | 4 +- test/integration/refund_lifecycle_test.go | 435 ++++++++++++++++++++++ 2 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 test/integration/refund_lifecycle_test.go diff --git a/Makefile b/Makefile index 63dedc439..63800670b 100644 --- a/Makefile +++ b/Makefile @@ -606,7 +606,7 @@ check-license: ## Verify license headers on Go source files # `make integration-test-shard-check`. # # Shards are grouped by semantic family, not balanced by runtime: `withdrawal` -# is a single test while `restart` and `replay` are the heaviest. Each shard +# covers withdrawals and deposit refunds; `restart` and `replay` are the heaviest. Each shard # gets its own CI runner and the full per-job `go test -timeout 55m` # (run-integration-tests.sh) budget; `restart` (multi-suite, ~25-min setup # contexts) is the first to watch if a shard ever approaches that ceiling. @@ -620,7 +620,7 @@ INTEGRATION_SHARD_quorum := ^Test(EchoQuorum|SameBlockInputs)$$ INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt|PrtPassiveDisputeObserver|SparseDisputeCommitment(MatchesDenseTrees|CanonicalGeometry|RejectsOutOfRangeRequests)|PassiveObserver(CleanupPreservesRestoreOrder|Config(PreservesOriginalAndUnrelatedFields|RejectsMissingFieldsAndInvalidJSON)))$$ INTEGRATION_SHARD_replay := ^Test(Foreclose|ForecloseReplay|DivergentClaim)$$ INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy|NodeSubprocess)$$ -INTEGRATION_SHARD_withdrawal := ^TestWithdrawalLifecycle$$ +INTEGRATION_SHARD_withdrawal := ^Test(WithdrawalLifecycle|RefundLifecycle)$$ INTEGRATION_SHARD_awskms := ^TestLocalStackAWSIntegration$$ # ----------------------------------------------------------------------------- diff --git a/test/integration/refund_lifecycle_test.go b/test/integration/refund_lifecycle_test.go new file mode 100644 index 000000000..48c5b5e9c --- /dev/null +++ b/test/integration/refund_lifecycle_test.go @@ -0,0 +1,435 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/outputs" +) + +const ( + refundPayerIndex uint32 = 9 + refundDepositAmount uint64 = 37 + refundSelectorBytes = 4 +) + +// TestRefundLifecycle stops every submitter before the second deposit exists. +// No claim prepared before shutdown can finalize that deposit. Foreclosure is +// mined before restarting, so the finalization boundary does not depend on a +// race against the background claimer, epoch timing, or local input status. +func TestRefundLifecycle(t *testing.T) { + if !isNodeSelfManaged() { + t.Skip("refund finalization control requires a test-managed node") + } + for _, consensus := range []withdrawalConsensus{ + withdrawalConsensusAuthority, withdrawalConsensusQuorum, withdrawalConsensusPRT, + } { + t.Run(string(consensus), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + client := newIntegrationEthClient(ctx, t) + t.Cleanup(client.Close) + chainID, err := client.ChainID(ctx) + require.NoError(t, err) + + // Reuse the existing lifecycle helpers without running its suite. + s := &WithdrawalLifecycleSuite{ctx: ctx, client: client, chainID: chainID} + s.SetT(t) + s.StartLogCapture() + if consensus == withdrawalConsensusPRT { + // PRT settlement mines blocks rapidly while the node is running. + s.SetExpectedLogs(t, prtBlockOutOfRangeAllowlist) + } + t.Cleanup(func() { s.CheckLogs(t) }) + // Keep recovery separate: a fatal cleanup assertion must not leave + // the shared node stopped for subsequent integration tests. + t.Cleanup(func() { + if sharedNode == nil { + startSharedNode(t) + } + }) + t.Cleanup(func() { + if s.appName != "" { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) + defer cleanupCancel() + require.NoError(t, disableApplication(cleanupCtx, s.appName)) + } + }) + runRefundLifecycle(s, consensus) + }) + } +} + +type refundLifecycleFixture struct { + s *WithdrawalLifecycleSuite + app withdrawalAppDeployment + contract *iapplication.IApplication + portal common.Address + token common.Address + first *iinputbox.IInputBoxInputAdded + second *iinputbox.IInputBoxInputAdded +} + +func runRefundLifecycle(s *WithdrawalLifecycleSuite, consensus withdrawalConsensus) { + r := s.Require() + dappPath := envOrDefault("CARTESI_TEST_ERC20_WITHDRAWAL_DAPP_PATH", "applications/erc20-withdrawal-dapp") + f := refundLifecycleFixture{ + s: s, + portal: devnetAddress(s.T(), "CARTESI_DEVNET_ERC20_PORTAL_ADDRESS", defaultDevnetERC20PortalAddress), + token: devnetAddress(s.T(), "CARTESI_DEVNET_TEST_ERC20_ADDRESS", defaultDevnetTestERC20Address), + } + depositor := mnemonicAddress(s.T(), withdrawalUserIndex) + payer := mnemonicAddress(s.T(), refundPayerIndex) + guardian := mnemonicAddress(s.T(), withdrawalGuardianIndex) + r.NotEqual(depositor, payer) + r.NotEqual(guardian, payer) + r.NotEqual(guardian, depositor) + r.NoError(anvilSetBalance(s.ctx, depositor.Hex(), oneEtherWei)) + r.NoError(anvilSetBalance(s.ctx, payer.Hex(), oneEtherWei)) + initialDepositorBalance := s.tokenBalance(f.token, depositor) + s.mintTestToken(f.token, withdrawalUserIndex, tokenAmount(withdrawalDepositAmount+refundDepositAmount)) + f.app = s.deployWithdrawalApp(consensus, dappPath) + s.appName = f.app.appName + var err error + f.contract, err = iapplication.NewIApplication(f.app.appAddress, s.client) + r.NoError(err) + if consensus != withdrawalConsensusPRT { + s.waitForIConsensusInputCursor(s.appName) + } + + f.first = f.deposit(withdrawalDepositAmount) + firstInput := s.waitForAcceptedInput(s.appName, f.first.Index.Uint64()) + s.finalizeWithdrawalEpoch(consensus, f.app, firstInput.EpochIndex) + f.requireFinalized(f.first, true) + firstFile := f.exportInput(f.first) + + stopSharedNode(s.T()) + f.second = f.deposit(refundDepositAmount) + f.requireFinalized(f.first, true) + f.requireFinalized(f.second, false) + s.requireTokenBalance(f.token, depositor, initialDepositorBalance, "both deposits left the depositor") + s.requireTokenBalance(f.token, f.app.appAddress, + tokenAmount(withdrawalDepositAmount+refundDepositAmount), "application holds both deposits") + before := f.state() + r.Zero(before.RefundCount.Sign()) + r.False(before.FirstRefunded) + r.False(before.SecondRefunded) + + // The node has not indexed this deposit yet. Use the actual InputAdded + // bytes for the pre-foreclosure rejection, then export them through the + // existing node API after restart for the successful CLI invocation. + eventFile := f.inputFile("event-input.hex", hexutil.Encode(f.second.Input)) + f.reject(s.appName, f.second.Index.String(), eventFile, "NotForeclosed", before) + forecloseJSON, err := runCLIWithEnv(s.ctx, + []string{fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", withdrawalGuardianIndex)}, + "foreclose", s.appName, "--yes", "--json") + r.NoError(err) + forecloseReceipt := f.cliReceipt(forecloseJSON) + foreclosed, err := f.contract.IsForeclosed(&bind.CallOpts{Context: s.ctx, BlockNumber: forecloseReceipt.BlockNumber}) + r.NoError(err) + r.True(foreclosed) + f.requireFinalized(f.first, true) + f.requireFinalized(f.second, false) + startSharedNode(s.T()) + forecloseCtx, forecloseCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + r.NoError(waitForApplicationForeclosed(forecloseCtx, s.T(), s.appName)) + forecloseCancel() + secondInput := s.waitForAcceptedInput(s.appName, f.second.Index.Uint64()) + r.Equal(model.InputCompletionStatus_Accepted, secondInput.Status, + "local execution acceptance does not make an input finalized by consensus") + secondFile := f.exportInput(f.second) + f.requireFinalized(f.second, false) + r.Equal(before, f.state(), "foreclosure and node replay do not transfer tokens or issue refunds") + + // Tampering must be checked before a successful refund sets the replay + // bitmap; afterwards even bad bytes would hit RefundAlreadyIssued first. + tampered := bytes.Clone(f.second.Input) + tampered[len(tampered)-1] ^= 1 + tamperedFile := f.inputFile("tampered-input.hex", hexutil.Encode(tampered)) + f.reject(s.appName, f.second.Index.String(), tamperedFile, "InvalidInputHash", before) + f.reject(f.app.appAddress.Hex(), f.first.Index.String(), firstFile, "CannotRefundFinalizedInput", before) + + // A manual gas limit bypasses estimation, not receipt checks. The invalid + // refund must mine, fail, and leave the contract and token state unchanged. + nonceBefore, err := s.client.NonceAt(s.ctx, payer, nil) + r.NoError(err) + _, err = f.refund(f.app.appAddress.Hex(), f.first.Index.String(), firstFile, "--gas-limit", "500000") + r.ErrorContains(err, "receipt status 0") + nonceAfter, err := s.client.NonceAt(s.ctx, payer, nil) + r.NoError(err) + r.Equal(nonceBefore+1, nonceAfter, "the failed refund was mined, not rejected by estimation") + r.Equal(before, f.state(), "a mined failing refund must preserve token balances and refund state") + + appReference := s.appName + var transactionFlags []string + if consensus == withdrawalConsensusQuorum { + appReference = f.app.appAddress.Hex() + transactionFlags = []string{"--no-wait", "--gas-limit", "500000"} + } + result, err := f.refund(appReference, hexutil.EncodeBig(f.second.Index), secondFile, transactionFlags...) + r.NoError(err, "refund CLI must submit the original exported input") + var submitted struct { + Status string `json:"status"` + } + r.NoError(json.Unmarshal([]byte(result), &submitted)) + if consensus == withdrawalConsensusQuorum { + r.Equal("broadcast", submitted.Status) + } else { + r.Equal("mined", submitted.Status) + } + receipt := f.cliReceipt(result) + f.requireRefundReceipt(receipt) + after := f.state() + r.Equal(tokenBalanceWithDelta(initialDepositorBalance, refundDepositAmount), after.DepositorBalance) + r.Equal(tokenAmount(withdrawalDepositAmount), after.ApplicationBalance, + "the finalized deposit remains in the application") + r.Equal(before.PayerBalance, after.PayerBalance, "the gas payer does not receive the deposited tokens") + r.Equal(tokenAmount(1), after.RefundCount) + r.False(after.FirstRefunded) + r.True(after.SecondRefunded) + r.Equal(before.ExecutedOutputCount, after.ExecutedOutputCount) + r.Equal(before.WithdrawalCount, after.WithdrawalCount) + f.requireFinalized(f.first, true) + f.requireFinalized(f.second, false) + f.reject(f.app.appAddress.Hex(), f.second.Index.String(), secondFile, "RefundAlreadyIssued", after) +} + +func (f *refundLifecycleFixture) deposit(amount uint64) *iinputbox.IInputBoxInputAdded { + s, r := f.s, f.s.Require() + start, err := s.client.BlockNumber(s.ctx) + r.NoError(err) + inputBoxAddr := inputBoxAddress(s.T()) + index := inputBoxInputCount(s.ctx, s.T(), s.client, inputBoxAddr, f.app.appAddress) + s.depositERC20(s.appName, f.portal, f.token, withdrawalUserIndex, amount) + end, err := s.client.BlockNumber(s.ctx) + r.NoError(err) + inputBox, err := iinputbox.NewIInputBox(inputBoxAddr, s.client) + r.NoError(err) + events, err := inputBox.FilterInputAdded(&bind.FilterOpts{Context: s.ctx, Start: start, End: &end}, + []common.Address{f.app.appAddress}, []*big.Int{new(big.Int).SetUint64(index)}) + r.NoError(err) + defer func() { r.NoError(events.Close()) }() + r.True(events.Next(), "deposit must emit InputAdded") + event := events.Event + r.Equal(f.app.appAddress, event.AppContract) + r.Equal(index, event.Index.Uint64()) + r.NotEmpty(event.Input) + r.False(events.Next(), "one portal deposit must emit exactly one scoped input") + r.NoError(events.Error()) + return event +} + +func (f *refundLifecycleFixture) exportInput(event *iinputbox.IInputBoxInputAdded) string { + s, r := f.s, f.s.Require() + out, err := runCLI(s.ctx, "read", "inputs", s.appName, event.Index.String(), "--jsonrpc") + r.NoError(err, "export the original input through JSON-RPC") + var response api.SingleResponse[struct { + RawData string `json:"raw_data"` + }] + r.NoError(json.Unmarshal([]byte(out), &response)) + r.Equal(hexutil.Encode(event.Input), response.Data.RawData, "data.raw_data must equal the full InputAdded.input") + return f.inputFile("exported-input.hex", response.Data.RawData) +} + +func (f *refundLifecycleFixture) inputFile(name, input string) string { + file := filepath.Join(f.s.T().TempDir(), name) + f.s.Require().NoError(os.WriteFile(file, []byte(input+"\n"), 0600)) + return file +} + +func (f *refundLifecycleFixture) requireFinalized(event *iinputbox.IInputBoxInputAdded, want bool) { + s, r := f.s, f.s.Require() + head, err := s.client.BlockNumber(s.ctx) + r.NoError(err) + opts := &bind.CallOpts{Context: s.ctx, BlockNumber: new(big.Int).SetUint64(head)} + validatorAddress, err := f.contract.GetOutputsMerkleRootValidator(opts) + r.NoError(err) + // wasInputFinalized has the same released interface for Authority, + // Quorum, and Dave, though their finalization frontiers differ. + validator, err := iconsensus.NewIConsensus(validatorAddress, s.client) + r.NoError(err) + finalized, err := validator.WasInputFinalized(opts, f.app.appAddress, event.Index, + new(big.Int).SetUint64(event.Raw.BlockNumber)) + r.NoError(err) + r.Equal(want, finalized, "input %s finalization at block %d", event.Index, head) +} + +type refundChainState struct { + DepositorBalance, ApplicationBalance, PayerBalance *big.Int + RefundCount, ExecutedOutputCount, WithdrawalCount *big.Int + FirstRefunded, SecondRefunded bool +} + +func (f *refundLifecycleFixture) state() refundChainState { + s, r := f.s, f.s.Require() + state := refundChainState{ + DepositorBalance: s.tokenBalance(f.token, mnemonicAddress(s.T(), withdrawalUserIndex)), + ApplicationBalance: s.tokenBalance(f.token, f.app.appAddress), + PayerBalance: s.tokenBalance(f.token, mnemonicAddress(s.T(), refundPayerIndex)), + } + opts := &bind.CallOpts{Context: s.ctx} + var err error + state.RefundCount, err = f.contract.GetNumberOfIssuedRefunds(opts) + r.NoError(err) + state.ExecutedOutputCount, err = f.contract.GetNumberOfExecutedOutputs(opts) + r.NoError(err) + state.WithdrawalCount, err = f.contract.GetNumberOfWithdrawals(opts) + r.NoError(err) + state.FirstRefunded, err = f.contract.WasRefundForInputIssued(opts, f.first.Index) + r.NoError(err) + state.SecondRefunded, err = f.contract.WasRefundForInputIssued(opts, f.second.Index) + r.NoError(err) + return state +} + +func (f *refundLifecycleFixture) refund(application, index, inputFile string, flags ...string) (string, error) { + args := []string{"refund", application, index, "--input-file", inputFile, "--yes", "--json"} + args = append(args, flags...) + return runCLIWithEnv(f.s.ctx, + []string{fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", refundPayerIndex)}, + args...) +} + +func (f *refundLifecycleFixture) reject(application, index, inputFile, revertName string, want refundChainState) { + _, err := f.refund(application, index, inputFile) + f.s.Require().Error(err) + f.s.Require().ErrorContains(err, "decoded revert: "+revertName) + f.s.Require().Equal(want, f.state(), "rejected refund must preserve balances, counters, and replay flags") +} + +func (f *refundLifecycleFixture) cliReceipt(out string) *types.Receipt { + s, r := f.s, f.s.Require() + var result struct { + TransactionHash common.Hash `json:"transaction_hash"` + ApplicationAddress common.Address `json:"application_address"` + Status string `json:"status"` + BlockNumber string `json:"block_number"` + } + r.NoError(json.Unmarshal([]byte(out), &result)) + r.Equal(f.app.appAddress, result.ApplicationAddress) + r.NotEqual(common.Hash{}, result.TransactionHash) + if result.Status == "mined" { + // Waiting is now the CLI default: its successful result must already + // have a successful receipt. Do not hide a missing wait with polling. + receipt, err := s.client.TransactionReceipt(s.ctx, result.TransactionHash) + r.NoError(err) + r.NotNil(receipt) + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + r.Equal(result.TransactionHash, receipt.TxHash) + r.Equal(hexutil.EncodeBig(receipt.BlockNumber), result.BlockNumber) + return receipt + } + r.Equal("broadcast", result.Status) + r.Empty(result.BlockNumber, "broadcast-only output must not claim a mined block") + ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + defer cancel() + var receipt *types.Receipt + err := pollUntil(ctx, time.Second, func() (bool, error) { + var err error + receipt, err = s.client.TransactionReceipt(ctx, result.TransactionHash) + if errors.Is(err, ethereum.NotFound) { + return false, nil + } + return receipt != nil, err + }) + r.NoError(err) + r.Equal(result.TransactionHash, receipt.TxHash) + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + return receipt +} + +func (f *refundLifecycleFixture) requireRefundReceipt(receipt *types.Receipt) { + s, r := f.s, f.s.Require() + tx, pending, err := s.client.TransactionByHash(s.ctx, receipt.TxHash) + r.NoError(err) + r.False(pending) + sender, err := types.Sender(types.LatestSignerForChainID(s.chainID), tx) + r.NoError(err) + r.Equal(mnemonicAddress(s.T(), refundPayerIndex), sender, "a distinct gas payer signed the actual transaction") + r.Equal(&f.app.appAddress, tx.To()) + appABI, err := iapplication.IApplicationMetaData.GetAbi() + r.NoError(err) + wantCall, err := appABI.Pack("issueRefund", f.second.Index, f.second.Input) + r.NoError(err) + r.Equal(wantCall, tx.Data()) + + token, err := ierc20metadata.NewIERC20Metadata(f.token, s.client) + r.NoError(err) + tokenABI, err := ierc20metadata.IERC20MetadataMetaData.GetAbi() + r.NoError(err) + refunds, transfers := 0, 0 + for _, raw := range receipt.Logs { + r.NotNil(raw) + if raw.Address == f.app.appAddress && len(raw.Topics) > 0 && raw.Topics[0] == appABI.Events["RefundIssued"].ID { + event, err := f.contract.ParseRefundIssued(*raw) + r.NoError(err) + r.Equal(f.second.Index, event.InputIndex) + r.Equal(f.second.Input, event.Input) + r.Equal(receipt.TxHash, event.Raw.TxHash) + r.Equal(receipt.BlockNumber.Uint64(), event.Raw.BlockNumber) + f.requireRefundOutput(event.Output, receipt.BlockNumber) + refunds++ + } + if raw.Address == f.token && len(raw.Topics) > 0 && raw.Topics[0] == tokenABI.Events["Transfer"].ID { + event, err := token.ParseTransfer(*raw) + r.NoError(err) + r.Equal(f.app.appAddress, event.From) + r.Equal(mnemonicAddress(s.T(), withdrawalUserIndex), event.To) + r.Equal(tokenAmount(refundDepositAmount), event.Value) + transfers++ + } + } + r.Equal(1, refunds, "exactly one RefundIssued must be emitted") + r.Equal(1, transfers, "exactly one ERC-20 transfer must pay the original depositor") +} + +func (f *refundLifecycleFixture) requireRefundOutput(raw []byte, block *big.Int) { + s, r := f.s, f.s.Require() + outputABI, err := outputs.OutputsMetaData.GetAbi() + r.NoError(err) + output, err := api.DecodeOutput(&model.Output{RawData: raw}, outputABI) + r.NoError(err) + r.Equal("DelegateCallVoucher", output.DecodedData.Type) + destination := common.HexToAddress(output.DecodedData.Destination) + code, err := s.client.CodeAt(s.ctx, destination, block) + r.NoError(err) + r.NotEmpty(code, "the canonical ERC-20 refund delegates to a deployed safe-transfer contract") + // ISafeErc20Transfer.safeTransfer(token, to, value), as encoded by the + // released LibErc20Deposit.buildRefund. Only the deployed destination is + // taken from the event; token, recipient, amount, and full encoding are checked. + payload := crypto.Keccak256([]byte("safeTransfer(address,address,uint256)"))[:refundSelectorBytes] + payload = append(payload, common.LeftPadBytes(f.token.Bytes(), common.HashLength)...) + payload = append(payload, common.LeftPadBytes(mnemonicAddress(s.T(), withdrawalUserIndex).Bytes(), common.HashLength)...) + payload = append(payload, common.LeftPadBytes(tokenAmount(refundDepositAmount).Bytes(), common.HashLength)...) + r.Equal(hexutil.Encode(payload), output.DecodedData.Payload) + want, err := outputABI.Pack("DelegateCallVoucher", destination, payload) + r.NoError(err) + r.Equal(want, raw, "RefundIssued.output must contain exactly the canonical transfer output") +}