From 3a03e0dcfd36db681167e46b44e3181a010ccb9f Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Mon, 11 May 2026 13:00:10 -0300 Subject: [PATCH 01/16] feat: bump rollups and prt contracts to v3 alpha --- Makefile | 20 +++++++----- compose.individual-services.yaml | 12 ++++--- compose.yaml | 12 ++++--- test/compose/compose.integration.yaml | 12 ++++--- test/compose/compose.test.yaml | 12 ++++--- test/devnet/Dockerfile | 47 +++++++++++++++++++++++++-- 6 files changed, 83 insertions(+), 32 deletions(-) diff --git a/Makefile b/Makefile index 7483baedc..513c814dd 100644 --- a/Makefile +++ b/Makefile @@ -18,14 +18,14 @@ TARGET_OS?=$(shell uname) export TARGET_OS ROLLUPS_NODE_VERSION := 2.0.0-alpha.11 -ROLLUPS_CONTRACTS_VERSION := 2.2.0 +ROLLUPS_CONTRACTS_VERSION := 3.0.0-alpha.6 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:=31c20a8c50f794185957ebd6e554fc99c8e01f0fdf9a80628d031fb0edc7091d -ROLLUPS_PRT_CONTRACTS_VERSION := 2.1.1 +ROLLUPS_CONTRACTS_SHA256:=ad1e0880766d25419fc6da1858ea4e7b9074b400e9d9ef68da88b12f4a8bba45 +ROLLUPS_PRT_CONTRACTS_VERSION := 3.0.0-alpha.3 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:=830815bcd858a67b73738c6747030960d88ed1e2e0b123086f2112b1ff47f7c9 +ROLLUPS_PRT_CONTRACTS_SHA256:=240f4934df7a313dc05a4ae6cc3eee97b5c146952c4218502fec0db83f36a5a5 IMAGE_TAG ?= devel @@ -123,11 +123,13 @@ env: @echo export CARTESI_BLOCKCHAIN_HTTP_ENDPOINT="http://localhost:8545" @echo export CARTESI_BLOCKCHAIN_WS_ENDPOINT="ws://localhost:8545" @echo export CARTESI_BLOCKCHAIN_ID="31337" - @echo export CARTESI_CONTRACTS_INPUT_BOX_ADDRESS="0x1b51e2992A2755Ba4D6F7094032DF91991a0Cfac" - @echo export CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS="0x5E96408CFE423b01dADeD3bc867E6013135990cc" - @echo export CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS="0x26E758238CB6eC5aB70ce0dd52aF2d7b82e1972E" - @echo export CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS="0x010D3CbB4223F5bCc7b7B03cEE59f3aAea8eDb8A" - @echo export CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS="0xfC2DBC639b5FB9AfE66A8696eC14EaD9FbFBC404" + @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_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS="0x0745787835A019cd4dae8EDB541Fbc0647793d63" @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" diff --git a/compose.individual-services.yaml b/compose.individual-services.yaml index 0327cc1e1..f65c54995 100644 --- a/compose.individual-services.yaml +++ b/compose.individual-services.yaml @@ -3,11 +3,13 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint CARTESI_BLOCKCHAIN_WS_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint CARTESI_BLOCKCHAIN_ID: 31337 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x1b51e2992A2755Ba4D6F7094032DF91991a0Cfac - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x5E96408CFE423b01dADeD3bc867E6013135990cc - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0x26E758238CB6eC5aB70ce0dd52aF2d7b82e1972E - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x010D3CbB4223F5bCc7b7B03cEE59f3aAea8eDb8A - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0xfC2DBC639b5FB9AfE66A8696eC14EaD9FbFBC404 + 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_DATABASE_CONNECTION_FILE: /run/secrets/database_connection CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic diff --git a/compose.yaml b/compose.yaml index 2907d4210..afa0dfdd8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,11 +3,13 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint CARTESI_BLOCKCHAIN_WS_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint CARTESI_BLOCKCHAIN_ID: 31337 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x1b51e2992A2755Ba4D6F7094032DF91991a0Cfac - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x5E96408CFE423b01dADeD3bc867E6013135990cc - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0x26E758238CB6eC5aB70ce0dd52aF2d7b82e1972E - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x010D3CbB4223F5bCc7b7B03cEE59f3aAea8eDb8A - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0xfC2DBC639b5FB9AfE66A8696eC14EaD9FbFBC404 + 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_DATABASE_CONNECTION_FILE: /run/secrets/database_connection CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 037e2be3f..2e00cf539 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -3,11 +3,13 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: http://ethereum_provider:8545 CARTESI_BLOCKCHAIN_WS_ENDPOINT: ws://ethereum_provider:8545 CARTESI_BLOCKCHAIN_ID: 31337 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x1b51e2992A2755Ba4D6F7094032DF91991a0Cfac - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x5E96408CFE423b01dADeD3bc867E6013135990cc - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0x26E758238CB6eC5aB70ce0dd52aF2d7b82e1972E - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x010D3CbB4223F5bCc7b7B03cEE59f3aAea8eDb8A - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0xfC2DBC639b5FB9AfE66A8696eC14EaD9FbFBC404 + 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_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 1f1de02c9..b5ee6a22e 100644 --- a/test/compose/compose.test.yaml +++ b/test/compose/compose.test.yaml @@ -3,11 +3,13 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: http://ethereum_provider:8545 CARTESI_BLOCKCHAIN_WS_ENDPOINT: ws://ethereum_provider:8545 CARTESI_BLOCKCHAIN_ID: 31337 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x1b51e2992A2755Ba4D6F7094032DF91991a0Cfac - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x5E96408CFE423b01dADeD3bc867E6013135990cc - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0x26E758238CB6eC5aB70ce0dd52aF2d7b82e1972E - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x010D3CbB4223F5bCc7b7B03cEE59f3aAea8eDb8A - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0xfC2DBC639b5FB9AfE66A8696eC14EaD9FbFBC404 + 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_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 8387208c4..c0dece8b7 100644 --- a/test/devnet/Dockerfile +++ b/test/devnet/Dockerfile @@ -1,8 +1,8 @@ ARG FOUNDRY_VERSION=1.4.3 -ARG PRT_CONTRACTS_VERSION=2.1.0 +ARG PRT_CONTRACTS_VERSION=3.0.0-alpha.3 ARG DEVNET_BUILD_PATH=/opt/cartesi/rollups-contracts -FROM debian:bookworm-20250407 AS rollups-node-devnet +FROM debian:trixie-20250811 AS rollups-node-devnet ARG FOUNDRY_VERSION ARG PRT_CONTRACTS_VERSION ARG DEVNET_BUILD_PATH @@ -32,6 +32,11 @@ 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 b4dbd09a3e7879f4c50cc9b6a383ccddb1149f30 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Mon, 11 May 2026 13:03:34 -0300 Subject: [PATCH 02/16] refactor: regenerate contracts bindings --- pkg/contracts/generate/main.go | 12 + pkg/contracts/iapplication/iapplication.go | 937 +++++++++++++++++- .../iapplicationfactory.go | 156 ++- pkg/contracts/iauthority/iauthority.go | 466 ++++++++- .../iauthorityfactory/iauthorityfactory.go | 134 ++- pkg/contracts/iconsensus/iconsensus.go | 459 ++++++++- .../idaveappfactory/idaveappfactory.go | 59 +- .../idaveconsensus/idaveconsensus.go | 33 +- .../ierc20metadata/ierc20metadata.go | 738 ++++++++++++++ pkg/contracts/iinputbox/iinputbox.go | 62 +- pkg/contracts/iquorum/iquorum.go | 502 +++++++++- .../iquorumfactory/iquorumfactory.go | 448 +++++++++ .../iselfhostedapplicationfactory.go | 119 ++- pkg/contracts/itournament/itournament.go | 2 +- .../iusdwithdrawaloutputbuilder.go | 303 ++++++ 15 files changed, 4149 insertions(+), 281 deletions(-) create mode 100644 pkg/contracts/ierc20metadata/ierc20metadata.go create mode 100644 pkg/contracts/iquorumfactory/iquorumfactory.go create mode 100644 pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go diff --git a/pkg/contracts/generate/main.go b/pkg/contracts/generate/main.go index 3352cb55f..426400f76 100644 --- a/pkg/contracts/generate/main.go +++ b/pkg/contracts/generate/main.go @@ -45,6 +45,10 @@ var bindings = []contractBinding{ jsonPath: rollupsContractsPath + "IQuorum.sol/IQuorum.json", typeName: "IQuorum", }, + { + jsonPath: rollupsContractsPath + "IQuorumFactory.sol/IQuorumFactory.json", + typeName: "IQuorumFactory", + }, { jsonPath: rollupsContractsPath + "IApplication.sol/IApplication.json", typeName: "IApplication", @@ -73,6 +77,14 @@ var bindings = []contractBinding{ jsonPath: rollupsContractsPath + "DataAvailability.sol/DataAvailability.json", typeName: "DataAvailability", }, + { + jsonPath: rollupsContractsPath + "IUsdWithdrawalOutputBuilder.sol/IUsdWithdrawalOutputBuilder.json", + typeName: "IUsdWithdrawalOutputBuilder", + }, + { + jsonPath: rollupsContractsPath + "IERC20Metadata.sol/IERC20Metadata.json", + typeName: "IERC20Metadata", + }, { jsonPath: rollupsPrtContractsPath + "prt/contracts/out/ITournament.sol/ITournament.json", typeName: "ITournament", diff --git a/pkg/contracts/iapplication/iapplication.go b/pkg/contracts/iapplication/iapplication.go index 69f2f6d97..dab53f9a5 100644 --- a/pkg/contracts/iapplication/iapplication.go +++ b/pkg/contracts/iapplication/iapplication.go @@ -29,15 +29,30 @@ var ( _ = abi.ConvertType ) +// AccountValidityProof is an auto generated low-level Go binding around an user-defined struct. +type AccountValidityProof struct { + AccountIndex uint64 + AccountRootSiblings [][32]byte +} + // OutputValidityProof is an auto generated low-level Go binding around an user-defined struct. type OutputValidityProof struct { OutputIndex uint64 OutputHashesSiblings [][32]byte } +// WithdrawalConfig is an auto generated low-level Go binding around an user-defined struct. +type WithdrawalConfig struct { + Guardian common.Address + Log2LeavesPerAccount uint8 + Log2MaxNumOfAccounts uint8 + AccountsDriveStartIndex uint64 + WithdrawalOutputBuilder common.Address +} + // 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\":\"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\":\"getNumberOfExecutedOutputs\",\"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\":\"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\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"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\":\"wasOutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"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\":\"error\",\"name\":\"InsufficientFunds\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputHashesSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRoot\",\"inputs\":[{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"OutputNotExecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"OutputNotReexecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}]", + 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\"}]}]", } // IApplicationABI is the input ABI used to generate the binding from. @@ -186,6 +201,82 @@ func (_IApplication *IApplicationTransactorRaw) Transact(opts *bind.TransactOpts return _IApplication.Contract.contract.Transact(opts, method, params...) } +// GetAccountsDriveMerkleRoot is a free data retrieval call binding the contract method 0xf04ba871. +// +// Solidity: function getAccountsDriveMerkleRoot() view returns(bool wasAccountsDriveMerkleRootProved, bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationCaller) GetAccountsDriveMerkleRoot(opts *bind.CallOpts) (struct { + WasAccountsDriveMerkleRootProved bool + AccountsDriveMerkleRoot [32]byte +}, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getAccountsDriveMerkleRoot") + + outstruct := new(struct { + WasAccountsDriveMerkleRootProved bool + AccountsDriveMerkleRoot [32]byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.WasAccountsDriveMerkleRootProved = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.AccountsDriveMerkleRoot = *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) + + return *outstruct, err + +} + +// GetAccountsDriveMerkleRoot is a free data retrieval call binding the contract method 0xf04ba871. +// +// Solidity: function getAccountsDriveMerkleRoot() view returns(bool wasAccountsDriveMerkleRootProved, bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationSession) GetAccountsDriveMerkleRoot() (struct { + WasAccountsDriveMerkleRootProved bool + AccountsDriveMerkleRoot [32]byte +}, error) { + return _IApplication.Contract.GetAccountsDriveMerkleRoot(&_IApplication.CallOpts) +} + +// GetAccountsDriveMerkleRoot is a free data retrieval call binding the contract method 0xf04ba871. +// +// Solidity: function getAccountsDriveMerkleRoot() view returns(bool wasAccountsDriveMerkleRootProved, bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationCallerSession) GetAccountsDriveMerkleRoot() (struct { + WasAccountsDriveMerkleRootProved bool + AccountsDriveMerkleRoot [32]byte +}, error) { + return _IApplication.Contract.GetAccountsDriveMerkleRoot(&_IApplication.CallOpts) +} + +// GetAccountsDriveStartIndex is a free data retrieval call binding the contract method 0xab2423ad. +// +// Solidity: function getAccountsDriveStartIndex() view returns(uint64) +func (_IApplication *IApplicationCaller) GetAccountsDriveStartIndex(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getAccountsDriveStartIndex") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// GetAccountsDriveStartIndex is a free data retrieval call binding the contract method 0xab2423ad. +// +// Solidity: function getAccountsDriveStartIndex() view returns(uint64) +func (_IApplication *IApplicationSession) GetAccountsDriveStartIndex() (uint64, error) { + return _IApplication.Contract.GetAccountsDriveStartIndex(&_IApplication.CallOpts) +} + +// GetAccountsDriveStartIndex is a free data retrieval call binding the contract method 0xab2423ad. +// +// Solidity: function getAccountsDriveStartIndex() view returns(uint64) +func (_IApplication *IApplicationCallerSession) GetAccountsDriveStartIndex() (uint64, error) { + return _IApplication.Contract.GetAccountsDriveStartIndex(&_IApplication.CallOpts) +} + // GetDataAvailability is a free data retrieval call binding the contract method 0xf02478de. // // Solidity: function getDataAvailability() view returns(bytes) @@ -248,6 +339,99 @@ func (_IApplication *IApplicationCallerSession) GetDeploymentBlockNumber() (*big return _IApplication.Contract.GetDeploymentBlockNumber(&_IApplication.CallOpts) } +// GetGuardian is a free data retrieval call binding the contract method 0xa75b87d2. +// +// Solidity: function getGuardian() view returns(address) +func (_IApplication *IApplicationCaller) GetGuardian(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getGuardian") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetGuardian is a free data retrieval call binding the contract method 0xa75b87d2. +// +// Solidity: function getGuardian() view returns(address) +func (_IApplication *IApplicationSession) GetGuardian() (common.Address, error) { + return _IApplication.Contract.GetGuardian(&_IApplication.CallOpts) +} + +// GetGuardian is a free data retrieval call binding the contract method 0xa75b87d2. +// +// Solidity: function getGuardian() view returns(address) +func (_IApplication *IApplicationCallerSession) GetGuardian() (common.Address, error) { + return _IApplication.Contract.GetGuardian(&_IApplication.CallOpts) +} + +// GetLog2LeavesPerAccount is a free data retrieval call binding the contract method 0x28a0e3c5. +// +// Solidity: function getLog2LeavesPerAccount() view returns(uint8) +func (_IApplication *IApplicationCaller) GetLog2LeavesPerAccount(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getLog2LeavesPerAccount") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// GetLog2LeavesPerAccount is a free data retrieval call binding the contract method 0x28a0e3c5. +// +// Solidity: function getLog2LeavesPerAccount() view returns(uint8) +func (_IApplication *IApplicationSession) GetLog2LeavesPerAccount() (uint8, error) { + return _IApplication.Contract.GetLog2LeavesPerAccount(&_IApplication.CallOpts) +} + +// GetLog2LeavesPerAccount is a free data retrieval call binding the contract method 0x28a0e3c5. +// +// Solidity: function getLog2LeavesPerAccount() view returns(uint8) +func (_IApplication *IApplicationCallerSession) GetLog2LeavesPerAccount() (uint8, error) { + return _IApplication.Contract.GetLog2LeavesPerAccount(&_IApplication.CallOpts) +} + +// GetLog2MaxNumOfAccounts is a free data retrieval call binding the contract method 0xfc39b736. +// +// Solidity: function getLog2MaxNumOfAccounts() view returns(uint8) +func (_IApplication *IApplicationCaller) GetLog2MaxNumOfAccounts(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getLog2MaxNumOfAccounts") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// GetLog2MaxNumOfAccounts is a free data retrieval call binding the contract method 0xfc39b736. +// +// Solidity: function getLog2MaxNumOfAccounts() view returns(uint8) +func (_IApplication *IApplicationSession) GetLog2MaxNumOfAccounts() (uint8, error) { + return _IApplication.Contract.GetLog2MaxNumOfAccounts(&_IApplication.CallOpts) +} + +// GetLog2MaxNumOfAccounts is a free data retrieval call binding the contract method 0xfc39b736. +// +// Solidity: function getLog2MaxNumOfAccounts() view returns(uint8) +func (_IApplication *IApplicationCallerSession) GetLog2MaxNumOfAccounts() (uint8, error) { + return _IApplication.Contract.GetLog2MaxNumOfAccounts(&_IApplication.CallOpts) +} + // GetNumberOfExecutedOutputs is a free data retrieval call binding the contract method 0xe64fab4d. // // Solidity: function getNumberOfExecutedOutputs() view returns(uint256) @@ -279,6 +463,37 @@ func (_IApplication *IApplicationCallerSession) GetNumberOfExecutedOutputs() (*b return _IApplication.Contract.GetNumberOfExecutedOutputs(&_IApplication.CallOpts) } +// GetNumberOfWithdrawals is a free data retrieval call binding the contract method 0x0e70381b. +// +// Solidity: function getNumberOfWithdrawals() view returns(uint256) +func (_IApplication *IApplicationCaller) GetNumberOfWithdrawals(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getNumberOfWithdrawals") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfWithdrawals is a free data retrieval call binding the contract method 0x0e70381b. +// +// Solidity: function getNumberOfWithdrawals() view returns(uint256) +func (_IApplication *IApplicationSession) GetNumberOfWithdrawals() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfWithdrawals(&_IApplication.CallOpts) +} + +// GetNumberOfWithdrawals is a free data retrieval call binding the contract method 0x0e70381b. +// +// Solidity: function getNumberOfWithdrawals() view returns(uint256) +func (_IApplication *IApplicationCallerSession) GetNumberOfWithdrawals() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfWithdrawals(&_IApplication.CallOpts) +} + // GetOutputsMerkleRootValidator is a free data retrieval call binding the contract method 0xa94dfc5a. // // Solidity: function getOutputsMerkleRootValidator() view returns(address) @@ -341,6 +556,99 @@ func (_IApplication *IApplicationCallerSession) GetTemplateHash() ([32]byte, err return _IApplication.Contract.GetTemplateHash(&_IApplication.CallOpts) } +// GetWithdrawalConfig is a free data retrieval call binding the contract method 0x65d0c9ce. +// +// Solidity: function getWithdrawalConfig() view returns((address,uint8,uint8,uint64,address) withdrawalConfig) +func (_IApplication *IApplicationCaller) GetWithdrawalConfig(opts *bind.CallOpts) (WithdrawalConfig, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getWithdrawalConfig") + + if err != nil { + return *new(WithdrawalConfig), err + } + + out0 := *abi.ConvertType(out[0], new(WithdrawalConfig)).(*WithdrawalConfig) + + return out0, err + +} + +// GetWithdrawalConfig is a free data retrieval call binding the contract method 0x65d0c9ce. +// +// Solidity: function getWithdrawalConfig() view returns((address,uint8,uint8,uint64,address) withdrawalConfig) +func (_IApplication *IApplicationSession) GetWithdrawalConfig() (WithdrawalConfig, error) { + return _IApplication.Contract.GetWithdrawalConfig(&_IApplication.CallOpts) +} + +// GetWithdrawalConfig is a free data retrieval call binding the contract method 0x65d0c9ce. +// +// Solidity: function getWithdrawalConfig() view returns((address,uint8,uint8,uint64,address) withdrawalConfig) +func (_IApplication *IApplicationCallerSession) GetWithdrawalConfig() (WithdrawalConfig, error) { + return _IApplication.Contract.GetWithdrawalConfig(&_IApplication.CallOpts) +} + +// GetWithdrawalOutputBuilder is a free data retrieval call binding the contract method 0x92ab68d0. +// +// Solidity: function getWithdrawalOutputBuilder() view returns(address) +func (_IApplication *IApplicationCaller) GetWithdrawalOutputBuilder(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getWithdrawalOutputBuilder") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetWithdrawalOutputBuilder is a free data retrieval call binding the contract method 0x92ab68d0. +// +// Solidity: function getWithdrawalOutputBuilder() view returns(address) +func (_IApplication *IApplicationSession) GetWithdrawalOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetWithdrawalOutputBuilder(&_IApplication.CallOpts) +} + +// GetWithdrawalOutputBuilder is a free data retrieval call binding the contract method 0x92ab68d0. +// +// Solidity: function getWithdrawalOutputBuilder() view returns(address) +func (_IApplication *IApplicationCallerSession) GetWithdrawalOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetWithdrawalOutputBuilder(&_IApplication.CallOpts) +} + +// IsForeclosed is a free data retrieval call binding the contract method 0x83e4fbcd. +// +// Solidity: function isForeclosed() view returns(bool) +func (_IApplication *IApplicationCaller) IsForeclosed(opts *bind.CallOpts) (bool, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "isForeclosed") + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsForeclosed is a free data retrieval call binding the contract method 0x83e4fbcd. +// +// Solidity: function isForeclosed() view returns(bool) +func (_IApplication *IApplicationSession) IsForeclosed() (bool, error) { + return _IApplication.Contract.IsForeclosed(&_IApplication.CallOpts) +} + +// IsForeclosed is a free data retrieval call binding the contract method 0x83e4fbcd. +// +// Solidity: function isForeclosed() view returns(bool) +func (_IApplication *IApplicationCallerSession) IsForeclosed() (bool, error) { + return _IApplication.Contract.IsForeclosed(&_IApplication.CallOpts) +} + // Owner is a free data retrieval call binding the contract method 0x8da5cb5b. // // Solidity: function owner() view returns(address) @@ -372,6 +680,64 @@ func (_IApplication *IApplicationCallerSession) Owner() (common.Address, error) return _IApplication.Contract.Owner(&_IApplication.CallOpts) } +// ValidateAccount is a free data retrieval call binding the contract method 0x2b639720. +// +// Solidity: function validateAccount(bytes account, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCaller) ValidateAccount(opts *bind.CallOpts, account []byte, proof AccountValidityProof) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateAccount", account, proof) + + if err != nil { + return err + } + + return err + +} + +// ValidateAccount is a free data retrieval call binding the contract method 0x2b639720. +// +// Solidity: function validateAccount(bytes account, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationSession) ValidateAccount(account []byte, proof AccountValidityProof) error { + return _IApplication.Contract.ValidateAccount(&_IApplication.CallOpts, account, proof) +} + +// ValidateAccount is a free data retrieval call binding the contract method 0x2b639720. +// +// Solidity: function validateAccount(bytes account, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCallerSession) ValidateAccount(account []byte, proof AccountValidityProof) error { + return _IApplication.Contract.ValidateAccount(&_IApplication.CallOpts, account, proof) +} + +// ValidateAccountMerkleRoot is a free data retrieval call binding the contract method 0x63b9c3b2. +// +// Solidity: function validateAccountMerkleRoot(bytes32 accountMerkleRoot, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCaller) ValidateAccountMerkleRoot(opts *bind.CallOpts, accountMerkleRoot [32]byte, proof AccountValidityProof) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateAccountMerkleRoot", accountMerkleRoot, proof) + + if err != nil { + return err + } + + return err + +} + +// ValidateAccountMerkleRoot is a free data retrieval call binding the contract method 0x63b9c3b2. +// +// Solidity: function validateAccountMerkleRoot(bytes32 accountMerkleRoot, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationSession) ValidateAccountMerkleRoot(accountMerkleRoot [32]byte, proof AccountValidityProof) error { + return _IApplication.Contract.ValidateAccountMerkleRoot(&_IApplication.CallOpts, accountMerkleRoot, proof) +} + +// ValidateAccountMerkleRoot is a free data retrieval call binding the contract method 0x63b9c3b2. +// +// Solidity: function validateAccountMerkleRoot(bytes32 accountMerkleRoot, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCallerSession) ValidateAccountMerkleRoot(accountMerkleRoot [32]byte, proof AccountValidityProof) error { + return _IApplication.Contract.ValidateAccountMerkleRoot(&_IApplication.CallOpts, accountMerkleRoot, proof) +} + // ValidateOutput is a free data retrieval call binding the contract method 0xe88d39c0. // // Solidity: function validateOutput(bytes output, (uint64,bytes32[]) proof) view returns() @@ -430,6 +796,66 @@ func (_IApplication *IApplicationCallerSession) ValidateOutputHash(outputHash [3 return _IApplication.Contract.ValidateOutputHash(&_IApplication.CallOpts, outputHash, proof) } +// 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 (_IApplication *IApplicationCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// 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 (_IApplication *IApplicationSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IApplication.Contract.Version(&_IApplication.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 (_IApplication *IApplicationCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IApplication.Contract.Version(&_IApplication.CallOpts) +} + // WasOutputExecuted is a free data retrieval call binding the contract method 0x71891db0. // // Solidity: function wasOutputExecuted(uint256 outputIndex) view returns(bool) @@ -461,6 +887,37 @@ func (_IApplication *IApplicationCallerSession) WasOutputExecuted(outputIndex *b return _IApplication.Contract.WasOutputExecuted(&_IApplication.CallOpts, outputIndex) } +// WereAccountFundsWithdrawn is a free data retrieval call binding the contract method 0x8272a6aa. +// +// Solidity: function wereAccountFundsWithdrawn(uint256 accountIndex) view returns(bool) +func (_IApplication *IApplicationCaller) WereAccountFundsWithdrawn(opts *bind.CallOpts, accountIndex *big.Int) (bool, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "wereAccountFundsWithdrawn", accountIndex) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WereAccountFundsWithdrawn is a free data retrieval call binding the contract method 0x8272a6aa. +// +// Solidity: function wereAccountFundsWithdrawn(uint256 accountIndex) view returns(bool) +func (_IApplication *IApplicationSession) WereAccountFundsWithdrawn(accountIndex *big.Int) (bool, error) { + return _IApplication.Contract.WereAccountFundsWithdrawn(&_IApplication.CallOpts, accountIndex) +} + +// WereAccountFundsWithdrawn is a free data retrieval call binding the contract method 0x8272a6aa. +// +// Solidity: function wereAccountFundsWithdrawn(uint256 accountIndex) view returns(bool) +func (_IApplication *IApplicationCallerSession) WereAccountFundsWithdrawn(accountIndex *big.Int) (bool, error) { + return _IApplication.Contract.WereAccountFundsWithdrawn(&_IApplication.CallOpts, accountIndex) +} + // ExecuteOutput is a paid mutator transaction binding the contract method 0x33137b76. // // Solidity: function executeOutput(bytes output, (uint64,bytes32[]) proof) returns() @@ -482,6 +939,27 @@ func (_IApplication *IApplicationTransactorSession) ExecuteOutput(output []byte, return _IApplication.Contract.ExecuteOutput(&_IApplication.TransactOpts, output, proof) } +// Foreclose is a paid mutator transaction binding the contract method 0xeb6266e2. +// +// Solidity: function foreclose() returns() +func (_IApplication *IApplicationTransactor) Foreclose(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "foreclose") +} + +// Foreclose is a paid mutator transaction binding the contract method 0xeb6266e2. +// +// Solidity: function foreclose() returns() +func (_IApplication *IApplicationSession) Foreclose() (*types.Transaction, error) { + return _IApplication.Contract.Foreclose(&_IApplication.TransactOpts) +} + +// Foreclose is a paid mutator transaction binding the contract method 0xeb6266e2. +// +// Solidity: function foreclose() returns() +func (_IApplication *IApplicationTransactorSession) Foreclose() (*types.Transaction, error) { + return _IApplication.Contract.Foreclose(&_IApplication.TransactOpts) +} + // MigrateToOutputsMerkleRootValidator is a paid mutator transaction binding the contract method 0xbf8abff8. // // Solidity: function migrateToOutputsMerkleRootValidator(address newOutputsMerkleRootValidator) returns() @@ -503,6 +981,27 @@ func (_IApplication *IApplicationTransactorSession) MigrateToOutputsMerkleRootVa return _IApplication.Contract.MigrateToOutputsMerkleRootValidator(&_IApplication.TransactOpts, newOutputsMerkleRootValidator) } +// ProveAccountsDriveMerkleRoot is a paid mutator transaction binding the contract method 0xbe77e2c4. +// +// Solidity: function proveAccountsDriveMerkleRoot(bytes32 accountsDriveMerkleRoot, bytes32[] proof) returns() +func (_IApplication *IApplicationTransactor) ProveAccountsDriveMerkleRoot(opts *bind.TransactOpts, accountsDriveMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "proveAccountsDriveMerkleRoot", accountsDriveMerkleRoot, proof) +} + +// ProveAccountsDriveMerkleRoot is a paid mutator transaction binding the contract method 0xbe77e2c4. +// +// Solidity: function proveAccountsDriveMerkleRoot(bytes32 accountsDriveMerkleRoot, bytes32[] proof) returns() +func (_IApplication *IApplicationSession) ProveAccountsDriveMerkleRoot(accountsDriveMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { + return _IApplication.Contract.ProveAccountsDriveMerkleRoot(&_IApplication.TransactOpts, accountsDriveMerkleRoot, proof) +} + +// ProveAccountsDriveMerkleRoot is a paid mutator transaction binding the contract method 0xbe77e2c4. +// +// Solidity: function proveAccountsDriveMerkleRoot(bytes32 accountsDriveMerkleRoot, bytes32[] proof) returns() +func (_IApplication *IApplicationTransactorSession) ProveAccountsDriveMerkleRoot(accountsDriveMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { + return _IApplication.Contract.ProveAccountsDriveMerkleRoot(&_IApplication.TransactOpts, accountsDriveMerkleRoot, proof) +} + // RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. // // Solidity: function renounceOwnership() returns() @@ -545,12 +1044,300 @@ func (_IApplication *IApplicationTransactorSession) TransferOwnership(newOwner c return _IApplication.Contract.TransferOwnership(&_IApplication.TransactOpts, newOwner) } -// IApplicationOutputExecutedIterator is returned from FilterOutputExecuted and is used to iterate over the raw logs and unpacked data for OutputExecuted events raised by the IApplication contract. -type IApplicationOutputExecutedIterator struct { - Event *IApplicationOutputExecuted // 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 +// Withdraw is a paid mutator transaction binding the contract method 0xbcf8023f. +// +// Solidity: function withdraw(bytes account, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationTransactor) Withdraw(opts *bind.TransactOpts, account []byte, proof AccountValidityProof) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "withdraw", account, proof) +} + +// Withdraw is a paid mutator transaction binding the contract method 0xbcf8023f. +// +// Solidity: function withdraw(bytes account, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationSession) Withdraw(account []byte, proof AccountValidityProof) (*types.Transaction, error) { + return _IApplication.Contract.Withdraw(&_IApplication.TransactOpts, account, proof) +} + +// Withdraw is a paid mutator transaction binding the contract method 0xbcf8023f. +// +// Solidity: function withdraw(bytes account, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationTransactorSession) Withdraw(account []byte, proof AccountValidityProof) (*types.Transaction, error) { + return _IApplication.Contract.Withdraw(&_IApplication.TransactOpts, account, proof) +} + +// IApplicationAccountsDriveMerkleRootProvedIterator is returned from FilterAccountsDriveMerkleRootProved and is used to iterate over the raw logs and unpacked data for AccountsDriveMerkleRootProved events raised by the IApplication contract. +type IApplicationAccountsDriveMerkleRootProvedIterator struct { + Event *IApplicationAccountsDriveMerkleRootProved // 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 *IApplicationAccountsDriveMerkleRootProvedIterator) 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(IApplicationAccountsDriveMerkleRootProved) + 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(IApplicationAccountsDriveMerkleRootProved) + 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 *IApplicationAccountsDriveMerkleRootProvedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationAccountsDriveMerkleRootProvedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationAccountsDriveMerkleRootProved represents a AccountsDriveMerkleRootProved event raised by the IApplication contract. +type IApplicationAccountsDriveMerkleRootProved struct { + AccountsDriveMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterAccountsDriveMerkleRootProved is a free log retrieval operation binding the contract event 0x421863fbad9f3586640ffad00109861693263a28f6e97c679f45c3cbf5263594. +// +// Solidity: event AccountsDriveMerkleRootProved(bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationFilterer) FilterAccountsDriveMerkleRootProved(opts *bind.FilterOpts) (*IApplicationAccountsDriveMerkleRootProvedIterator, error) { + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "AccountsDriveMerkleRootProved") + if err != nil { + return nil, err + } + return &IApplicationAccountsDriveMerkleRootProvedIterator{contract: _IApplication.contract, event: "AccountsDriveMerkleRootProved", logs: logs, sub: sub}, nil +} + +// WatchAccountsDriveMerkleRootProved is a free log subscription operation binding the contract event 0x421863fbad9f3586640ffad00109861693263a28f6e97c679f45c3cbf5263594. +// +// Solidity: event AccountsDriveMerkleRootProved(bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationFilterer) WatchAccountsDriveMerkleRootProved(opts *bind.WatchOpts, sink chan<- *IApplicationAccountsDriveMerkleRootProved) (event.Subscription, error) { + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "AccountsDriveMerkleRootProved") + 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(IApplicationAccountsDriveMerkleRootProved) + if err := _IApplication.contract.UnpackLog(event, "AccountsDriveMerkleRootProved", 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 +} + +// ParseAccountsDriveMerkleRootProved is a log parse operation binding the contract event 0x421863fbad9f3586640ffad00109861693263a28f6e97c679f45c3cbf5263594. +// +// Solidity: event AccountsDriveMerkleRootProved(bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationFilterer) ParseAccountsDriveMerkleRootProved(log types.Log) (*IApplicationAccountsDriveMerkleRootProved, error) { + event := new(IApplicationAccountsDriveMerkleRootProved) + if err := _IApplication.contract.UnpackLog(event, "AccountsDriveMerkleRootProved", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IApplicationForeclosureIterator is returned from FilterForeclosure and is used to iterate over the raw logs and unpacked data for Foreclosure events raised by the IApplication contract. +type IApplicationForeclosureIterator struct { + Event *IApplicationForeclosure // 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 *IApplicationForeclosureIterator) 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(IApplicationForeclosure) + 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(IApplicationForeclosure) + 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 *IApplicationForeclosureIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationForeclosureIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationForeclosure represents a Foreclosure event raised by the IApplication contract. +type IApplicationForeclosure struct { + Raw types.Log // Blockchain specific contextual infos +} + +// FilterForeclosure is a free log retrieval operation binding the contract event 0xd10ac0ca4adfcec0fa841963d75fefd049b7cd20555173c0673d9b2bfdb9d3ac. +// +// Solidity: event Foreclosure() +func (_IApplication *IApplicationFilterer) FilterForeclosure(opts *bind.FilterOpts) (*IApplicationForeclosureIterator, error) { + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "Foreclosure") + if err != nil { + return nil, err + } + return &IApplicationForeclosureIterator{contract: _IApplication.contract, event: "Foreclosure", logs: logs, sub: sub}, nil +} + +// WatchForeclosure is a free log subscription operation binding the contract event 0xd10ac0ca4adfcec0fa841963d75fefd049b7cd20555173c0673d9b2bfdb9d3ac. +// +// Solidity: event Foreclosure() +func (_IApplication *IApplicationFilterer) WatchForeclosure(opts *bind.WatchOpts, sink chan<- *IApplicationForeclosure) (event.Subscription, error) { + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "Foreclosure") + 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(IApplicationForeclosure) + if err := _IApplication.contract.UnpackLog(event, "Foreclosure", 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 +} + +// ParseForeclosure is a log parse operation binding the contract event 0xd10ac0ca4adfcec0fa841963d75fefd049b7cd20555173c0673d9b2bfdb9d3ac. +// +// Solidity: event Foreclosure() +func (_IApplication *IApplicationFilterer) ParseForeclosure(log types.Log) (*IApplicationForeclosure, error) { + event := new(IApplicationForeclosure) + if err := _IApplication.contract.UnpackLog(event, "Foreclosure", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IApplicationOutputExecutedIterator is returned from FilterOutputExecuted and is used to iterate over the raw logs and unpacked data for OutputExecuted events raised by the IApplication contract. +type IApplicationOutputExecutedIterator struct { + Event *IApplicationOutputExecuted // 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 @@ -813,3 +1600,139 @@ func (_IApplication *IApplicationFilterer) ParseOutputsMerkleRootValidatorChange 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 + + 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 *IApplicationWithdrawalIterator) 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(IApplicationWithdrawal) + 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(IApplicationWithdrawal) + 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 *IApplicationWithdrawalIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationWithdrawalIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationWithdrawal represents a Withdrawal event raised by the IApplication contract. +type IApplicationWithdrawal struct { + AccountIndex uint64 + Account []byte + Output []byte + Raw types.Log // Blockchain specific contextual infos +} + +// 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) { + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "Withdrawal") + if err != nil { + return nil, err + } + return &IApplicationWithdrawalIterator{contract: _IApplication.contract, event: "Withdrawal", logs: logs, sub: sub}, nil +} + +// 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) { + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "Withdrawal") + 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(IApplicationWithdrawal) + if err := _IApplication.contract.UnpackLog(event, "Withdrawal", 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 +} + +// ParseWithdrawal is a log parse operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. +// +// Solidity: event Withdrawal(uint64 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 { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iapplicationfactory/iapplicationfactory.go b/pkg/contracts/iapplicationfactory/iapplicationfactory.go index b681cb3b8..dc871c872 100644 --- a/pkg/contracts/iapplicationfactory/iapplicationfactory.go +++ b/pkg/contracts/iapplicationfactory/iapplicationfactory.go @@ -29,9 +29,18 @@ var ( _ = abi.ConvertType ) +// WithdrawalConfig is an auto generated low-level Go binding around an user-defined struct. +type WithdrawalConfig struct { + Guardian common.Address + Log2LeavesPerAccount uint8 + Log2MaxNumOfAccounts uint8 + AccountsDriveStartIndex uint64 + WithdrawalOutputBuilder common.Address +} + // 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\":\"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\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"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\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"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\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"}],\"anonymous\":false}]", + 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\"}]}]}]", } // IApplicationFactoryABI is the input ABI used to generate the binding from. @@ -180,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 0x4269667b. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddress(opts *bind.CallOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (common.Address, error) { +// 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) { var out []interface{} - err := _IApplicationFactory.contract.Call(opts, &out, "calculateApplicationAddress", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt) + err := _IApplicationFactory.contract.Call(opts, &out, "calculateApplicationAddress", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) if err != nil { return *new(common.Address), err @@ -197,60 +206,120 @@ func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddre } -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0x4269667b. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactorySession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (common.Address, error) { - return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt) +// 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) } -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0x4269667b. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactoryCallerSession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (common.Address, error) { - return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt) +// 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) } -// NewApplication is a paid mutator transaction binding the contract method 0x2cc3ef7c. +// 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 (_IApplicationFactory *IApplicationFactoryCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IApplicationFactory.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// 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 (_IApplicationFactory *IApplicationFactorySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IApplicationFactory.Contract.Version(&_IApplicationFactory.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 (_IApplicationFactory *IApplicationFactoryCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IApplicationFactory.Contract.Version(&_IApplicationFactory.CallOpts) +} + +// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.contract.Transact(opts, "newApplication", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt) +// 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) } -// NewApplication is a paid mutator transaction binding the contract method 0x2cc3ef7c. +// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactorySession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt) +// 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) } -// NewApplication is a paid mutator transaction binding the contract method 0x2cc3ef7c. +// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, salt) +// 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) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x8d02370d. +// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication0(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte) (*types.Transaction, error) { - return _IApplicationFactory.contract.Transact(opts, "newApplication0", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability) +// 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) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x8d02370d. +// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability) returns(address) -func (_IApplicationFactory *IApplicationFactorySession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability) +// 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) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x8d02370d. +// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability) +// 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) } // 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. @@ -326,13 +395,14 @@ type IApplicationFactoryApplicationCreated struct { AppOwner common.Address TemplateHash [32]byte DataAvailability []byte + WithdrawalConfig WithdrawalConfig AppContract common.Address Raw types.Log // Blockchain specific contextual infos } -// FilterApplicationCreated is a free log retrieval operation binding the contract event 0xd291ffe9436f2c57d5ce3e87ed33576f801053946651a2fb4fec5a406cf68cc5. +// FilterApplicationCreated is a free log retrieval operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) func (_IApplicationFactory *IApplicationFactoryFilterer) FilterApplicationCreated(opts *bind.FilterOpts, outputsMerkleRootValidator []common.Address) (*IApplicationFactoryApplicationCreatedIterator, error) { var outputsMerkleRootValidatorRule []interface{} @@ -347,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 0xd291ffe9436f2c57d5ce3e87ed33576f801053946651a2fb4fec5a406cf68cc5. +// WatchApplicationCreated is a free log subscription operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (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{} @@ -389,9 +459,9 @@ func (_IApplicationFactory *IApplicationFactoryFilterer) WatchApplicationCreated }), nil } -// ParseApplicationCreated is a log parse operation binding the contract event 0xd291ffe9436f2c57d5ce3e87ed33576f801053946651a2fb4fec5a406cf68cc5. +// ParseApplicationCreated is a log parse operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (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 2f29e2425..3e3388c52 100644 --- a/pkg/contracts/iauthority/iauthority.go +++ b/pkg/contracts/iauthority/iauthority.go @@ -29,9 +29,16 @@ var ( _ = abi.ConvertType ) +// IConsensusClaim is an auto generated low-level Go binding around an user-defined struct. +type IConsensusClaim struct { + Status uint8 + StagingBlockNumber *big.Int + StagedOutputsMerkleRoot [32]byte +} + // IAuthorityMetaData contains all meta data concerning the IAuthority contract. var IAuthorityMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[],\"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\"}],\"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\":\"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\"}],\"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\"}],\"anonymous\":false},{\"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\":\"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\"}]}]", } // IAuthorityABI is the input ABI used to generate the binding from. @@ -180,6 +187,68 @@ func (_IAuthority *IAuthorityTransactorRaw) Transact(opts *bind.TransactOpts, me return _IAuthority.Contract.contract.Transact(opts, method, params...) } +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IAuthority *IAuthorityCaller) GetClaim(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) + + if err != nil { + return *new(IConsensusClaim), err + } + + out0 := *abi.ConvertType(out[0], new(IConsensusClaim)).(*IConsensusClaim) + + return out0, err + +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IAuthority *IAuthoritySession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IAuthority.Contract.GetClaim(&_IAuthority.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IAuthority *IAuthorityCallerSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IAuthority.Contract.GetClaim(&_IAuthority.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IAuthority.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 (_IAuthority *IAuthoritySession) GetClaimStagingPeriod() (*big.Int, error) { + return _IAuthority.Contract.GetClaimStagingPeriod(&_IAuthority.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IAuthority.Contract.GetClaimStagingPeriod(&_IAuthority.CallOpts) +} + // GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. // // Solidity: function getEpochLength() view returns(uint256) @@ -211,12 +280,43 @@ func (_IAuthority *IAuthorityCallerSession) GetEpochLength() (*big.Int, error) { return _IAuthority.Contract.GetEpochLength(&_IAuthority.CallOpts) } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IAuthority *IAuthorityCaller) GetLastFinalizedMachineMerkleRoot(opts *bind.CallOpts, appContract common.Address) ([32]byte, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getLastFinalizedMachineMerkleRoot", appContract) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IAuthority *IAuthorityCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts) (*big.Int, error) { +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IAuthority *IAuthoritySession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IAuthority.Contract.GetLastFinalizedMachineMerkleRoot(&_IAuthority.CallOpts, appContract) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IAuthority *IAuthorityCallerSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IAuthority.Contract.GetLastFinalizedMachineMerkleRoot(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { var out []interface{} - err := _IAuthority.contract.Call(opts, &out, "getNumberOfAcceptedClaims") + err := _IAuthority.contract.Call(opts, &out, "getNumberOfAcceptedClaims", appContract) if err != nil { return *new(*big.Int), err @@ -228,18 +328,80 @@ func (_IAuthority *IAuthorityCaller) GetNumberOfAcceptedClaims(opts *bind.CallOp } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IAuthority *IAuthoritySession) GetNumberOfAcceptedClaims() (*big.Int, error) { - return _IAuthority.Contract.GetNumberOfAcceptedClaims(&_IAuthority.CallOpts) +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthoritySession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfAcceptedClaims(&_IAuthority.CallOpts, appContract) } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IAuthority *IAuthorityCallerSession) GetNumberOfAcceptedClaims() (*big.Int, error) { - return _IAuthority.Contract.GetNumberOfAcceptedClaims(&_IAuthority.CallOpts) +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfAcceptedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetNumberOfStagedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getNumberOfStagedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthoritySession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfStagedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfStagedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetNumberOfSubmittedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getNumberOfSubmittedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthoritySession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfSubmittedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfSubmittedClaims(&_IAuthority.CallOpts, appContract) } // IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. @@ -335,6 +497,87 @@ func (_IAuthority *IAuthorityCallerSession) SupportsInterface(interfaceId [4]byt return _IAuthority.Contract.SupportsInterface(&_IAuthority.CallOpts, interfaceId) } +// 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 (_IAuthority *IAuthorityCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// 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 (_IAuthority *IAuthoritySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IAuthority.Contract.Version(&_IAuthority.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 (_IAuthority *IAuthorityCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IAuthority.Contract.Version(&_IAuthority.CallOpts) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IAuthority *IAuthorityTransactor) AcceptClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IAuthority.contract.Transact(opts, "acceptClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IAuthority *IAuthoritySession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IAuthority.Contract.AcceptClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IAuthority *IAuthorityTransactorSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IAuthority.Contract.AcceptClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + // RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. // // Solidity: function renounceOwnership() returns() @@ -356,25 +599,25 @@ func (_IAuthority *IAuthorityTransactorSession) RenounceOwnership() (*types.Tran return _IAuthority.Contract.RenounceOwnership(&_IAuthority.TransactOpts) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IAuthority *IAuthorityTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IAuthority.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// 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) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IAuthority *IAuthoritySession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// 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) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IAuthority *IAuthorityTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// 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) } // TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. @@ -470,12 +713,13 @@ type IAuthorityClaimAccepted struct { AppContract common.Address LastProcessedBlockNumber *big.Int OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte Raw types.Log // Blockchain specific contextual infos } -// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IAuthority *IAuthorityFilterer) FilterClaimAccepted(opts *bind.FilterOpts, appContract []common.Address) (*IAuthorityClaimAcceptedIterator, error) { var appContractRule []interface{} @@ -490,9 +734,9 @@ func (_IAuthority *IAuthorityFilterer) FilterClaimAccepted(opts *bind.FilterOpts return &IAuthorityClaimAcceptedIterator{contract: _IAuthority.contract, event: "ClaimAccepted", logs: logs, sub: sub}, nil } -// WatchClaimAccepted is a free log subscription operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// WatchClaimAccepted is a free log subscription operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IAuthority *IAuthorityFilterer) WatchClaimAccepted(opts *bind.WatchOpts, sink chan<- *IAuthorityClaimAccepted, appContract []common.Address) (event.Subscription, error) { var appContractRule []interface{} @@ -532,9 +776,9 @@ func (_IAuthority *IAuthorityFilterer) WatchClaimAccepted(opts *bind.WatchOpts, }), nil } -// ParseClaimAccepted is a log parse operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// ParseClaimAccepted is a log parse operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IAuthority *IAuthorityFilterer) ParseClaimAccepted(log types.Log) (*IAuthorityClaimAccepted, error) { event := new(IAuthorityClaimAccepted) if err := _IAuthority.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { @@ -544,6 +788,153 @@ func (_IAuthority *IAuthorityFilterer) ParseClaimAccepted(log types.Log) (*IAuth return event, nil } +// IAuthorityClaimStagedIterator is returned from FilterClaimStaged and is used to iterate over the raw logs and unpacked data for ClaimStaged events raised by the IAuthority contract. +type IAuthorityClaimStagedIterator struct { + Event *IAuthorityClaimStaged // 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 *IAuthorityClaimStagedIterator) 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(IAuthorityClaimStaged) + 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(IAuthorityClaimStaged) + 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 *IAuthorityClaimStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IAuthorityClaimStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IAuthorityClaimStaged represents a ClaimStaged event raised by the IAuthority contract. +type IAuthorityClaimStaged struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimStaged is a free log retrieval operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) FilterClaimStaged(opts *bind.FilterOpts, appContract []common.Address) (*IAuthorityClaimStagedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IAuthority.contract.FilterLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return &IAuthorityClaimStagedIterator{contract: _IAuthority.contract, event: "ClaimStaged", logs: logs, sub: sub}, nil +} + +// WatchClaimStaged is a free log subscription operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) WatchClaimStaged(opts *bind.WatchOpts, sink chan<- *IAuthorityClaimStaged, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IAuthority.contract.WatchLogs(opts, "ClaimStaged", appContractRule) + 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(IAuthorityClaimStaged) + if err := _IAuthority.contract.UnpackLog(event, "ClaimStaged", 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 +} + +// ParseClaimStaged is a log parse operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) ParseClaimStaged(log types.Log) (*IAuthorityClaimStaged, error) { + event := new(IAuthorityClaimStaged) + if err := _IAuthority.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // IAuthorityClaimSubmittedIterator is returned from FilterClaimSubmitted and is used to iterate over the raw logs and unpacked data for ClaimSubmitted events raised by the IAuthority contract. type IAuthorityClaimSubmittedIterator struct { Event *IAuthorityClaimSubmitted // Event containing the contract specifics and raw log @@ -617,12 +1008,13 @@ type IAuthorityClaimSubmitted struct { AppContract common.Address LastProcessedBlockNumber *big.Int OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte Raw types.Log // Blockchain specific contextual infos } -// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IAuthority *IAuthorityFilterer) FilterClaimSubmitted(opts *bind.FilterOpts, submitter []common.Address, appContract []common.Address) (*IAuthorityClaimSubmittedIterator, error) { var submitterRule []interface{} @@ -641,9 +1033,9 @@ func (_IAuthority *IAuthorityFilterer) FilterClaimSubmitted(opts *bind.FilterOpt return &IAuthorityClaimSubmittedIterator{contract: _IAuthority.contract, event: "ClaimSubmitted", logs: logs, sub: sub}, nil } -// WatchClaimSubmitted is a free log subscription operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// WatchClaimSubmitted is a free log subscription operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IAuthority *IAuthorityFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, sink chan<- *IAuthorityClaimSubmitted, submitter []common.Address, appContract []common.Address) (event.Subscription, error) { var submitterRule []interface{} @@ -687,9 +1079,9 @@ func (_IAuthority *IAuthorityFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, }), nil } -// ParseClaimSubmitted is a log parse operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// ParseClaimSubmitted is a log parse operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IAuthority *IAuthorityFilterer) ParseClaimSubmitted(log types.Log) (*IAuthorityClaimSubmitted, error) { event := new(IAuthorityClaimSubmitted) if err := _IAuthority.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { diff --git a/pkg/contracts/iauthorityfactory/iauthorityfactory.go b/pkg/contracts/iauthorityfactory/iauthorityfactory.go index b10c5eecc..9d8cea11a 100644 --- a/pkg/contracts/iauthorityfactory/iauthorityfactory.go +++ b/pkg/contracts/iauthorityfactory/iauthorityfactory.go @@ -31,7 +31,7 @@ var ( // IAuthorityFactoryMetaData contains all meta data concerning the IAuthorityFactory contract. var IAuthorityFactoryMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"calculateAuthorityAddress\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newAuthority\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newAuthority\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AuthorityCreated\",\"inputs\":[{\"name\":\"authority\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIAuthority\"}],\"anonymous\":false}]", + ABI: "[{\"type\":\"function\",\"name\":\"calculateAuthorityAddress\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newAuthority\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newAuthority\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"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\":\"AuthorityCreated\",\"inputs\":[{\"name\":\"authority\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIAuthority\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", } // IAuthorityFactoryABI is the input ABI used to generate the binding from. @@ -180,12 +180,12 @@ func (_IAuthorityFactory *IAuthorityFactoryTransactorRaw) Transact(opts *bind.Tr return _IAuthorityFactory.Contract.contract.Transact(opts, method, params...) } -// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0x1442f7bb. +// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0xe771e61b. // -// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, bytes32 salt) view returns(address) -func (_IAuthorityFactory *IAuthorityFactoryCaller) CalculateAuthorityAddress(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, salt [32]byte) (common.Address, error) { +// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IAuthorityFactory *IAuthorityFactoryCaller) CalculateAuthorityAddress(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { var out []interface{} - err := _IAuthorityFactory.contract.Call(opts, &out, "calculateAuthorityAddress", authorityOwner, epochLength, salt) + err := _IAuthorityFactory.contract.Call(opts, &out, "calculateAuthorityAddress", authorityOwner, epochLength, claimStagingPeriod, salt) if err != nil { return *new(common.Address), err @@ -197,60 +197,120 @@ func (_IAuthorityFactory *IAuthorityFactoryCaller) CalculateAuthorityAddress(opt } -// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0x1442f7bb. +// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0xe771e61b. // -// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, bytes32 salt) view returns(address) -func (_IAuthorityFactory *IAuthorityFactorySession) CalculateAuthorityAddress(authorityOwner common.Address, epochLength *big.Int, salt [32]byte) (common.Address, error) { - return _IAuthorityFactory.Contract.CalculateAuthorityAddress(&_IAuthorityFactory.CallOpts, authorityOwner, epochLength, salt) +// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IAuthorityFactory *IAuthorityFactorySession) CalculateAuthorityAddress(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + return _IAuthorityFactory.Contract.CalculateAuthorityAddress(&_IAuthorityFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, salt) } -// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0x1442f7bb. +// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0xe771e61b. // -// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, bytes32 salt) view returns(address) -func (_IAuthorityFactory *IAuthorityFactoryCallerSession) CalculateAuthorityAddress(authorityOwner common.Address, epochLength *big.Int, salt [32]byte) (common.Address, error) { - return _IAuthorityFactory.Contract.CalculateAuthorityAddress(&_IAuthorityFactory.CallOpts, authorityOwner, epochLength, salt) +// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IAuthorityFactory *IAuthorityFactoryCallerSession) CalculateAuthorityAddress(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + return _IAuthorityFactory.Contract.CalculateAuthorityAddress(&_IAuthorityFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, salt) } -// NewAuthority is a paid mutator transaction binding the contract method 0x93d7217c. +// Version is a free data retrieval call binding the contract method 0x54fd4d50. // -// Solidity: function newAuthority(address authorityOwner, uint256 epochLength) returns(address) -func (_IAuthorityFactory *IAuthorityFactoryTransactor) NewAuthority(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int) (*types.Transaction, error) { - return _IAuthorityFactory.contract.Transact(opts, "newAuthority", authorityOwner, epochLength) +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IAuthorityFactory *IAuthorityFactoryCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IAuthorityFactory.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// 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 (_IAuthorityFactory *IAuthorityFactorySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IAuthorityFactory.Contract.Version(&_IAuthorityFactory.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 (_IAuthorityFactory *IAuthorityFactoryCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IAuthorityFactory.Contract.Version(&_IAuthorityFactory.CallOpts) +} + +// NewAuthority is a paid mutator transaction binding the contract method 0x6dbc5ab0. +// +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IAuthorityFactory *IAuthorityFactoryTransactor) NewAuthority(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IAuthorityFactory.contract.Transact(opts, "newAuthority", authorityOwner, epochLength, claimStagingPeriod) } -// NewAuthority is a paid mutator transaction binding the contract method 0x93d7217c. +// NewAuthority is a paid mutator transaction binding the contract method 0x6dbc5ab0. // -// Solidity: function newAuthority(address authorityOwner, uint256 epochLength) returns(address) -func (_IAuthorityFactory *IAuthorityFactorySession) NewAuthority(authorityOwner common.Address, epochLength *big.Int) (*types.Transaction, error) { - return _IAuthorityFactory.Contract.NewAuthority(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength) +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IAuthorityFactory *IAuthorityFactorySession) NewAuthority(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.NewAuthority(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod) } -// NewAuthority is a paid mutator transaction binding the contract method 0x93d7217c. +// NewAuthority is a paid mutator transaction binding the contract method 0x6dbc5ab0. // -// Solidity: function newAuthority(address authorityOwner, uint256 epochLength) returns(address) -func (_IAuthorityFactory *IAuthorityFactoryTransactorSession) NewAuthority(authorityOwner common.Address, epochLength *big.Int) (*types.Transaction, error) { - return _IAuthorityFactory.Contract.NewAuthority(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength) +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IAuthorityFactory *IAuthorityFactoryTransactorSession) NewAuthority(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.NewAuthority(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod) } -// NewAuthority0 is a paid mutator transaction binding the contract method 0xec992668. +// NewAuthority0 is a paid mutator transaction binding the contract method 0x75855f0d. // -// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, bytes32 salt) returns(address) -func (_IAuthorityFactory *IAuthorityFactoryTransactor) NewAuthority0(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, salt [32]byte) (*types.Transaction, error) { - return _IAuthorityFactory.contract.Transact(opts, "newAuthority0", authorityOwner, epochLength, salt) +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IAuthorityFactory *IAuthorityFactoryTransactor) NewAuthority0(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IAuthorityFactory.contract.Transact(opts, "newAuthority0", authorityOwner, epochLength, claimStagingPeriod, salt) } -// NewAuthority0 is a paid mutator transaction binding the contract method 0xec992668. +// NewAuthority0 is a paid mutator transaction binding the contract method 0x75855f0d. // -// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, bytes32 salt) returns(address) -func (_IAuthorityFactory *IAuthorityFactorySession) NewAuthority0(authorityOwner common.Address, epochLength *big.Int, salt [32]byte) (*types.Transaction, error) { - return _IAuthorityFactory.Contract.NewAuthority0(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, salt) +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IAuthorityFactory *IAuthorityFactorySession) NewAuthority0(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.NewAuthority0(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, salt) } -// NewAuthority0 is a paid mutator transaction binding the contract method 0xec992668. +// NewAuthority0 is a paid mutator transaction binding the contract method 0x75855f0d. // -// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, bytes32 salt) returns(address) -func (_IAuthorityFactory *IAuthorityFactoryTransactorSession) NewAuthority0(authorityOwner common.Address, epochLength *big.Int, salt [32]byte) (*types.Transaction, error) { - return _IAuthorityFactory.Contract.NewAuthority0(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, salt) +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IAuthorityFactory *IAuthorityFactoryTransactorSession) NewAuthority0(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.NewAuthority0(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, salt) } // IAuthorityFactoryAuthorityCreatedIterator is returned from FilterAuthorityCreated and is used to iterate over the raw logs and unpacked data for AuthorityCreated events raised by the IAuthorityFactory contract. diff --git a/pkg/contracts/iconsensus/iconsensus.go b/pkg/contracts/iconsensus/iconsensus.go index d0071cdd2..981a7a55e 100644 --- a/pkg/contracts/iconsensus/iconsensus.go +++ b/pkg/contracts/iconsensus/iconsensus.go @@ -29,9 +29,16 @@ var ( _ = abi.ConvertType ) +// IConsensusClaim is an auto generated low-level Go binding around an user-defined struct. +type IConsensusClaim struct { + Status uint8 + StagingBlockNumber *big.Int + StagedOutputsMerkleRoot [32]byte +} + // IConsensusMetaData contains all meta data concerning the IConsensus contract. var IConsensusMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[],\"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\"}],\"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\":\"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\"}],\"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\"}],\"anonymous\":false},{\"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\":\"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\"}]}]", } // IConsensusABI is the input ABI used to generate the binding from. @@ -180,6 +187,68 @@ func (_IConsensus *IConsensusTransactorRaw) Transact(opts *bind.TransactOpts, me return _IConsensus.Contract.contract.Transact(opts, method, params...) } +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IConsensus *IConsensusCaller) GetClaim(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) + + if err != nil { + return *new(IConsensusClaim), err + } + + out0 := *abi.ConvertType(out[0], new(IConsensusClaim)).(*IConsensusClaim) + + return out0, err + +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IConsensus *IConsensusSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IConsensus.Contract.GetClaim(&_IConsensus.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IConsensus *IConsensusCallerSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IConsensus.Contract.GetClaim(&_IConsensus.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IConsensus *IConsensusCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IConsensus.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 (_IConsensus *IConsensusSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IConsensus.Contract.GetClaimStagingPeriod(&_IConsensus.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IConsensus.Contract.GetClaimStagingPeriod(&_IConsensus.CallOpts) +} + // GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. // // Solidity: function getEpochLength() view returns(uint256) @@ -211,12 +280,43 @@ func (_IConsensus *IConsensusCallerSession) GetEpochLength() (*big.Int, error) { return _IConsensus.Contract.GetEpochLength(&_IConsensus.CallOpts) } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IConsensus *IConsensusCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts) (*big.Int, error) { +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IConsensus *IConsensusCaller) GetLastFinalizedMachineMerkleRoot(opts *bind.CallOpts, appContract common.Address) ([32]byte, error) { var out []interface{} - err := _IConsensus.contract.Call(opts, &out, "getNumberOfAcceptedClaims") + err := _IConsensus.contract.Call(opts, &out, "getLastFinalizedMachineMerkleRoot", appContract) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IConsensus *IConsensusSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IConsensus.CallOpts, appContract) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IConsensus *IConsensusCallerSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getNumberOfAcceptedClaims", appContract) if err != nil { return *new(*big.Int), err @@ -228,26 +328,26 @@ func (_IConsensus *IConsensusCaller) GetNumberOfAcceptedClaims(opts *bind.CallOp } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IConsensus *IConsensusSession) GetNumberOfAcceptedClaims() (*big.Int, error) { - return _IConsensus.Contract.GetNumberOfAcceptedClaims(&_IConsensus.CallOpts) +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfAcceptedClaims(&_IConsensus.CallOpts, appContract) } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IConsensus *IConsensusCallerSession) GetNumberOfAcceptedClaims() (*big.Int, error) { - return _IConsensus.Contract.GetNumberOfAcceptedClaims(&_IConsensus.CallOpts) +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfAcceptedClaims(&_IConsensus.CallOpts, appContract) } -// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0xee5e0faa. +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. // -// Solidity: function getNumberOfSubmittedClaims() view returns(uint256) -func (_IConsensus *IConsensusCaller) GetNumberOfSubmittedClaims(opts *bind.CallOpts) (*big.Int, error) { +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCaller) GetNumberOfStagedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { var out []interface{} - err := _IConsensus.contract.Call(opts, &out, "getNumberOfSubmittedClaims") + err := _IConsensus.contract.Call(opts, &out, "getNumberOfStagedClaims", appContract) if err != nil { return *new(*big.Int), err @@ -259,18 +359,49 @@ func (_IConsensus *IConsensusCaller) GetNumberOfSubmittedClaims(opts *bind.CallO } -// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0xee5e0faa. +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfStagedClaims(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. // -// Solidity: function getNumberOfSubmittedClaims() view returns(uint256) -func (_IConsensus *IConsensusSession) GetNumberOfSubmittedClaims() (*big.Int, error) { - return _IConsensus.Contract.GetNumberOfSubmittedClaims(&_IConsensus.CallOpts) +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfStagedClaims(&_IConsensus.CallOpts, appContract) } -// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0xee5e0faa. +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. // -// Solidity: function getNumberOfSubmittedClaims() view returns(uint256) -func (_IConsensus *IConsensusCallerSession) GetNumberOfSubmittedClaims() (*big.Int, error) { - return _IConsensus.Contract.GetNumberOfSubmittedClaims(&_IConsensus.CallOpts) +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCaller) GetNumberOfSubmittedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getNumberOfSubmittedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfSubmittedClaims(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfSubmittedClaims(&_IConsensus.CallOpts, appContract) } // IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. @@ -335,25 +466,106 @@ func (_IConsensus *IConsensusCallerSession) SupportsInterface(interfaceId [4]byt return _IConsensus.Contract.SupportsInterface(&_IConsensus.CallOpts, interfaceId) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// Version is a free data retrieval call binding the contract method 0x54fd4d50. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IConsensus *IConsensusTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IConsensus.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IConsensus *IConsensusCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// 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 (_IConsensus *IConsensusSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IConsensus.Contract.Version(&_IConsensus.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IConsensus *IConsensusSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IConsensus *IConsensusCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IConsensus.Contract.Version(&_IConsensus.CallOpts) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IConsensus *IConsensusTransactor) AcceptClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IConsensus.contract.Transact(opts, "acceptClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IConsensus *IConsensusSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IConsensus.Contract.AcceptClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IConsensus *IConsensusTransactorSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IConsensus.Contract.AcceptClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// +// 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) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// +// 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) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IConsensus *IConsensusTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// 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) } // 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. @@ -428,12 +640,13 @@ type IConsensusClaimAccepted struct { AppContract common.Address LastProcessedBlockNumber *big.Int OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte Raw types.Log // Blockchain specific contextual infos } -// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IConsensus *IConsensusFilterer) FilterClaimAccepted(opts *bind.FilterOpts, appContract []common.Address) (*IConsensusClaimAcceptedIterator, error) { var appContractRule []interface{} @@ -448,9 +661,9 @@ func (_IConsensus *IConsensusFilterer) FilterClaimAccepted(opts *bind.FilterOpts return &IConsensusClaimAcceptedIterator{contract: _IConsensus.contract, event: "ClaimAccepted", logs: logs, sub: sub}, nil } -// WatchClaimAccepted is a free log subscription operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// WatchClaimAccepted is a free log subscription operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IConsensus *IConsensusFilterer) WatchClaimAccepted(opts *bind.WatchOpts, sink chan<- *IConsensusClaimAccepted, appContract []common.Address) (event.Subscription, error) { var appContractRule []interface{} @@ -490,9 +703,9 @@ func (_IConsensus *IConsensusFilterer) WatchClaimAccepted(opts *bind.WatchOpts, }), nil } -// ParseClaimAccepted is a log parse operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// ParseClaimAccepted is a log parse operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IConsensus *IConsensusFilterer) ParseClaimAccepted(log types.Log) (*IConsensusClaimAccepted, error) { event := new(IConsensusClaimAccepted) if err := _IConsensus.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { @@ -502,6 +715,153 @@ func (_IConsensus *IConsensusFilterer) ParseClaimAccepted(log types.Log) (*ICons return event, nil } +// IConsensusClaimStagedIterator is returned from FilterClaimStaged and is used to iterate over the raw logs and unpacked data for ClaimStaged events raised by the IConsensus contract. +type IConsensusClaimStagedIterator struct { + Event *IConsensusClaimStaged // 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 *IConsensusClaimStagedIterator) 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(IConsensusClaimStaged) + 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(IConsensusClaimStaged) + 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 *IConsensusClaimStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IConsensusClaimStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IConsensusClaimStaged represents a ClaimStaged event raised by the IConsensus contract. +type IConsensusClaimStaged struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimStaged is a free log retrieval operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) FilterClaimStaged(opts *bind.FilterOpts, appContract []common.Address) (*IConsensusClaimStagedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IConsensus.contract.FilterLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return &IConsensusClaimStagedIterator{contract: _IConsensus.contract, event: "ClaimStaged", logs: logs, sub: sub}, nil +} + +// WatchClaimStaged is a free log subscription operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) WatchClaimStaged(opts *bind.WatchOpts, sink chan<- *IConsensusClaimStaged, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IConsensus.contract.WatchLogs(opts, "ClaimStaged", appContractRule) + 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(IConsensusClaimStaged) + if err := _IConsensus.contract.UnpackLog(event, "ClaimStaged", 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 +} + +// ParseClaimStaged is a log parse operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) ParseClaimStaged(log types.Log) (*IConsensusClaimStaged, error) { + event := new(IConsensusClaimStaged) + if err := _IConsensus.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // IConsensusClaimSubmittedIterator is returned from FilterClaimSubmitted and is used to iterate over the raw logs and unpacked data for ClaimSubmitted events raised by the IConsensus contract. type IConsensusClaimSubmittedIterator struct { Event *IConsensusClaimSubmitted // Event containing the contract specifics and raw log @@ -575,12 +935,13 @@ type IConsensusClaimSubmitted struct { AppContract common.Address LastProcessedBlockNumber *big.Int OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte Raw types.Log // Blockchain specific contextual infos } -// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IConsensus *IConsensusFilterer) FilterClaimSubmitted(opts *bind.FilterOpts, submitter []common.Address, appContract []common.Address) (*IConsensusClaimSubmittedIterator, error) { var submitterRule []interface{} @@ -599,9 +960,9 @@ func (_IConsensus *IConsensusFilterer) FilterClaimSubmitted(opts *bind.FilterOpt return &IConsensusClaimSubmittedIterator{contract: _IConsensus.contract, event: "ClaimSubmitted", logs: logs, sub: sub}, nil } -// WatchClaimSubmitted is a free log subscription operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// WatchClaimSubmitted is a free log subscription operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IConsensus *IConsensusFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, sink chan<- *IConsensusClaimSubmitted, submitter []common.Address, appContract []common.Address) (event.Subscription, error) { var submitterRule []interface{} @@ -645,9 +1006,9 @@ func (_IConsensus *IConsensusFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, }), nil } -// ParseClaimSubmitted is a log parse operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// ParseClaimSubmitted is a log parse operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IConsensus *IConsensusFilterer) ParseClaimSubmitted(log types.Log) (*IConsensusClaimSubmitted, error) { event := new(IConsensusClaimSubmitted) if err := _IConsensus.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { diff --git a/pkg/contracts/idaveappfactory/idaveappfactory.go b/pkg/contracts/idaveappfactory/idaveappfactory.go index d84b60348..5590683e0 100644 --- a/pkg/contracts/idaveappfactory/idaveappfactory.go +++ b/pkg/contracts/idaveappfactory/idaveappfactory.go @@ -29,9 +29,18 @@ var ( _ = abi.ConvertType ) +// WithdrawalConfig is an auto generated low-level Go binding around an user-defined struct. +type WithdrawalConfig struct { + Guardian common.Address + Log2LeavesPerAccount uint8 + Log2MaxNumOfAccounts uint8 + AccountsDriveStartIndex uint64 + WithdrawalOutputBuilder common.Address +} + // 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\":\"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\":\"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}]", + 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\"}]}]}]", } // IDaveAppFactoryABI is the input ABI used to generate the binding from. @@ -180,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 0x2bbb8279. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bind.CallOpts, templateHash [32]byte, salt [32]byte) (struct { +// 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 { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { var out []interface{} - err := _IDaveAppFactory.contract.Call(opts, &out, "calculateDaveAppAddress", templateHash, salt) + err := _IDaveAppFactory.contract.Call(opts, &out, "calculateDaveAppAddress", templateHash, withdrawalConfig, salt) outstruct := new(struct { AppContractAddress common.Address @@ -205,45 +214,45 @@ func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bin } -// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x2bbb8279. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactorySession) CalculateDaveAppAddress(templateHash [32]byte, salt [32]byte) (struct { +// 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 { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { - return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, salt) + return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, withdrawalConfig, salt) } -// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x2bbb8279. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactoryCallerSession) CalculateDaveAppAddress(templateHash [32]byte, salt [32]byte) (struct { +// 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 { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { - return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, salt) + return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, withdrawalConfig, salt) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xf46cad3a. +// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. // -// Solidity: function newDaveApp(bytes32 templateHash, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactoryTransactor) NewDaveApp(opts *bind.TransactOpts, templateHash [32]byte, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.contract.Transact(opts, "newDaveApp", templateHash, salt) +// 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) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xf46cad3a. +// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. // -// Solidity: function newDaveApp(bytes32 templateHash, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactorySession) NewDaveApp(templateHash [32]byte, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, salt) +// 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) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xf46cad3a. +// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. // -// Solidity: function newDaveApp(bytes32 templateHash, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactoryTransactorSession) NewDaveApp(templateHash [32]byte, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, salt) +// 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) } // 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 b4e792a85..f920a1fc7 100644 --- a/pkg/contracts/idaveconsensus/idaveconsensus.go +++ b/pkg/contracts/idaveconsensus/idaveconsensus.go @@ -31,7 +31,7 @@ var ( // 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\":\"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\":\"ApplicationMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"received\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"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\":\"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\":[]}]", + 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\"}]}]", } // IDaveConsensusABI is the input ABI used to generate the binding from. @@ -378,6 +378,37 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) GetInputBox() (common.Addres return _IDaveConsensus.Contract.GetInputBox(&_IDaveConsensus.CallOpts) } +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusCaller) GetLastFinalizedMachineMerkleRoot(opts *bind.CallOpts, appContract common.Address) ([32]byte, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getLastFinalizedMachineMerkleRoot", appContract) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IDaveConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IDaveConsensus.CallOpts, appContract) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IDaveConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IDaveConsensus.CallOpts, appContract) +} + // GetTournamentFactory is a free data retrieval call binding the contract method 0x813a1aaf. // // Solidity: function getTournamentFactory() view returns(address) diff --git a/pkg/contracts/ierc20metadata/ierc20metadata.go b/pkg/contracts/ierc20metadata/ierc20metadata.go new file mode 100644 index 000000000..33e24b610 --- /dev/null +++ b/pkg/contracts/ierc20metadata/ierc20metadata.go @@ -0,0 +1,738 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package ierc20metadata + +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 +) + +// IERC20MetadataMetaData contains all meta data concerning the IERC20Metadata contract. +var IERC20MetadataMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"allowance\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"approve\",\"inputs\":[{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"balanceOf\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"decimals\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"name\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"symbol\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"totalSupply\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transfer\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferFrom\",\"inputs\":[{\"name\":\"from\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"Approval\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"spender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Transfer\",\"inputs\":[{\"name\":\"from\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false}]", +} + +// IERC20MetadataABI is the input ABI used to generate the binding from. +// Deprecated: Use IERC20MetadataMetaData.ABI instead. +var IERC20MetadataABI = IERC20MetadataMetaData.ABI + +// IERC20Metadata is an auto generated Go binding around an Ethereum contract. +type IERC20Metadata struct { + IERC20MetadataCaller // Read-only binding to the contract + IERC20MetadataTransactor // Write-only binding to the contract + IERC20MetadataFilterer // Log filterer for contract events +} + +// IERC20MetadataCaller is an auto generated read-only Go binding around an Ethereum contract. +type IERC20MetadataCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20MetadataTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IERC20MetadataTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20MetadataFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IERC20MetadataFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20MetadataSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IERC20MetadataSession struct { + Contract *IERC20Metadata // 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 +} + +// IERC20MetadataCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IERC20MetadataCallerSession struct { + Contract *IERC20MetadataCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IERC20MetadataTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IERC20MetadataTransactorSession struct { + Contract *IERC20MetadataTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IERC20MetadataRaw is an auto generated low-level Go binding around an Ethereum contract. +type IERC20MetadataRaw struct { + Contract *IERC20Metadata // Generic contract binding to access the raw methods on +} + +// IERC20MetadataCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IERC20MetadataCallerRaw struct { + Contract *IERC20MetadataCaller // Generic read-only contract binding to access the raw methods on +} + +// IERC20MetadataTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IERC20MetadataTransactorRaw struct { + Contract *IERC20MetadataTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIERC20Metadata creates a new instance of IERC20Metadata, bound to a specific deployed contract. +func NewIERC20Metadata(address common.Address, backend bind.ContractBackend) (*IERC20Metadata, error) { + contract, err := bindIERC20Metadata(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IERC20Metadata{IERC20MetadataCaller: IERC20MetadataCaller{contract: contract}, IERC20MetadataTransactor: IERC20MetadataTransactor{contract: contract}, IERC20MetadataFilterer: IERC20MetadataFilterer{contract: contract}}, nil +} + +// NewIERC20MetadataCaller creates a new read-only instance of IERC20Metadata, bound to a specific deployed contract. +func NewIERC20MetadataCaller(address common.Address, caller bind.ContractCaller) (*IERC20MetadataCaller, error) { + contract, err := bindIERC20Metadata(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IERC20MetadataCaller{contract: contract}, nil +} + +// NewIERC20MetadataTransactor creates a new write-only instance of IERC20Metadata, bound to a specific deployed contract. +func NewIERC20MetadataTransactor(address common.Address, transactor bind.ContractTransactor) (*IERC20MetadataTransactor, error) { + contract, err := bindIERC20Metadata(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IERC20MetadataTransactor{contract: contract}, nil +} + +// NewIERC20MetadataFilterer creates a new log filterer instance of IERC20Metadata, bound to a specific deployed contract. +func NewIERC20MetadataFilterer(address common.Address, filterer bind.ContractFilterer) (*IERC20MetadataFilterer, error) { + contract, err := bindIERC20Metadata(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IERC20MetadataFilterer{contract: contract}, nil +} + +// bindIERC20Metadata binds a generic wrapper to an already deployed contract. +func bindIERC20Metadata(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IERC20MetadataMetaData.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 (_IERC20Metadata *IERC20MetadataRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IERC20Metadata.Contract.IERC20MetadataCaller.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 (_IERC20Metadata *IERC20MetadataRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IERC20Metadata.Contract.IERC20MetadataTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IERC20Metadata *IERC20MetadataRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IERC20Metadata.Contract.IERC20MetadataTransactor.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 (_IERC20Metadata *IERC20MetadataCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IERC20Metadata.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 (_IERC20Metadata *IERC20MetadataTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IERC20Metadata.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IERC20Metadata *IERC20MetadataTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IERC20Metadata.Contract.contract.Transact(opts, method, params...) +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCaller) Allowance(opts *bind.CallOpts, owner common.Address, spender common.Address) (*big.Int, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "allowance", owner, spender) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataSession) Allowance(owner common.Address, spender common.Address) (*big.Int, error) { + return _IERC20Metadata.Contract.Allowance(&_IERC20Metadata.CallOpts, owner, spender) +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCallerSession) Allowance(owner common.Address, spender common.Address) (*big.Int, error) { + return _IERC20Metadata.Contract.Allowance(&_IERC20Metadata.CallOpts, owner, spender) +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address account) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCaller) BalanceOf(opts *bind.CallOpts, account common.Address) (*big.Int, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "balanceOf", account) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address account) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataSession) BalanceOf(account common.Address) (*big.Int, error) { + return _IERC20Metadata.Contract.BalanceOf(&_IERC20Metadata.CallOpts, account) +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address account) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCallerSession) BalanceOf(account common.Address) (*big.Int, error) { + return _IERC20Metadata.Contract.BalanceOf(&_IERC20Metadata.CallOpts, account) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_IERC20Metadata *IERC20MetadataCaller) Decimals(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "decimals") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_IERC20Metadata *IERC20MetadataSession) Decimals() (uint8, error) { + return _IERC20Metadata.Contract.Decimals(&_IERC20Metadata.CallOpts) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_IERC20Metadata *IERC20MetadataCallerSession) Decimals() (uint8, error) { + return _IERC20Metadata.Contract.Decimals(&_IERC20Metadata.CallOpts) +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_IERC20Metadata *IERC20MetadataCaller) Name(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "name") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_IERC20Metadata *IERC20MetadataSession) Name() (string, error) { + return _IERC20Metadata.Contract.Name(&_IERC20Metadata.CallOpts) +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_IERC20Metadata *IERC20MetadataCallerSession) Name() (string, error) { + return _IERC20Metadata.Contract.Name(&_IERC20Metadata.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_IERC20Metadata *IERC20MetadataCaller) Symbol(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "symbol") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_IERC20Metadata *IERC20MetadataSession) Symbol() (string, error) { + return _IERC20Metadata.Contract.Symbol(&_IERC20Metadata.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_IERC20Metadata *IERC20MetadataCallerSession) Symbol() (string, error) { + return _IERC20Metadata.Contract.Symbol(&_IERC20Metadata.CallOpts) +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCaller) TotalSupply(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "totalSupply") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_IERC20Metadata *IERC20MetadataSession) TotalSupply() (*big.Int, error) { + return _IERC20Metadata.Contract.TotalSupply(&_IERC20Metadata.CallOpts) +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCallerSession) TotalSupply() (*big.Int, error) { + return _IERC20Metadata.Contract.TotalSupply(&_IERC20Metadata.CallOpts) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactor) Approve(opts *bind.TransactOpts, spender common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.contract.Transact(opts, "approve", spender, value) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataSession) Approve(spender common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.Approve(&_IERC20Metadata.TransactOpts, spender, value) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactorSession) Approve(spender common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.Approve(&_IERC20Metadata.TransactOpts, spender, value) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactor) Transfer(opts *bind.TransactOpts, to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.contract.Transact(opts, "transfer", to, value) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataSession) Transfer(to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.Transfer(&_IERC20Metadata.TransactOpts, to, value) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactorSession) Transfer(to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.Transfer(&_IERC20Metadata.TransactOpts, to, value) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactor) TransferFrom(opts *bind.TransactOpts, from common.Address, to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.contract.Transact(opts, "transferFrom", from, to, value) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataSession) TransferFrom(from common.Address, to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.TransferFrom(&_IERC20Metadata.TransactOpts, from, to, value) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactorSession) TransferFrom(from common.Address, to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.TransferFrom(&_IERC20Metadata.TransactOpts, from, to, value) +} + +// IERC20MetadataApprovalIterator is returned from FilterApproval and is used to iterate over the raw logs and unpacked data for Approval events raised by the IERC20Metadata contract. +type IERC20MetadataApprovalIterator struct { + Event *IERC20MetadataApproval // 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 *IERC20MetadataApprovalIterator) 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(IERC20MetadataApproval) + 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(IERC20MetadataApproval) + 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 *IERC20MetadataApprovalIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IERC20MetadataApprovalIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IERC20MetadataApproval represents a Approval event raised by the IERC20Metadata contract. +type IERC20MetadataApproval struct { + Owner common.Address + Spender common.Address + Value *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterApproval is a free log retrieval operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) FilterApproval(opts *bind.FilterOpts, owner []common.Address, spender []common.Address) (*IERC20MetadataApprovalIterator, error) { + + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + + logs, sub, err := _IERC20Metadata.contract.FilterLogs(opts, "Approval", ownerRule, spenderRule) + if err != nil { + return nil, err + } + return &IERC20MetadataApprovalIterator{contract: _IERC20Metadata.contract, event: "Approval", logs: logs, sub: sub}, nil +} + +// WatchApproval is a free log subscription operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) WatchApproval(opts *bind.WatchOpts, sink chan<- *IERC20MetadataApproval, owner []common.Address, spender []common.Address) (event.Subscription, error) { + + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + + logs, sub, err := _IERC20Metadata.contract.WatchLogs(opts, "Approval", ownerRule, spenderRule) + 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(IERC20MetadataApproval) + if err := _IERC20Metadata.contract.UnpackLog(event, "Approval", 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 +} + +// ParseApproval is a log parse operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) ParseApproval(log types.Log) (*IERC20MetadataApproval, error) { + event := new(IERC20MetadataApproval) + if err := _IERC20Metadata.contract.UnpackLog(event, "Approval", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IERC20MetadataTransferIterator is returned from FilterTransfer and is used to iterate over the raw logs and unpacked data for Transfer events raised by the IERC20Metadata contract. +type IERC20MetadataTransferIterator struct { + Event *IERC20MetadataTransfer // 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 *IERC20MetadataTransferIterator) 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(IERC20MetadataTransfer) + 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(IERC20MetadataTransfer) + 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 *IERC20MetadataTransferIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IERC20MetadataTransferIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IERC20MetadataTransfer represents a Transfer event raised by the IERC20Metadata contract. +type IERC20MetadataTransfer struct { + From common.Address + To common.Address + Value *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTransfer is a free log retrieval operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) FilterTransfer(opts *bind.FilterOpts, from []common.Address, to []common.Address) (*IERC20MetadataTransferIterator, error) { + + var fromRule []interface{} + for _, fromItem := range from { + fromRule = append(fromRule, fromItem) + } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } + + logs, sub, err := _IERC20Metadata.contract.FilterLogs(opts, "Transfer", fromRule, toRule) + if err != nil { + return nil, err + } + return &IERC20MetadataTransferIterator{contract: _IERC20Metadata.contract, event: "Transfer", logs: logs, sub: sub}, nil +} + +// WatchTransfer is a free log subscription operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) WatchTransfer(opts *bind.WatchOpts, sink chan<- *IERC20MetadataTransfer, from []common.Address, to []common.Address) (event.Subscription, error) { + + var fromRule []interface{} + for _, fromItem := range from { + fromRule = append(fromRule, fromItem) + } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } + + logs, sub, err := _IERC20Metadata.contract.WatchLogs(opts, "Transfer", fromRule, toRule) + 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(IERC20MetadataTransfer) + if err := _IERC20Metadata.contract.UnpackLog(event, "Transfer", 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 +} + +// ParseTransfer is a log parse operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) ParseTransfer(log types.Log) (*IERC20MetadataTransfer, error) { + event := new(IERC20MetadataTransfer) + if err := _IERC20Metadata.contract.UnpackLog(event, "Transfer", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iinputbox/iinputbox.go b/pkg/contracts/iinputbox/iinputbox.go index 951fbe48d..db7999b13 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\":\"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\":\"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\":\"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. @@ -273,6 +273,66 @@ func (_IInputBox *IInputBoxCallerSession) GetNumberOfInputs(appContract common.A return _IInputBox.Contract.GetNumberOfInputs(&_IInputBox.CallOpts, appContract) } +// 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 (_IInputBox *IInputBoxCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IInputBox.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// 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 (_IInputBox *IInputBoxSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IInputBox.Contract.Version(&_IInputBox.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 (_IInputBox *IInputBoxCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IInputBox.Contract.Version(&_IInputBox.CallOpts) +} + // AddInput is a paid mutator transaction binding the contract method 0x1789cd63. // // Solidity: function addInput(address appContract, bytes payload) returns(bytes32) diff --git a/pkg/contracts/iquorum/iquorum.go b/pkg/contracts/iquorum/iquorum.go index 0c915ec0a..3dda3e5d4 100644 --- a/pkg/contracts/iquorum/iquorum.go +++ b/pkg/contracts/iquorum/iquorum.go @@ -29,9 +29,16 @@ var ( _ = abi.ConvertType ) +// IConsensusClaim is an auto generated low-level Go binding around an user-defined struct. +type IConsensusClaim struct { + Status uint8 + StagingBlockNumber *big.Int + StagedOutputsMerkleRoot [32]byte +} + // IQuorumMetaData contains all meta data concerning the IQuorum contract. var IQuorumMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[],\"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\":\"outputsMerkleRoot\",\"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\":\"outputsMerkleRoot\",\"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\"}],\"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\":\"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\"}],\"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\"}],\"anonymous\":false},{\"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\":\"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\"}]}]", } // IQuorumABI is the input ABI used to generate the binding from. @@ -180,6 +187,68 @@ func (_IQuorum *IQuorumTransactorRaw) Transact(opts *bind.TransactOpts, method s return _IQuorum.Contract.contract.Transact(opts, method, params...) } +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IQuorum *IQuorumCaller) GetClaim(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) + + if err != nil { + return *new(IConsensusClaim), err + } + + out0 := *abi.ConvertType(out[0], new(IConsensusClaim)).(*IConsensusClaim) + + return out0, err + +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IQuorum *IQuorumSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IQuorum.Contract.GetClaim(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IQuorum *IQuorumCallerSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IQuorum.Contract.GetClaim(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IQuorum *IQuorumCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IQuorum.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 (_IQuorum *IQuorumSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IQuorum.Contract.GetClaimStagingPeriod(&_IQuorum.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IQuorum.Contract.GetClaimStagingPeriod(&_IQuorum.CallOpts) +} + // GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. // // Solidity: function getEpochLength() view returns(uint256) @@ -211,12 +280,43 @@ func (_IQuorum *IQuorumCallerSession) GetEpochLength() (*big.Int, error) { return _IQuorum.Contract.GetEpochLength(&_IQuorum.CallOpts) } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IQuorum *IQuorumCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts) (*big.Int, error) { +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IQuorum *IQuorumCaller) GetLastFinalizedMachineMerkleRoot(opts *bind.CallOpts, appContract common.Address) ([32]byte, error) { var out []interface{} - err := _IQuorum.contract.Call(opts, &out, "getNumberOfAcceptedClaims") + err := _IQuorum.contract.Call(opts, &out, "getLastFinalizedMachineMerkleRoot", appContract) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IQuorum *IQuorumSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IQuorum.Contract.GetLastFinalizedMachineMerkleRoot(&_IQuorum.CallOpts, appContract) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IQuorum *IQuorumCallerSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IQuorum.Contract.GetLastFinalizedMachineMerkleRoot(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getNumberOfAcceptedClaims", appContract) if err != nil { return *new(*big.Int), err @@ -228,18 +328,80 @@ func (_IQuorum *IQuorumCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts) (* } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IQuorum *IQuorumSession) GetNumberOfAcceptedClaims() (*big.Int, error) { - return _IQuorum.Contract.GetNumberOfAcceptedClaims(&_IQuorum.CallOpts) +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfAcceptedClaims(&_IQuorum.CallOpts, appContract) } -// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0xd574f4d7. +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. // -// Solidity: function getNumberOfAcceptedClaims() view returns(uint256) -func (_IQuorum *IQuorumCallerSession) GetNumberOfAcceptedClaims() (*big.Int, error) { - return _IQuorum.Contract.GetNumberOfAcceptedClaims(&_IQuorum.CallOpts) +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfAcceptedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCaller) GetNumberOfStagedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getNumberOfStagedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfStagedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfStagedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCaller) GetNumberOfSubmittedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getNumberOfSubmittedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfSubmittedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfSubmittedClaims(&_IQuorum.CallOpts, appContract) } // IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. @@ -275,10 +437,10 @@ func (_IQuorum *IQuorumCallerSession) IsOutputsMerkleRootValid(appContract commo // IsValidatorInFavorOf is a free data retrieval call binding the contract method 0x4b84231c. // -// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, uint256 id) view returns(bool) -func (_IQuorum *IQuorumCaller) IsValidatorInFavorOf(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, id *big.Int) (bool, error) { +// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, uint256 id) view returns(bool) +func (_IQuorum *IQuorumCaller) IsValidatorInFavorOf(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, id *big.Int) (bool, error) { var out []interface{} - err := _IQuorum.contract.Call(opts, &out, "isValidatorInFavorOf", appContract, lastProcessedBlockNumber, outputsMerkleRoot, id) + err := _IQuorum.contract.Call(opts, &out, "isValidatorInFavorOf", appContract, lastProcessedBlockNumber, machineMerkleRoot, id) if err != nil { return *new(bool), err @@ -292,16 +454,16 @@ func (_IQuorum *IQuorumCaller) IsValidatorInFavorOf(opts *bind.CallOpts, appCont // IsValidatorInFavorOf is a free data retrieval call binding the contract method 0x4b84231c. // -// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, uint256 id) view returns(bool) -func (_IQuorum *IQuorumSession) IsValidatorInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, id *big.Int) (bool, error) { - return _IQuorum.Contract.IsValidatorInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, id) +// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, uint256 id) view returns(bool) +func (_IQuorum *IQuorumSession) IsValidatorInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, id *big.Int) (bool, error) { + return _IQuorum.Contract.IsValidatorInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, id) } // IsValidatorInFavorOf is a free data retrieval call binding the contract method 0x4b84231c. // -// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, uint256 id) view returns(bool) -func (_IQuorum *IQuorumCallerSession) IsValidatorInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, id *big.Int) (bool, error) { - return _IQuorum.Contract.IsValidatorInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, id) +// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, uint256 id) view returns(bool) +func (_IQuorum *IQuorumCallerSession) IsValidatorInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, id *big.Int) (bool, error) { + return _IQuorum.Contract.IsValidatorInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, id) } // IsValidatorInFavorOfAnyClaimInEpoch is a free data retrieval call binding the contract method 0x4b53459c. @@ -368,10 +530,10 @@ func (_IQuorum *IQuorumCallerSession) NumOfValidators() (*big.Int, error) { // NumOfValidatorsInFavorOf is a free data retrieval call binding the contract method 0x7051bfd5. // -// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) view returns(uint256) -func (_IQuorum *IQuorumCaller) NumOfValidatorsInFavorOf(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*big.Int, error) { +// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns(uint256) +func (_IQuorum *IQuorumCaller) NumOfValidatorsInFavorOf(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*big.Int, error) { var out []interface{} - err := _IQuorum.contract.Call(opts, &out, "numOfValidatorsInFavorOf", appContract, lastProcessedBlockNumber, outputsMerkleRoot) + err := _IQuorum.contract.Call(opts, &out, "numOfValidatorsInFavorOf", appContract, lastProcessedBlockNumber, machineMerkleRoot) if err != nil { return *new(*big.Int), err @@ -385,16 +547,16 @@ func (_IQuorum *IQuorumCaller) NumOfValidatorsInFavorOf(opts *bind.CallOpts, app // NumOfValidatorsInFavorOf is a free data retrieval call binding the contract method 0x7051bfd5. // -// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) view returns(uint256) -func (_IQuorum *IQuorumSession) NumOfValidatorsInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*big.Int, error) { - return _IQuorum.Contract.NumOfValidatorsInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns(uint256) +func (_IQuorum *IQuorumSession) NumOfValidatorsInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*big.Int, error) { + return _IQuorum.Contract.NumOfValidatorsInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) } // NumOfValidatorsInFavorOf is a free data retrieval call binding the contract method 0x7051bfd5. // -// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) view returns(uint256) -func (_IQuorum *IQuorumCallerSession) NumOfValidatorsInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*big.Int, error) { - return _IQuorum.Contract.NumOfValidatorsInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) NumOfValidatorsInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*big.Int, error) { + return _IQuorum.Contract.NumOfValidatorsInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) } // NumOfValidatorsInFavorOfAnyClaimInEpoch is a free data retrieval call binding the contract method 0x446ccbf0. @@ -521,25 +683,106 @@ func (_IQuorum *IQuorumCallerSession) ValidatorId(validator common.Address) (*bi return _IQuorum.Contract.ValidatorId(&_IQuorum.CallOpts, validator) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// Version is a free data retrieval call binding the contract method 0x54fd4d50. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IQuorum *IQuorumTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IQuorum.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IQuorum *IQuorumCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// 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 (_IQuorum *IQuorumSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IQuorum.Contract.Version(&_IQuorum.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IQuorum *IQuorumSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IQuorum *IQuorumCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IQuorum.Contract.Version(&_IQuorum.CallOpts) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IQuorum *IQuorumTransactor) AcceptClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IQuorum.contract.Transact(opts, "acceptClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x6470af00. +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) returns() -func (_IQuorum *IQuorumTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte) (*types.Transaction, error) { - return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot) +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IQuorum *IQuorumSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IQuorum.Contract.AcceptClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IQuorum *IQuorumTransactorSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IQuorum.Contract.AcceptClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// +// 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) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// +// 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) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// +// 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) } // 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. @@ -614,12 +857,13 @@ type IQuorumClaimAccepted struct { AppContract common.Address LastProcessedBlockNumber *big.Int OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte Raw types.Log // Blockchain specific contextual infos } -// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IQuorum *IQuorumFilterer) FilterClaimAccepted(opts *bind.FilterOpts, appContract []common.Address) (*IQuorumClaimAcceptedIterator, error) { var appContractRule []interface{} @@ -634,9 +878,9 @@ func (_IQuorum *IQuorumFilterer) FilterClaimAccepted(opts *bind.FilterOpts, appC return &IQuorumClaimAcceptedIterator{contract: _IQuorum.contract, event: "ClaimAccepted", logs: logs, sub: sub}, nil } -// WatchClaimAccepted is a free log subscription operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// WatchClaimAccepted is a free log subscription operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IQuorum *IQuorumFilterer) WatchClaimAccepted(opts *bind.WatchOpts, sink chan<- *IQuorumClaimAccepted, appContract []common.Address) (event.Subscription, error) { var appContractRule []interface{} @@ -676,9 +920,9 @@ func (_IQuorum *IQuorumFilterer) WatchClaimAccepted(opts *bind.WatchOpts, sink c }), nil } -// ParseClaimAccepted is a log parse operation binding the contract event 0x0f2cd00a405c0d1a66050307b6722c4788db6ed57aa3589a5c38da535cc3ce63. +// ParseClaimAccepted is a log parse operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. // -// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IQuorum *IQuorumFilterer) ParseClaimAccepted(log types.Log) (*IQuorumClaimAccepted, error) { event := new(IQuorumClaimAccepted) if err := _IQuorum.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { @@ -688,6 +932,153 @@ func (_IQuorum *IQuorumFilterer) ParseClaimAccepted(log types.Log) (*IQuorumClai return event, nil } +// IQuorumClaimStagedIterator is returned from FilterClaimStaged and is used to iterate over the raw logs and unpacked data for ClaimStaged events raised by the IQuorum contract. +type IQuorumClaimStagedIterator struct { + Event *IQuorumClaimStaged // 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 *IQuorumClaimStagedIterator) 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(IQuorumClaimStaged) + 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(IQuorumClaimStaged) + 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 *IQuorumClaimStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IQuorumClaimStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IQuorumClaimStaged represents a ClaimStaged event raised by the IQuorum contract. +type IQuorumClaimStaged struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimStaged is a free log retrieval operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) FilterClaimStaged(opts *bind.FilterOpts, appContract []common.Address) (*IQuorumClaimStagedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IQuorum.contract.FilterLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return &IQuorumClaimStagedIterator{contract: _IQuorum.contract, event: "ClaimStaged", logs: logs, sub: sub}, nil +} + +// WatchClaimStaged is a free log subscription operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) WatchClaimStaged(opts *bind.WatchOpts, sink chan<- *IQuorumClaimStaged, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IQuorum.contract.WatchLogs(opts, "ClaimStaged", appContractRule) + 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(IQuorumClaimStaged) + if err := _IQuorum.contract.UnpackLog(event, "ClaimStaged", 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 +} + +// ParseClaimStaged is a log parse operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) ParseClaimStaged(log types.Log) (*IQuorumClaimStaged, error) { + event := new(IQuorumClaimStaged) + if err := _IQuorum.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // IQuorumClaimSubmittedIterator is returned from FilterClaimSubmitted and is used to iterate over the raw logs and unpacked data for ClaimSubmitted events raised by the IQuorum contract. type IQuorumClaimSubmittedIterator struct { Event *IQuorumClaimSubmitted // Event containing the contract specifics and raw log @@ -761,12 +1152,13 @@ type IQuorumClaimSubmitted struct { AppContract common.Address LastProcessedBlockNumber *big.Int OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte Raw types.Log // Blockchain specific contextual infos } -// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IQuorum *IQuorumFilterer) FilterClaimSubmitted(opts *bind.FilterOpts, submitter []common.Address, appContract []common.Address) (*IQuorumClaimSubmittedIterator, error) { var submitterRule []interface{} @@ -785,9 +1177,9 @@ func (_IQuorum *IQuorumFilterer) FilterClaimSubmitted(opts *bind.FilterOpts, sub return &IQuorumClaimSubmittedIterator{contract: _IQuorum.contract, event: "ClaimSubmitted", logs: logs, sub: sub}, nil } -// WatchClaimSubmitted is a free log subscription operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// WatchClaimSubmitted is a free log subscription operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IQuorum *IQuorumFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, sink chan<- *IQuorumClaimSubmitted, submitter []common.Address, appContract []common.Address) (event.Subscription, error) { var submitterRule []interface{} @@ -831,9 +1223,9 @@ func (_IQuorum *IQuorumFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, sink }), nil } -// ParseClaimSubmitted is a log parse operation binding the contract event 0xf4ff953641f10e17dd93c0bc51334cb1f711fdcb4e37992021a5973f7a958f09. +// ParseClaimSubmitted is a log parse operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. // -// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot) +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) func (_IQuorum *IQuorumFilterer) ParseClaimSubmitted(log types.Log) (*IQuorumClaimSubmitted, error) { event := new(IQuorumClaimSubmitted) if err := _IQuorum.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { diff --git a/pkg/contracts/iquorumfactory/iquorumfactory.go b/pkg/contracts/iquorumfactory/iquorumfactory.go new file mode 100644 index 000000000..3fdbc699a --- /dev/null +++ b/pkg/contracts/iquorumfactory/iquorumfactory.go @@ -0,0 +1,448 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iquorumfactory + +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 +) + +// IQuorumFactoryMetaData contains all meta data concerning the IQuorumFactory contract. +var IQuorumFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"calculateQuorumAddress\",\"inputs\":[{\"name\":\"validators\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newQuorum\",\"inputs\":[{\"name\":\"validators\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIQuorum\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newQuorum\",\"inputs\":[{\"name\":\"validators\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIQuorum\"}],\"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\":\"QuorumCreated\",\"inputs\":[{\"name\":\"quorum\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIQuorum\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"EmptyQuorum\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ZeroAddressValidator\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", +} + +// IQuorumFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use IQuorumFactoryMetaData.ABI instead. +var IQuorumFactoryABI = IQuorumFactoryMetaData.ABI + +// IQuorumFactory is an auto generated Go binding around an Ethereum contract. +type IQuorumFactory struct { + IQuorumFactoryCaller // Read-only binding to the contract + IQuorumFactoryTransactor // Write-only binding to the contract + IQuorumFactoryFilterer // Log filterer for contract events +} + +// IQuorumFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type IQuorumFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IQuorumFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IQuorumFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IQuorumFactorySession struct { + Contract *IQuorumFactory // 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 +} + +// IQuorumFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IQuorumFactoryCallerSession struct { + Contract *IQuorumFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IQuorumFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IQuorumFactoryTransactorSession struct { + Contract *IQuorumFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IQuorumFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type IQuorumFactoryRaw struct { + Contract *IQuorumFactory // Generic contract binding to access the raw methods on +} + +// IQuorumFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IQuorumFactoryCallerRaw struct { + Contract *IQuorumFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// IQuorumFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IQuorumFactoryTransactorRaw struct { + Contract *IQuorumFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIQuorumFactory creates a new instance of IQuorumFactory, bound to a specific deployed contract. +func NewIQuorumFactory(address common.Address, backend bind.ContractBackend) (*IQuorumFactory, error) { + contract, err := bindIQuorumFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IQuorumFactory{IQuorumFactoryCaller: IQuorumFactoryCaller{contract: contract}, IQuorumFactoryTransactor: IQuorumFactoryTransactor{contract: contract}, IQuorumFactoryFilterer: IQuorumFactoryFilterer{contract: contract}}, nil +} + +// NewIQuorumFactoryCaller creates a new read-only instance of IQuorumFactory, bound to a specific deployed contract. +func NewIQuorumFactoryCaller(address common.Address, caller bind.ContractCaller) (*IQuorumFactoryCaller, error) { + contract, err := bindIQuorumFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IQuorumFactoryCaller{contract: contract}, nil +} + +// NewIQuorumFactoryTransactor creates a new write-only instance of IQuorumFactory, bound to a specific deployed contract. +func NewIQuorumFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*IQuorumFactoryTransactor, error) { + contract, err := bindIQuorumFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IQuorumFactoryTransactor{contract: contract}, nil +} + +// NewIQuorumFactoryFilterer creates a new log filterer instance of IQuorumFactory, bound to a specific deployed contract. +func NewIQuorumFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*IQuorumFactoryFilterer, error) { + contract, err := bindIQuorumFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IQuorumFactoryFilterer{contract: contract}, nil +} + +// bindIQuorumFactory binds a generic wrapper to an already deployed contract. +func bindIQuorumFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IQuorumFactoryMetaData.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 (_IQuorumFactory *IQuorumFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IQuorumFactory.Contract.IQuorumFactoryCaller.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 (_IQuorumFactory *IQuorumFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IQuorumFactory.Contract.IQuorumFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IQuorumFactory *IQuorumFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IQuorumFactory.Contract.IQuorumFactoryTransactor.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 (_IQuorumFactory *IQuorumFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IQuorumFactory.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 (_IQuorumFactory *IQuorumFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IQuorumFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IQuorumFactory *IQuorumFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IQuorumFactory.Contract.contract.Transact(opts, method, params...) +} + +// CalculateQuorumAddress is a free data retrieval call binding the contract method 0xdbf30807. +// +// Solidity: function calculateQuorumAddress(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IQuorumFactory *IQuorumFactoryCaller) CalculateQuorumAddress(opts *bind.CallOpts, validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + var out []interface{} + err := _IQuorumFactory.contract.Call(opts, &out, "calculateQuorumAddress", validators, epochLength, claimStagingPeriod, salt) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// CalculateQuorumAddress is a free data retrieval call binding the contract method 0xdbf30807. +// +// Solidity: function calculateQuorumAddress(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IQuorumFactory *IQuorumFactorySession) CalculateQuorumAddress(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + return _IQuorumFactory.Contract.CalculateQuorumAddress(&_IQuorumFactory.CallOpts, validators, epochLength, claimStagingPeriod, salt) +} + +// CalculateQuorumAddress is a free data retrieval call binding the contract method 0xdbf30807. +// +// Solidity: function calculateQuorumAddress(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IQuorumFactory *IQuorumFactoryCallerSession) CalculateQuorumAddress(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + return _IQuorumFactory.Contract.CalculateQuorumAddress(&_IQuorumFactory.CallOpts, validators, epochLength, claimStagingPeriod, salt) +} + +// 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 (_IQuorumFactory *IQuorumFactoryCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IQuorumFactory.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// 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 (_IQuorumFactory *IQuorumFactorySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IQuorumFactory.Contract.Version(&_IQuorumFactory.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 (_IQuorumFactory *IQuorumFactoryCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IQuorumFactory.Contract.Version(&_IQuorumFactory.CallOpts) +} + +// NewQuorum is a paid mutator transaction binding the contract method 0x0f726dd4. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IQuorumFactory *IQuorumFactoryTransactor) NewQuorum(opts *bind.TransactOpts, validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IQuorumFactory.contract.Transact(opts, "newQuorum", validators, epochLength, claimStagingPeriod, salt) +} + +// NewQuorum is a paid mutator transaction binding the contract method 0x0f726dd4. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IQuorumFactory *IQuorumFactorySession) NewQuorum(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IQuorumFactory.Contract.NewQuorum(&_IQuorumFactory.TransactOpts, validators, epochLength, claimStagingPeriod, salt) +} + +// NewQuorum is a paid mutator transaction binding the contract method 0x0f726dd4. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IQuorumFactory *IQuorumFactoryTransactorSession) NewQuorum(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IQuorumFactory.Contract.NewQuorum(&_IQuorumFactory.TransactOpts, validators, epochLength, claimStagingPeriod, salt) +} + +// NewQuorum0 is a paid mutator transaction binding the contract method 0xae123219. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IQuorumFactory *IQuorumFactoryTransactor) NewQuorum0(opts *bind.TransactOpts, validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IQuorumFactory.contract.Transact(opts, "newQuorum0", validators, epochLength, claimStagingPeriod) +} + +// NewQuorum0 is a paid mutator transaction binding the contract method 0xae123219. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IQuorumFactory *IQuorumFactorySession) NewQuorum0(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IQuorumFactory.Contract.NewQuorum0(&_IQuorumFactory.TransactOpts, validators, epochLength, claimStagingPeriod) +} + +// NewQuorum0 is a paid mutator transaction binding the contract method 0xae123219. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IQuorumFactory *IQuorumFactoryTransactorSession) NewQuorum0(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IQuorumFactory.Contract.NewQuorum0(&_IQuorumFactory.TransactOpts, validators, epochLength, claimStagingPeriod) +} + +// IQuorumFactoryQuorumCreatedIterator is returned from FilterQuorumCreated and is used to iterate over the raw logs and unpacked data for QuorumCreated events raised by the IQuorumFactory contract. +type IQuorumFactoryQuorumCreatedIterator struct { + Event *IQuorumFactoryQuorumCreated // 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 *IQuorumFactoryQuorumCreatedIterator) 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(IQuorumFactoryQuorumCreated) + 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(IQuorumFactoryQuorumCreated) + 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 *IQuorumFactoryQuorumCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IQuorumFactoryQuorumCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IQuorumFactoryQuorumCreated represents a QuorumCreated event raised by the IQuorumFactory contract. +type IQuorumFactoryQuorumCreated struct { + Quorum common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterQuorumCreated is a free log retrieval operation binding the contract event 0x446698b70271bce331e53210572bd37ac8c590b6cdca2e6763e6448243cba802. +// +// Solidity: event QuorumCreated(address quorum) +func (_IQuorumFactory *IQuorumFactoryFilterer) FilterQuorumCreated(opts *bind.FilterOpts) (*IQuorumFactoryQuorumCreatedIterator, error) { + + logs, sub, err := _IQuorumFactory.contract.FilterLogs(opts, "QuorumCreated") + if err != nil { + return nil, err + } + return &IQuorumFactoryQuorumCreatedIterator{contract: _IQuorumFactory.contract, event: "QuorumCreated", logs: logs, sub: sub}, nil +} + +// WatchQuorumCreated is a free log subscription operation binding the contract event 0x446698b70271bce331e53210572bd37ac8c590b6cdca2e6763e6448243cba802. +// +// Solidity: event QuorumCreated(address quorum) +func (_IQuorumFactory *IQuorumFactoryFilterer) WatchQuorumCreated(opts *bind.WatchOpts, sink chan<- *IQuorumFactoryQuorumCreated) (event.Subscription, error) { + + logs, sub, err := _IQuorumFactory.contract.WatchLogs(opts, "QuorumCreated") + 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(IQuorumFactoryQuorumCreated) + if err := _IQuorumFactory.contract.UnpackLog(event, "QuorumCreated", 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 +} + +// ParseQuorumCreated is a log parse operation binding the contract event 0x446698b70271bce331e53210572bd37ac8c590b6cdca2e6763e6448243cba802. +// +// Solidity: event QuorumCreated(address quorum) +func (_IQuorumFactory *IQuorumFactoryFilterer) ParseQuorumCreated(log types.Log) (*IQuorumFactoryQuorumCreated, error) { + event := new(IQuorumFactoryQuorumCreated) + if err := _IQuorumFactory.contract.UnpackLog(event, "QuorumCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go b/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go index 7cf8196b9..543289bfe 100644 --- a/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go +++ b/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go @@ -29,9 +29,18 @@ var ( _ = abi.ConvertType ) +// WithdrawalConfig is an auto generated low-level Go binding around an user-defined struct. +type WithdrawalConfig struct { + Guardian common.Address + Log2LeavesPerAccount uint8 + Log2MaxNumOfAccounts uint8 + AccountsDriveStartIndex uint64 + WithdrawalOutputBuilder common.Address +} + // 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\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"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\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"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\"}]", + 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\":[]}]", } // ISelfHostedApplicationFactoryABI is the input ABI used to generate the binding from. @@ -180,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 0x938f7adc. +// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) CalculateAddresses(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (common.Address, common.Address, error) { +// 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) { var out []interface{} - err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "calculateAddresses", authorityOwner, epochLength, appOwner, templateHash, dataAvailability, salt) + err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "calculateAddresses", authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) if err != nil { return *new(common.Address), *new(common.Address), err @@ -198,18 +207,18 @@ func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) Calcu } -// CalculateAddresses is a free data retrieval call binding the contract method 0x938f7adc. +// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (common.Address, common.Address, error) { - return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, appOwner, templateHash, dataAvailability, salt) +// 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) } -// CalculateAddresses is a free data retrieval call binding the contract method 0x938f7adc. +// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (common.Address, common.Address, error) { - return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, appOwner, templateHash, dataAvailability, salt) +// 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) } // GetApplicationFactory is a free data retrieval call binding the contract method 0xe63d50ff. @@ -274,23 +283,83 @@ func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession return _ISelfHostedApplicationFactory.Contract.GetAuthorityFactory(&_ISelfHostedApplicationFactory.CallOpts) } -// DeployContracts is a paid mutator transaction binding the contract method 0x50567b3a. +// 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 (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// 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 (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _ISelfHostedApplicationFactory.Contract.Version(&_ISelfHostedApplicationFactory.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 (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _ISelfHostedApplicationFactory.Contract.Version(&_ISelfHostedApplicationFactory.CallOpts) +} + +// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactor) DeployContracts(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.contract.Transact(opts, "deployContracts", authorityOwner, epochLength, appOwner, templateHash, dataAvailability, salt) +// 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) } -// DeployContracts is a paid mutator transaction binding the contract method 0x50567b3a. +// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, appOwner, templateHash, dataAvailability, salt) +// 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) } -// DeployContracts is a paid mutator transaction binding the contract method 0x50567b3a. +// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, address appOwner, bytes32 templateHash, bytes dataAvailability, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorSession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, appOwner, templateHash, dataAvailability, salt) +// 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) } diff --git a/pkg/contracts/itournament/itournament.go b/pkg/contracts/itournament/itournament.go index fc3ec7ce0..c26f6fe5a 100644 --- a/pkg/contracts/itournament/itournament.go +++ b/pkg/contracts/itournament/itournament.go @@ -84,7 +84,7 @@ type MatchState struct { // 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\":\"BothClocksHaveNotTimedOut\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentCannotBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentMustBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ClockNotTimedOut\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CommitmentFinalStateMismatch\",\"inputs\":[{\"name\":\"received\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"expected\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"CommitmentProofWrongSize\",\"inputs\":[{\"name\":\"received\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expected\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"CommitmentStateMismatch\",\"inputs\":[{\"name\":\"received\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"expected\",\"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\":\"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\":\"LengthMismatch\",\"inputs\":[{\"name\":\"treeHeight\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"siblingsLength\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"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\":\"commitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"parent\",\"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\":\"commitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"computed\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"claimed\",\"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\":\"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\"}]}]", } // ITournamentABI is the input ABI used to generate the binding from. diff --git a/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go b/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go new file mode 100644 index 000000000..4093256fa --- /dev/null +++ b/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go @@ -0,0 +1,303 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iusdwithdrawaloutputbuilder + +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 +) + +// 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\"}]}]", +} + +// IUsdWithdrawalOutputBuilderABI is the input ABI used to generate the binding from. +// Deprecated: Use IUsdWithdrawalOutputBuilderMetaData.ABI instead. +var IUsdWithdrawalOutputBuilderABI = IUsdWithdrawalOutputBuilderMetaData.ABI + +// IUsdWithdrawalOutputBuilder is an auto generated Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilder struct { + IUsdWithdrawalOutputBuilderCaller // Read-only binding to the contract + IUsdWithdrawalOutputBuilderTransactor // Write-only binding to the contract + IUsdWithdrawalOutputBuilderFilterer // Log filterer for contract events +} + +// IUsdWithdrawalOutputBuilderCaller is an auto generated read-only Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IUsdWithdrawalOutputBuilderTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IUsdWithdrawalOutputBuilderFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IUsdWithdrawalOutputBuilderFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IUsdWithdrawalOutputBuilderSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IUsdWithdrawalOutputBuilderSession struct { + Contract *IUsdWithdrawalOutputBuilder // 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 +} + +// IUsdWithdrawalOutputBuilderCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IUsdWithdrawalOutputBuilderCallerSession struct { + Contract *IUsdWithdrawalOutputBuilderCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IUsdWithdrawalOutputBuilderTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IUsdWithdrawalOutputBuilderTransactorSession struct { + Contract *IUsdWithdrawalOutputBuilderTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IUsdWithdrawalOutputBuilderRaw is an auto generated low-level Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderRaw struct { + Contract *IUsdWithdrawalOutputBuilder // Generic contract binding to access the raw methods on +} + +// IUsdWithdrawalOutputBuilderCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderCallerRaw struct { + Contract *IUsdWithdrawalOutputBuilderCaller // Generic read-only contract binding to access the raw methods on +} + +// IUsdWithdrawalOutputBuilderTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderTransactorRaw struct { + Contract *IUsdWithdrawalOutputBuilderTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIUsdWithdrawalOutputBuilder creates a new instance of IUsdWithdrawalOutputBuilder, bound to a specific deployed contract. +func NewIUsdWithdrawalOutputBuilder(address common.Address, backend bind.ContractBackend) (*IUsdWithdrawalOutputBuilder, error) { + contract, err := bindIUsdWithdrawalOutputBuilder(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IUsdWithdrawalOutputBuilder{IUsdWithdrawalOutputBuilderCaller: IUsdWithdrawalOutputBuilderCaller{contract: contract}, IUsdWithdrawalOutputBuilderTransactor: IUsdWithdrawalOutputBuilderTransactor{contract: contract}, IUsdWithdrawalOutputBuilderFilterer: IUsdWithdrawalOutputBuilderFilterer{contract: contract}}, nil +} + +// NewIUsdWithdrawalOutputBuilderCaller creates a new read-only instance of IUsdWithdrawalOutputBuilder, bound to a specific deployed contract. +func NewIUsdWithdrawalOutputBuilderCaller(address common.Address, caller bind.ContractCaller) (*IUsdWithdrawalOutputBuilderCaller, error) { + contract, err := bindIUsdWithdrawalOutputBuilder(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IUsdWithdrawalOutputBuilderCaller{contract: contract}, nil +} + +// NewIUsdWithdrawalOutputBuilderTransactor creates a new write-only instance of IUsdWithdrawalOutputBuilder, bound to a specific deployed contract. +func NewIUsdWithdrawalOutputBuilderTransactor(address common.Address, transactor bind.ContractTransactor) (*IUsdWithdrawalOutputBuilderTransactor, error) { + contract, err := bindIUsdWithdrawalOutputBuilder(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IUsdWithdrawalOutputBuilderTransactor{contract: contract}, nil +} + +// NewIUsdWithdrawalOutputBuilderFilterer creates a new log filterer instance of IUsdWithdrawalOutputBuilder, bound to a specific deployed contract. +func NewIUsdWithdrawalOutputBuilderFilterer(address common.Address, filterer bind.ContractFilterer) (*IUsdWithdrawalOutputBuilderFilterer, error) { + contract, err := bindIUsdWithdrawalOutputBuilder(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IUsdWithdrawalOutputBuilderFilterer{contract: contract}, nil +} + +// bindIUsdWithdrawalOutputBuilder binds a generic wrapper to an already deployed contract. +func bindIUsdWithdrawalOutputBuilder(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IUsdWithdrawalOutputBuilderMetaData.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 (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IUsdWithdrawalOutputBuilder.Contract.IUsdWithdrawalOutputBuilderCaller.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 (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IUsdWithdrawalOutputBuilder.Contract.IUsdWithdrawalOutputBuilderTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IUsdWithdrawalOutputBuilder.Contract.IUsdWithdrawalOutputBuilderTransactor.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 (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IUsdWithdrawalOutputBuilder.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 (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IUsdWithdrawalOutputBuilder.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IUsdWithdrawalOutputBuilder.Contract.contract.Transact(opts, method, params...) +} + +// BuildWithdrawalOutput is a free data retrieval call binding the contract method 0x1d2675a3. +// +// Solidity: function buildWithdrawalOutput(address appContract, bytes account) view returns(bytes output) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCaller) BuildWithdrawalOutput(opts *bind.CallOpts, appContract common.Address, account []byte) ([]byte, error) { + var out []interface{} + err := _IUsdWithdrawalOutputBuilder.contract.Call(opts, &out, "buildWithdrawalOutput", appContract, account) + + if err != nil { + return *new([]byte), err + } + + out0 := *abi.ConvertType(out[0], new([]byte)).(*[]byte) + + return out0, err + +} + +// BuildWithdrawalOutput is a free data retrieval call binding the contract method 0x1d2675a3. +// +// Solidity: function buildWithdrawalOutput(address appContract, bytes account) view returns(bytes output) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderSession) BuildWithdrawalOutput(appContract common.Address, account []byte) ([]byte, error) { + return _IUsdWithdrawalOutputBuilder.Contract.BuildWithdrawalOutput(&_IUsdWithdrawalOutputBuilder.CallOpts, appContract, account) +} + +// BuildWithdrawalOutput is a free data retrieval call binding the contract method 0x1d2675a3. +// +// Solidity: function buildWithdrawalOutput(address appContract, bytes account) view returns(bytes output) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCallerSession) BuildWithdrawalOutput(appContract common.Address, account []byte) ([]byte, error) { + return _IUsdWithdrawalOutputBuilder.Contract.BuildWithdrawalOutput(&_IUsdWithdrawalOutputBuilder.CallOpts, appContract, account) +} + +// Token is a free data retrieval call binding the contract method 0xfc0c546a. +// +// Solidity: function token() view returns(address) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCaller) Token(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IUsdWithdrawalOutputBuilder.contract.Call(opts, &out, "token") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// Token is a free data retrieval call binding the contract method 0xfc0c546a. +// +// Solidity: function token() view returns(address) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderSession) Token() (common.Address, error) { + return _IUsdWithdrawalOutputBuilder.Contract.Token(&_IUsdWithdrawalOutputBuilder.CallOpts) +} + +// Token is a free data retrieval call binding the contract method 0xfc0c546a. +// +// Solidity: function token() view returns(address) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCallerSession) Token() (common.Address, error) { + return _IUsdWithdrawalOutputBuilder.Contract.Token(&_IUsdWithdrawalOutputBuilder.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 (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IUsdWithdrawalOutputBuilder.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// 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 (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IUsdWithdrawalOutputBuilder.Contract.Version(&_IUsdWithdrawalOutputBuilder.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 (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IUsdWithdrawalOutputBuilder.Contract.Version(&_IUsdWithdrawalOutputBuilder.CallOpts) +} From a1eeeee209a455d648e94bed4016477986459c30 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:02:48 -0300 Subject: [PATCH 03/16] feat(ethutil): add v3 withdrawal config and staging helpers --- pkg/ethutil/application.go | 38 ++++-- pkg/ethutil/authority.go | 18 +-- pkg/ethutil/ethutil.go | 34 ++++- pkg/ethutil/prt.go | 18 ++- pkg/ethutil/quorum.go | 94 ++++++++++++++ pkg/ethutil/rpcerror.go | 27 ++++ pkg/ethutil/rpcerror_test.go | 37 ++++++ pkg/ethutil/selfhosted.go | 56 ++++++-- pkg/ethutil/withdrawal_account.go | 138 ++++++++++++++++++++ pkg/ethutil/withdrawal_account_test.go | 33 +++++ pkg/ethutil/withdrawal_config.go | 170 +++++++++++++++++++++++++ pkg/ethutil/withdrawal_config_test.go | 131 +++++++++++++++++++ 12 files changed, 762 insertions(+), 32 deletions(-) create mode 100644 pkg/ethutil/quorum.go create mode 100644 pkg/ethutil/withdrawal_account.go create mode 100644 pkg/ethutil/withdrawal_account_test.go create mode 100644 pkg/ethutil/withdrawal_config.go create mode 100644 pkg/ethutil/withdrawal_config_test.go diff --git a/pkg/ethutil/application.go b/pkg/ethutil/application.go index fcdcd6120..6ba79b43e 100644 --- a/pkg/ethutil/application.go +++ b/pkg/ethutil/application.go @@ -20,17 +20,20 @@ type IApplicationDeployment interface { type IApplicationDeploymentResult interface{} 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"` - Salt SaltBytes `json:"salt"` + 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"` // needed by model.Application - InputBoxAddress common.Address `json:"inputbox_address"` - IInputBoxBlock uint64 `json:"inputbox_block"` - EpochLength uint64 `json:"epoch_length"` + InputBoxAddress common.Address `json:"inputbox_address"` + IInputBoxBlock uint64 `json:"inputbox_block"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + ConsensusType string `json:"consensus_type,omitempty"` Verbose bool } @@ -52,6 +55,9 @@ func (me *ApplicationDeployment) String() string { 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 != "" { + result += fmt.Sprintf("\tconsensus type: %v\n", me.ConsensusType) + } } return result } @@ -75,8 +81,15 @@ func (me *ApplicationDeployment) Deploy( return zero, nil, fmt.Errorf("failed to instantiate contract: %v", err) } + if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { + return zero, nil, err + } + if err := CheckWithdrawalOutputBuilderCode(ctx, client, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + // check if addresses are available (have no code) - applicationAddress, err := factory.CalculateApplicationAddress(nil, me.Consensus, me.OwnerAddress, me.TemplateHash, me.DataAvailability, me.Salt) + applicationAddress, err := factory.CalculateApplicationAddress(nil, me.Consensus, me.OwnerAddress, me.TemplateHash, me.DataAvailability, me.WithdrawalConfig, me.Salt) if err != nil { return zero, nil, err } @@ -90,7 +103,7 @@ func (me *ApplicationDeployment) Deploy( } // deploy the contracts - tx, err := factory.NewApplication(txOpts, me.Consensus, me.OwnerAddress, me.TemplateHash, me.DataAvailability, me.Salt) + tx, err := factory.NewApplication0(txOpts, me.Consensus, me.OwnerAddress, me.TemplateHash, me.DataAvailability, me.WithdrawalConfig, me.Salt) if err != nil { return zero, nil, fmt.Errorf("transaction failed: %v", err) } @@ -112,6 +125,9 @@ func (me *ApplicationDeployment) Deploy( continue // Skip logs that don't match } result.ApplicationAddress = event.AppContract + if err := VerifyDeployedWithdrawalConfig(ctx, client, result.ApplicationAddress, me.WithdrawalConfig); err != nil { + return zero, nil, err + } return result.ApplicationAddress, result, nil } return zero, nil, fmt.Errorf("failed to find ApplicationCreated event in receipt logs") diff --git a/pkg/ethutil/authority.go b/pkg/ethutil/authority.go index efb466958..3d42799fa 100644 --- a/pkg/ethutil/authority.go +++ b/pkg/ethutil/authority.go @@ -14,12 +14,13 @@ import ( ) type AuthorityDeployment struct { - Address common.Address `json:"address"` - FactoryAddress common.Address `json:"factory"` - OwnerAddress common.Address `json:"owner"` - EpochLength uint64 `json:"epoch_length"` - Salt SaltBytes `json:"salt"` - Verbose bool `json:"-"` + Address common.Address `json:"address"` + FactoryAddress common.Address `json:"factory"` + OwnerAddress common.Address `json:"owner"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + Salt SaltBytes `json:"salt"` + Verbose bool `json:"-"` } func (me *AuthorityDeployment) String() string { @@ -30,6 +31,7 @@ func (me *AuthorityDeployment) String() string { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) 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) } return result } @@ -46,7 +48,7 @@ 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), me.Salt) + authorityAddress, err := factory.CalculateAuthorityAddress(nil, me.OwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), me.Salt) if err != nil { return zero, err } @@ -60,7 +62,7 @@ func (me *AuthorityDeployment) Deploy( } // deploy the contracts - tx, err := factory.NewAuthority0(txOpts, me.OwnerAddress, new(big.Int).SetUint64(me.EpochLength), me.Salt) + tx, err := factory.NewAuthority0(txOpts, me.OwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), me.Salt) if err != nil { return common.Address{}, fmt.Errorf("failed to create new authority: %v", err) } diff --git a/pkg/ethutil/ethutil.go b/pkg/ethutil/ethutil.go index 2cbb11a71..ce3c6f477 100644 --- a/pkg/ethutil/ethutil.go +++ b/pkg/ethutil/ethutil.go @@ -256,6 +256,15 @@ func GetConsensus( ctx context.Context, client *ethclient.Client, appAddress common.Address, +) (common.Address, error) { + return GetConsensusAt(ctx, client, appAddress, nil) +} + +func GetConsensusAt( + ctx context.Context, + client *ethclient.Client, + appAddress common.Address, + blockNumber *big.Int, ) (common.Address, error) { if client == nil { return common.Address{}, fmt.Errorf("get consensus: client is nil") @@ -264,7 +273,8 @@ func GetConsensus( if err != nil { return common.Address{}, fmt.Errorf("Failed to instantiate contract: %v", err) } - consensus, err := app.GetOutputsMerkleRootValidator(&bind.CallOpts{Context: ctx}) + opts := &bind.CallOpts{Context: ctx, BlockNumber: blockNumber} + consensus, err := app.GetOutputsMerkleRootValidator(opts) if err != nil { return common.Address{}, fmt.Errorf("error retrieving application epoch length: %v", err) } @@ -309,6 +319,28 @@ func GetEpochLength( return epochLengthRaw.Uint64(), nil } +// GetClaimStagingPeriod returns the consensus contract's immutable +// claimStagingPeriod, in blocks. Solidity guarantees this value cannot change +// for the lifetime of the contract, so it is safe to cache locally. +func GetClaimStagingPeriod( + ctx context.Context, + client *ethclient.Client, + consensusAddr common.Address, +) (uint64, error) { + if client == nil { + return 0, fmt.Errorf("get claim staging period: client is nil") + } + consensus, err := iconsensus.NewIConsensus(consensusAddr, client) + if err != nil { + return 0, fmt.Errorf("failed to instantiate contract: %v", err) + } + raw, err := consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: ctx}) + if err != nil { + return 0, fmt.Errorf("error retrieving claim staging period: %v", err) + } + return raw.Uint64(), nil +} + func GetInputBoxDeploymentBlock( ctx context.Context, client *ethclient.Client, diff --git a/pkg/ethutil/prt.go b/pkg/ethutil/prt.go index eeb6838e4..bd807d562 100644 --- a/pkg/ethutil/prt.go +++ b/pkg/ethutil/prt.go @@ -62,8 +62,18 @@ func (me *PRTApplicationDeployment) deployPRT( return zero, zero, fmt.Errorf("failed to instantiate contract binding: %v", err) } + if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { + return zero, zero, err + } + if err := CheckWithdrawalOutputBuilderCode(ctx, client, me.WithdrawalConfig); err != nil { + return zero, zero, err + } + + // idaveappfactory has its own WithdrawalConfig type with identical fields. + daveWC := idaveappfactory.WithdrawalConfig(me.WithdrawalConfig) + // check if addresses are available (have no code) - addresses, err := factory.CalculateDaveAppAddress(nil, me.TemplateHash, me.Salt) + addresses, err := factory.CalculateDaveAppAddress(nil, me.TemplateHash, daveWC, me.Salt) if err != nil { return zero, zero, err } @@ -84,7 +94,7 @@ func (me *PRTApplicationDeployment) deployPRT( } // deploy the contracts - tx, err := factory.NewDaveApp(txOpts, me.TemplateHash, me.Salt) + tx, err := factory.NewDaveApp(txOpts, me.TemplateHash, daveWC, me.Salt) if err != nil { return zero, zero, fmt.Errorf("transaction failed: %v", err) } @@ -144,6 +154,10 @@ func (me *PRTApplicationDeployment) Deploy( return zero, nil, fmt.Errorf("failed to decode data availability: %v", err) } + if err := VerifyDeployedWithdrawalConfig(ctx, client, appAddress, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + return appAddress, result, nil } diff --git a/pkg/ethutil/quorum.go b/pkg/ethutil/quorum.go new file mode 100644 index 000000000..36419d7d9 --- /dev/null +++ b/pkg/ethutil/quorum.go @@ -0,0 +1,94 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + "math/big" + + "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/ethclient" +) + +type QuorumDeployment struct { + Address common.Address `json:"address"` + FactoryAddress common.Address `json:"factory"` + Validators []common.Address `json:"validators"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + Salt SaltBytes `json:"salt"` + Verbose bool `json:"-"` +} + +func (me *QuorumDeployment) String() string { + result := "" + result += fmt.Sprintf("quorum deployment:\n") + result += fmt.Sprintf("\tvalidators: %v\n", me.Validators) + if me.Verbose { + result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) + 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) + } + return result +} + +func (me *QuorumDeployment) Deploy( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, +) (common.Address, error) { + zero := common.Address{} + factory, err := iquorumfactory.NewIQuorumFactory(me.FactoryAddress, client) + if err != nil { + return zero, fmt.Errorf("failed to instantiate contract: %v", err) + } + + epochLength := new(big.Int).SetUint64(me.EpochLength) + claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) + quorumAddress, err := factory.CalculateQuorumAddress( + nil, + me.Validators, + epochLength, + claimStagingPeriod, + me.Salt, + ) + if err != nil { + return zero, err + } + + quorumCode, err := client.CodeAt(ctx, quorumAddress, nil) + if err != nil { + return zero, err + } + if len(quorumCode) != 0 { + return zero, fmt.Errorf("quorum with address: %v already exists. Try a different salt.", quorumAddress) + } + + tx, err := factory.NewQuorum(txOpts, me.Validators, epochLength, claimStagingPeriod, me.Salt) + if err != nil { + return zero, fmt.Errorf("failed to create new quorum: %v", err) + } + + receipt, err := bind.WaitMined(ctx, client, tx) + if err != nil { + return zero, fmt.Errorf("failed to mine new quorum transaction: %v", err) + } + + if receipt.Status != 1 { + return zero, fmt.Errorf("transaction failed") + } + + for _, vLog := range receipt.Logs { + event, err := factory.ParseQuorumCreated(*vLog) + if err != nil { + continue + } + return event.Quorum, nil + } + return zero, fmt.Errorf("failed to find event in receipt logs") +} diff --git a/pkg/ethutil/rpcerror.go b/pkg/ethutil/rpcerror.go index 76b5674e7..34db8432a 100644 --- a/pkg/ethutil/rpcerror.go +++ b/pkg/ethutil/rpcerror.go @@ -7,6 +7,7 @@ import ( "bytes" "errors" "fmt" + "strings" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -90,3 +91,29 @@ func IsCustomError(err error, metadata *bind.MetaData, errorName string) bool { selector := fmt.Sprintf("0x%x", abiErr.ID[:4]) return MatchesSelector(info.Data, selector) } + +// IsNonceTooLowError reports whether an error returned by a contract-binding +// broadcast (e.g. SubmitClaim, AcceptClaim, Settle, JoinTournament) is the +// JSON-RPC "nonce too low" rejection. This is a transient broadcast-time +// condition: the chain has already mined a tx with this EOA's nonce N, so a +// new broadcast (also using N because bind.TransactOpts.Nonce is nil and the +// binding fetched it via PendingNonceAt) is rejected. The classic trigger is +// a node restart that straddles an in-flight tx: the pre-restart broadcast +// landed, but the post-restart Tick re-derives the same nonce from +// PendingNonceAt before the chain's pending view catches up. +// +// The check is a case-insensitive substring match because the JSON-RPC error +// from the node arrives as an opaque rpc.Error wrapper around the upstream +// string; go-ethereum's core.ErrNonceTooLow sentinel is not propagated +// through eth_sendRawTransaction. Both anvil and geth produce the literal +// "nonce too low" inside the wrapper. +// +// The recommended response is to treat this as a retry-later condition and +// rely on a pre-flight on-chain read (IsEpochSettled, IsCommitmentJoined, +// getClaim, etc.) at the next tick to reconcile against state. +func IsNonceTooLowError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "nonce too low") +} diff --git a/pkg/ethutil/rpcerror_test.go b/pkg/ethutil/rpcerror_test.go index ccfeea374..ff0b18597 100644 --- a/pkg/ethutil/rpcerror_test.go +++ b/pkg/ethutil/rpcerror_test.go @@ -5,6 +5,7 @@ package ethutil import ( "errors" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -106,3 +107,39 @@ func TestIsCustomError(t *testing.T) { assert.False(t, IsCustomError(err, nil, "Foo")) }) } + +// TestIsNonceTooLowError pins the substring-match contract used by both the +// claimer and PRT broadcast paths to short-circuit on the JSON-RPC +// "nonce too low" rejection. The classifier must catch the literal anvil/ +// geth wording, the wrapped form (`[nonce too low]` produced when a top-level +// formatter renders a []error), and arbitrary case; it must not match +// unrelated errors. +func TestIsNonceTooLowError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {name: "Nil", err: nil, want: false}, + {name: "LiteralLowercase", err: errors.New("nonce too low"), want: true}, + {name: "MixedCase", err: errors.New("Nonce Too Low"), want: true}, + {name: "BracketWrapped", err: errors.New("[nonce too low]"), want: true}, + { + name: "WrappedWithFmt", + err: fmt.Errorf("send transaction: %w", errors.New("nonce too low")), + want: true, + }, + {name: "UnrelatedError", err: errors.New("connection refused"), want: false}, + {name: "RevertedError", err: errors.New("execution reverted"), want: false}, + { + name: "NonceTooHigh", + err: errors.New("nonce too high"), + want: false, // intentional — different condition, not handled here + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, IsNonceTooLowError(tc.err)) + }) + } +} diff --git a/pkg/ethutil/selfhosted.go b/pkg/ethutil/selfhosted.go index 846a613ee..d3711d119 100644 --- a/pkg/ethutil/selfhosted.go +++ b/pkg/ethutil/selfhosted.go @@ -18,13 +18,15 @@ 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"` - Salt SaltBytes `json:"salt"` + 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"` InputBoxAddress common.Address `json:"inputbox_address"` IInputBoxBlock uint64 `json:"inputbox_block"` @@ -53,6 +55,7 @@ func (me *SelfhostedApplicationDeployment) String() string { 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) } return result } @@ -81,8 +84,29 @@ func (me *SelfhostedApplicationDeployment) Deploy( return zero, nil, err } + if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { + return zero, nil, err + } + if err := CheckWithdrawalOutputBuilderCode(ctx, client, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + + // The self-hosted factory binding has its own WithdrawalConfig type + // with identical fields; explicit conversion is required. + shWC := iselfhostedapplicationfactory.WithdrawalConfig(me.WithdrawalConfig) + // check if addresses are available (have no code) - applicationAddress, authorityAddress, err := factory.CalculateAddresses(nil, me.AuthorityOwnerAddress, new(big.Int).SetUint64(me.EpochLength), me.ApplicationOwnerAddress, me.TemplateHash, me.DataAvailability, me.Salt) + applicationAddress, authorityAddress, err := factory.CalculateAddresses( + nil, + me.AuthorityOwnerAddress, + new(big.Int).SetUint64(me.EpochLength), + new(big.Int).SetUint64(me.ClaimStagingPeriod), + me.ApplicationOwnerAddress, + me.TemplateHash, + me.DataAvailability, + shWC, + me.Salt, + ) if err != nil { return zero, nil, err } @@ -115,8 +139,17 @@ func (me *SelfhostedApplicationDeployment) Deploy( if err != nil { return nil, fmt.Errorf("failed to retrieve authority factory address: %w", err) } - return factory.DeployContracts(txOpts, me.AuthorityOwnerAddress, big.NewInt(0).SetUint64(me.EpochLength), me.ApplicationOwnerAddress, - me.TemplateHash, me.DataAvailability, me.Salt) + return factory.DeployContracts( + txOpts, + me.AuthorityOwnerAddress, + new(big.Int).SetUint64(me.EpochLength), + new(big.Int).SetUint64(me.ClaimStagingPeriod), + me.ApplicationOwnerAddress, + me.TemplateHash, + me.DataAvailability, + shWC, + me.Salt, + ) }, ) if err != nil { @@ -155,6 +188,9 @@ applicationEventFound: return zero, nil, fmt.Errorf("failed to obtain authority address during self hosted application deployment. AuthorityCreated event not found in the recipe logs") authorityEventFound: + if err := VerifyDeployedWithdrawalConfig(ctx, client, result.ApplicationAddress, me.WithdrawalConfig); err != nil { + return zero, nil, err + } return result.ApplicationAddress, result, nil } diff --git a/pkg/ethutil/withdrawal_account.go b/pkg/ethutil/withdrawal_account.go new file mode 100644 index 000000000..ec61a53b3 --- /dev/null +++ b/pkg/ethutil/withdrawal_account.go @@ -0,0 +1,138 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "encoding/binary" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" + "github.com/cartesi/rollups-node/pkg/contracts/iusdwithdrawaloutputbuilder" +) + +// usdAccountSize is the byte length of the LibUsdAccount encoding consumed +// by every UsdWithdrawalOutputBuilder: +// +// bytes 0..7 uint64 balance, little-endian +// bytes 8..27 20-byte user address +const usdAccountSize = 28 + +// DescribeWithdrawalAccount renders a multi-line human description of the +// `account` bytes consumed by an IApplication.withdraw() call so the +// operator can verify the recipient and amount before signing. +// +// Algorithm: +// +// 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 28-byte account into recipient and balance per LibUsdAccount. +// A length mismatch is a hard error — a malformed proof against a +// recognized builder, not a fallback signal. +// 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. +// +// Tri-state return: +// +// - (desc, true, nil): builder recognized and account decoded. +// - ("", true, err): builder recognized but the bytes do not match +// the USD encoding — surface to the operator. +// - ("", false, nil): Token() reverted — caller falls back to raw +// bytes and stricter confirmation. +func DescribeWithdrawalAccount( + ctx context.Context, + client *ethclient.Client, + builder common.Address, + account []byte, +) (description string, matched bool, err error) { + b, err := iusdwithdrawaloutputbuilder.NewIUsdWithdrawalOutputBuilder(builder, client) + if err != nil { + return "", false, nil + } + token, err := b.Token(&bind.CallOpts{Context: ctx}) + if err != nil { + return "", false, nil + } + if len(account) != usdAccountSize { + return "", true, fmt.Errorf( + "USD account must be %d bytes, got %d (token %s)", + usdAccountSize, len(account), token) + } + balance := binary.LittleEndian.Uint64(account[:8]) + var recipient common.Address + copy(recipient[:], account[8:usdAccountSize]) + + symbol, decimals, metaOK := fetchERC20Metadata(ctx, client, token) + tokenLine := fmt.Sprintf(" token: %s", token) + if metaOK { + tokenLine = fmt.Sprintf(" token: %s %s", token, symbol) + } + var amountLine string + if metaOK { + amountLine = fmt.Sprintf( + " amount: %s %s (raw: %d, decimals: %d)", + formatTokenAmount(balance, decimals), symbol, balance, decimals) + } else { + amountLine = fmt.Sprintf( + " amount (raw uint64): %d (token metadata unavailable)", + balance) + } + return fmt.Sprintf( + "USD-style account (recognized via IUsdWithdrawalOutputBuilder.Token)\n"+ + "%s\n recipient: %s\n%s", + tokenLine, recipient, amountLine, + ), true, nil +} + +// fetchERC20Metadata best-effort-fetches the symbol and decimals of an +// ERC-20 token. Returns ok=false if either view reverts so the caller can +// fall back to a raw integer display rather than guessing. +func fetchERC20Metadata( + ctx context.Context, + client *ethclient.Client, + token common.Address, +) (symbol string, decimals uint8, ok bool) { + md, err := ierc20metadata.NewIERC20Metadata(token, client) + if err != nil { + return "", 0, false + } + opts := &bind.CallOpts{Context: ctx} + symbol, err = md.Symbol(opts) + if err != nil { + return "", 0, false + } + decimals, err = md.Decimals(opts) + if err != nil { + return "", 0, false + } + return symbol, decimals, true +} + +// formatTokenAmount converts a raw integer balance into the conventional +// 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 { + if decimals == 0 { + return fmt.Sprintf("%d", raw) + } + 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)) + if frac.Sign() == 0 { + return whole.String() + } + fracStr := fmt.Sprintf("%0*s", decimals, frac.String()) + for len(fracStr) > 0 && fracStr[len(fracStr)-1] == '0' { + fracStr = fracStr[:len(fracStr)-1] + } + return whole.String() + "." + fracStr +} diff --git a/pkg/ethutil/withdrawal_account_test.go b/pkg/ethutil/withdrawal_account_test.go new file mode 100644 index 000000000..1b97fcf4c --- /dev/null +++ b/pkg/ethutil/withdrawal_account_test.go @@ -0,0 +1,33 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFormatTokenAmount(t *testing.T) { + cases := []struct { + raw uint64 + 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"}, + } + for _, c := range cases { + got := formatTokenAmount(c.raw, c.decimals) + require.Equalf(t, c.want, got, "formatTokenAmount(%d, %d)", c.raw, c.decimals) + } +} diff --git a/pkg/ethutil/withdrawal_config.go b/pkg/ethutil/withdrawal_config.go new file mode 100644 index 000000000..57fe4f2aa --- /dev/null +++ b/pkg/ethutil/withdrawal_config.go @@ -0,0 +1,170 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "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/ethclient" +) + +// Constants mirror CanonicalMachine.sol. +const ( + log2DataBlockSize = 5 + log2MemorySize = 64 +) + +// ValidateWithdrawalConfig is the Go mirror of LibWithdrawalConfig.isValid in +// src/library/LibWithdrawalConfig.sol. It exists so the CLI can surface a clear +// error before sending a transaction that would revert with the opaque +// InvalidWithdrawalConfig selector. +// +// A zero-valued config (no foreclosure / no withdrawal) is valid. +func ValidateWithdrawalConfig(wc iapplicationfactory.WithdrawalConfig) error { + log2AccountsDriveSize := uint(log2DataBlockSize) + + uint(wc.Log2MaxNumOfAccounts) + + uint(wc.Log2LeavesPerAccount) + + if log2AccountsDriveSize > log2MemorySize { + return fmt.Errorf( + "withdrawal config: accounts drive larger than machine memory: log2(drive) = %d + %d + %d = %d > %d", + log2DataBlockSize, wc.Log2MaxNumOfAccounts, wc.Log2LeavesPerAccount, + log2AccountsDriveSize, log2MemorySize, + ) + } + + endIndex := new(big.Int).SetUint64(wc.AccountsDriveStartIndex) + endIndex.Add(endIndex, big.NewInt(1)) + accountsDriveEnd := new(big.Int).Lsh(endIndex, log2AccountsDriveSize) + memorySize := new(big.Int).Lsh(big.NewInt(1), log2MemorySize) + + if accountsDriveEnd.Cmp(memorySize) > 0 { + return fmt.Errorf( + "withdrawal config: accounts drive ends past machine memory: (start+1=%s) << %d > 2^%d", + endIndex.String(), log2AccountsDriveSize, log2MemorySize, + ) + } + + return nil +} + +// withdrawalConfigArgs is sourced from the abigen-generated +// IApplicationFactory ABI so the encoding stays in lockstep with the +// contract definition. The tuple type is taken from +// `calculateApplicationAddress` (unique signature, no overload ambiguity). +var withdrawalConfigArgs = func() abi.Arguments { + parsed, err := iapplicationfactory.IApplicationFactoryMetaData.GetAbi() + if err != nil { + panic(fmt.Errorf("withdrawal_config: failed to parse iapplicationfactory ABI: %w", err)) + } + method, ok := parsed.Methods["calculateApplicationAddress"] + if !ok { + panic("withdrawal_config: calculateApplicationAddress method not found in ABI") + } + for _, in := range method.Inputs { + if in.Name == "withdrawalConfig" { + return abi.Arguments{{Name: "withdrawalConfig", Type: in.Type}} + } + } + panic("withdrawal_config: withdrawalConfig argument not found in calculateApplicationAddress ABI") +}() + +// EncodeWithdrawalConfig serializes a WithdrawalConfig as the on-chain +// tuple ABI encoding (160 bytes for the all-static field layout). The +// all-zero config encodes to 160 zero bytes — the canonical "no +// foreclosure" representation. +func EncodeWithdrawalConfig(wc iapplicationfactory.WithdrawalConfig) ([]byte, error) { + return withdrawalConfigArgs.Pack(wc) +} + +// GetApplicationWithdrawalConfig reads the WithdrawalConfig struct from a +// deployed IApplication contract via the single getWithdrawalConfig() view. +// Used at registration time when the contract is the source of truth. +// +// abigen emits a distinct WithdrawalConfig struct per contract package, so +// the iapplication-binding result is copied into the iapplicationfactory +// shape callers expect for downstream encoding via EncodeWithdrawalConfig. +func GetApplicationWithdrawalConfig( + ctx context.Context, + client *ethclient.Client, + appAddr common.Address, +) (iapplicationfactory.WithdrawalConfig, error) { + app, err := iapplication.NewIApplication(appAddr, client) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, + fmt.Errorf("withdrawal config: failed to instantiate IApplication binding: %w", err) + } + + wc, err := app.GetWithdrawalConfig(&bind.CallOpts{Context: ctx}) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, + fmt.Errorf("withdrawal config: getWithdrawalConfig failed: %w", err) + } + return iapplicationfactory.WithdrawalConfig{ + Guardian: wc.Guardian, + Log2LeavesPerAccount: wc.Log2LeavesPerAccount, + Log2MaxNumOfAccounts: wc.Log2MaxNumOfAccounts, + AccountsDriveStartIndex: wc.AccountsDriveStartIndex, + WithdrawalOutputBuilder: wc.WithdrawalOutputBuilder, + }, nil +} + +// VerifyDeployedWithdrawalConfig reads the WithdrawalConfig from the +// just-deployed application contract and asserts field-by-field equality +// against the config the caller passed to the factory. The four binding +// packages (iapplicationfactory, iselfhostedapplicationfactory, +// idaveappfactory, iapplication) each declare their own WithdrawalConfig +// struct; the deploy paths cross between them via Go type conversion, +// which silently masks any abigen field-order drift. This verify catches +// that drift the moment it ships, rather than at some downstream failure +// (claimer reverting, withdrawal output going to the wrong recipient). +func VerifyDeployedWithdrawalConfig( + ctx context.Context, + client *ethclient.Client, + appAddr common.Address, + expected iapplicationfactory.WithdrawalConfig, +) error { + actual, err := GetApplicationWithdrawalConfig(ctx, client, appAddr) + if err != nil { + return fmt.Errorf("verify withdrawal config: %w", err) + } + if actual != expected { + return fmt.Errorf( + "verify withdrawal config: on-chain config at %s does not match factory input\n"+ + " expected: %+v\n"+ + " actual: %+v", + appAddr, expected, actual) + } + return nil +} + +// CheckWithdrawalOutputBuilderCode performs a cheap sanity check on the +// builder address: if non-zero, it must have bytecode on chain. Skips the +// check when WithdrawalOutputBuilder is the zero address (no-foreclosure +// default). +func CheckWithdrawalOutputBuilderCode( + ctx context.Context, + client *ethclient.Client, + wc iapplicationfactory.WithdrawalConfig, +) error { + if wc.WithdrawalOutputBuilder == (common.Address{}) { + return nil + } + code, err := client.CodeAt(ctx, wc.WithdrawalOutputBuilder, nil) + if err != nil { + return fmt.Errorf("withdrawal config: failed to read builder code at %s: %w", + wc.WithdrawalOutputBuilder, err) + } + if len(code) == 0 { + return fmt.Errorf("withdrawal config: builder address %s has no code on chain", + wc.WithdrawalOutputBuilder) + } + return nil +} diff --git a/pkg/ethutil/withdrawal_config_test.go b/pkg/ethutil/withdrawal_config_test.go new file mode 100644 index 000000000..63817a306 --- /dev/null +++ b/pkg/ethutil/withdrawal_config_test.go @@ -0,0 +1,131 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "bytes" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestEncodeWithdrawalConfig(t *testing.T) { + cases := []iapplicationfactory.WithdrawalConfig{ + {}, + { + Guardian: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 20, + AccountsDriveStartIndex: 33554432, + WithdrawalOutputBuilder: common.HexToAddress("0x2222222222222222222222222222222222222222"), + }, + { + Guardian: common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), + Log2LeavesPerAccount: 7, + Log2MaxNumOfAccounts: 19, + AccountsDriveStartIndex: 12345, + WithdrawalOutputBuilder: common.HexToAddress("0xcafebabecafebabecafebabecafebabecafebabe"), + }, + } + for i, wc := range cases { + b, err := EncodeWithdrawalConfig(wc) + require.NoError(t, err, "case %d encode", i) + require.Equal(t, 160, len(b), "case %d encoded length (5 * 32 = 160 bytes)", i) + + // Round-trip: unpack the encoded bytes and assert field-by-field equality + // with the original. Pinning this protects against an abigen field-order + // shift between the four binding packages that share the WithdrawalConfig + // shape (iapplicationfactory, iselfhostedapplicationfactory, idaveappfactory, + // iapplication) — a silent reorder there would break encoding and the + // length-only check would not catch it. + unpacked, err := withdrawalConfigArgs.Unpack(b) + require.NoError(t, err, "case %d unpack", i) + require.Len(t, unpacked, 1, "case %d unpack arity", i) + got := *abi.ConvertType(unpacked[0], + new(iapplicationfactory.WithdrawalConfig)).(*iapplicationfactory.WithdrawalConfig) + require.Equal(t, wc, got, "case %d round-trip", i) + } + + // Zero-valued config must encode to 160 zero bytes — the canonical + // "no foreclosure" sentinel used as the DEFAULT value in the deploy tx + // ABI and assumed by downstream readers. + zeroBytes, err := EncodeWithdrawalConfig(iapplicationfactory.WithdrawalConfig{}) + require.NoError(t, err) + require.True(t, bytes.Equal(zeroBytes, make([]byte, 160)), + "all-zero config must encode to 160 zero bytes") +} + +func TestValidateWithdrawalConfig(t *testing.T) { + tests := []struct { + name string + wc iapplicationfactory.WithdrawalConfig + wantErr string // substring expected in the error message; "" means no error + }{ + { + name: "all zeros is valid (no foreclosure)", + wc: iapplicationfactory.WithdrawalConfig{}, + }, + { + name: "typical realistic config", + wc: iapplicationfactory.WithdrawalConfig{ + Guardian: common.HexToAddress("0x1"), + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 20, + AccountsDriveStartIndex: 33554432, + WithdrawalOutputBuilder: common.HexToAddress("0x2"), + }, + }, + { + name: "drive size at the memory boundary, start=0 (valid)", + wc: iapplicationfactory.WithdrawalConfig{ + // 5 + 0 + 59 = 64 == log2MemorySize, start=0 -> end = 1 << 64 == 2^64 == memorySize + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 59, + AccountsDriveStartIndex: 0, + }, + }, + { + name: "drive too large (log2 sum > 64)", + wc: iapplicationfactory.WithdrawalConfig{ + Log2LeavesPerAccount: 60, + Log2MaxNumOfAccounts: 60, + }, + wantErr: "larger than machine memory", + }, + { + name: "drive end overflows past memory (start>0 at boundary)", + wc: iapplicationfactory.WithdrawalConfig{ + // 5 + 0 + 59 = 64 == log2MemorySize, start=1 -> end = 2 << 64 > 2^64 + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 59, + AccountsDriveStartIndex: 1, + }, + wantErr: "past machine memory", + }, + { + name: "drive end past machine memory (start non-zero)", + wc: iapplicationfactory.WithdrawalConfig{ + // 5 + 0 + 30 = 35; start = 2^34 -> (start+1) << 35 > 2^64 + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 30, + AccountsDriveStartIndex: 1 << 34, + }, + wantErr: "past machine memory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateWithdrawalConfig(tc.wc) + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + } + }) + } +} From 9b4b2a51eaba26e86f30907ede015e4d5e0ef761 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Fri, 22 May 2026 13:15:24 -0300 Subject: [PATCH 04/16] feat(repository): v3 schema, claim staging, foreclosure --- internal/model/models.go | 369 ++++++++++++++--- internal/repository/postgres/application.go | 292 ++++++++++++++ internal/repository/postgres/claimer.go | 371 +++++++++++++++++- .../db/rollupsdb/public/enum/epochstatus.go | 2 + .../db/rollupsdb/public/table/application.go | 168 +++++--- .../db/rollupsdb/public/table/epoch.go | 7 +- .../public/table/table_use_schema.go | 1 + .../db/rollupsdb/public/table/withdrawal.go | 102 +++++ internal/repository/postgres/epoch.go | 97 +++++ .../000001_create_initial_schema.down.sql | 4 + .../000001_create_initial_schema.up.sql | 140 ++++++- internal/repository/postgres/withdrawal.go | 287 ++++++++++++++ internal/repository/repository.go | 150 +++++++ .../repotest/application_test_cases.go | 290 ++++++++++++++ internal/repository/repotest/builders.go | 35 ++ .../repository/repotest/claimer_test_cases.go | 222 ++++++++++- .../repository/repotest/epoch_test_cases.go | 323 +++++++++++++++ internal/repository/repotest/repotest.go | 1 + .../repotest/withdrawal_test_cases.go | 304 ++++++++++++++ 19 files changed, 2999 insertions(+), 166 deletions(-) create mode 100644 internal/repository/postgres/db/rollupsdb/public/table/withdrawal.go create mode 100644 internal/repository/postgres/withdrawal.go create mode 100644 internal/repository/repotest/withdrawal_test_cases.go diff --git a/internal/model/models.go b/internal/model/models.go index bb20e3cfb..341e251d8 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "strconv" "strings" "time" @@ -17,27 +18,116 @@ import ( ) type Application struct { - ID int64 `sql:"primary_key" json:"-"` - Name string `json:"name"` - IApplicationAddress common.Address `json:"iapplication_address"` - IConsensusAddress common.Address `json:"iconsensus_address"` - IInputBoxAddress common.Address `json:"iinputbox_address"` - TemplateHash common.Hash `json:"template_hash"` - TemplateURI string `json:"-"` - EpochLength uint64 `json:"epoch_length"` - DataAvailability []byte `json:"data_availability"` - ConsensusType Consensus `json:"consensus_type"` - State ApplicationState `json:"state"` - Reason *string `json:"reason"` - IInputBoxBlock uint64 `json:"iinputbox_block"` - LastEpochCheckBlock uint64 `json:"last_epoch_check_block"` - LastInputCheckBlock uint64 `json:"last_input_check_block"` - LastOutputCheckBlock uint64 `json:"last_output_check_block"` - LastTournamentCheckBlock uint64 `json:"last_tournament_check_block"` - ProcessedInputs uint64 `json:"processed_inputs"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ExecutionParameters ExecutionParameters `json:"execution_parameters"` + ID int64 `sql:"primary_key" json:"-"` + Name string `json:"name"` + IApplicationAddress common.Address `json:"iapplication_address"` + IConsensusAddress common.Address `json:"iconsensus_address"` + IInputBoxAddress common.Address `json:"iinputbox_address"` + TemplateHash common.Hash `json:"template_hash"` + TemplateURI string `json:"-"` + 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"` + State ApplicationState `json:"state"` + Reason *string `json:"reason"` + IInputBoxBlock uint64 `json:"iinputbox_block"` + LastEpochCheckBlock uint64 `json:"last_epoch_check_block"` + LastInputCheckBlock uint64 `json:"last_input_check_block"` + LastOutputCheckBlock uint64 `json:"last_output_check_block"` + LastTournamentCheckBlock uint64 `json:"last_tournament_check_block"` + LastForecloseCheckBlock uint64 `json:"last_foreclose_check_block"` + LastAccountsDriveProvedCheckBlock uint64 `json:"last_accounts_drive_proved_check_block"` + LastWithdrawalCheckBlock uint64 `json:"last_withdrawal_check_block"` + ProcessedInputs uint64 `json:"processed_inputs"` + ForecloseBlock uint64 `json:"foreclose_block"` + ForecloseTransaction *common.Hash `json:"foreclose_transaction"` + AccountsDriveProvedBlock uint64 `json:"accounts_drive_proved_block"` + AccountsDriveProvedTransaction *common.Hash `json:"accounts_drive_proved_transaction"` + AccountsDriveMerkleRoot *common.Hash `json:"accounts_drive_merkle_root"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ExecutionParameters ExecutionParameters `json:"execution_parameters"` +} + +// IsForeclosed reports whether the node has observed an on-chain Foreclosure +// event for this application. Block 0 is unreachable for foreclosure (the +// contract is deployed at block >= 1), so 0 is the unambiguous "not observed +// yet" sentinel. Once non-zero it remains so (the chain-level foreclosed +// flag is one-way). +func (a *Application) IsForeclosed() bool { + return a.ForecloseBlock != 0 +} + +// WithdrawalConfig mirrors the on-chain five-immutable layout from the +// Application contract. Field order matches iapplicationfactory.WithdrawalConfig +// so the two are convertible via a Go type conversion. +type WithdrawalConfig struct { + Guardian common.Address `json:"guardian"` + Log2LeavesPerAccount uint8 `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts uint8 `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex uint64 `json:"accounts_drive_start_index"` + WithdrawalOutputBuilder common.Address `json:"withdrawal_output_builder"` +} + +func (w WithdrawalConfig) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Guardian common.Address `json:"guardian"` + Log2LeavesPerAccount string `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts string `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex string `json:"accounts_drive_start_index"` + WithdrawalOutputBuilder common.Address `json:"withdrawal_output_builder"` + }{ + Guardian: w.Guardian, + Log2LeavesPerAccount: fmt.Sprintf("0x%x", w.Log2LeavesPerAccount), + Log2MaxNumOfAccounts: fmt.Sprintf("0x%x", w.Log2MaxNumOfAccounts), + AccountsDriveStartIndex: fmt.Sprintf("0x%x", w.AccountsDriveStartIndex), + WithdrawalOutputBuilder: w.WithdrawalOutputBuilder, + }) +} + +func (w *WithdrawalConfig) UnmarshalJSON(data []byte) error { + aux := &struct { + Guardian common.Address `json:"guardian"` + Log2LeavesPerAccount string `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts string `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex string `json:"accounts_drive_start_index"` + WithdrawalOutputBuilder common.Address `json:"withdrawal_output_builder"` + }{} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + w.Guardian = aux.Guardian + w.WithdrawalOutputBuilder = aux.WithdrawalOutputBuilder + if aux.Log2LeavesPerAccount != "" { + v, err := ParseHexUint64(aux.Log2LeavesPerAccount) + if err != nil { + return fmt.Errorf("invalid log2_leaves_per_account: %w", err) + } + if v > math.MaxUint8 { + return fmt.Errorf("log2_leaves_per_account out of range for uint8: %d", v) + } + w.Log2LeavesPerAccount = uint8(v) + } + if aux.Log2MaxNumOfAccounts != "" { + v, err := ParseHexUint64(aux.Log2MaxNumOfAccounts) + if err != nil { + return fmt.Errorf("invalid log2_max_num_of_accounts: %w", err) + } + if v > math.MaxUint8 { + return fmt.Errorf("log2_max_num_of_accounts out of range for uint8: %d", v) + } + w.Log2MaxNumOfAccounts = uint8(v) + } + if aux.AccountsDriveStartIndex != "" { + v, err := ParseHexUint64(aux.AccountsDriveStartIndex) + if err != nil { + return fmt.Errorf("invalid accounts_drive_start_index: %w", err) + } + w.AccountsDriveStartIndex = v + } + return nil } // HasDataAvailabilitySelector checks if the application's DataAvailability @@ -52,24 +142,36 @@ func (a *Application) MarshalJSON() ([]byte, error) { // 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"` - LastOutputCheckBlock string `json:"last_output_check_block"` - LastTournamentCheckBlock string `json:"last_tournament_check_block"` - EpochLength string `json:"epoch_length"` - ProcessedInputs string `json:"processed_inputs"` + DataAvailability string `json:"data_availability"` + IInputBoxBlock string `json:"iinputbox_block"` + LastEpochCheckBlock string `json:"last_epoch_check_block"` + LastInputCheckBlock string `json:"last_input_check_block"` + LastOutputCheckBlock string `json:"last_output_check_block"` + LastTournamentCheckBlock string `json:"last_tournament_check_block"` + LastForecloseCheckBlock string `json:"last_foreclose_check_block"` + LastAccountsDriveProvedCheckBlock string `json:"last_accounts_drive_proved_check_block"` + LastWithdrawalCheckBlock string `json:"last_withdrawal_check_block"` + EpochLength string `json:"epoch_length"` + ClaimStagingPeriod string `json:"claim_staging_period"` + ProcessedInputs string `json:"processed_inputs"` + ForecloseBlock string `json:"foreclose_block"` + 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), - LastOutputCheckBlock: fmt.Sprintf("0x%x", a.LastOutputCheckBlock), - LastTournamentCheckBlock: fmt.Sprintf("0x%x", a.LastTournamentCheckBlock), - EpochLength: fmt.Sprintf("0x%x", a.EpochLength), - ProcessedInputs: fmt.Sprintf("0x%x", a.ProcessedInputs), + 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), + LastOutputCheckBlock: fmt.Sprintf("0x%x", a.LastOutputCheckBlock), + LastTournamentCheckBlock: fmt.Sprintf("0x%x", a.LastTournamentCheckBlock), + LastForecloseCheckBlock: fmt.Sprintf("0x%x", a.LastForecloseCheckBlock), + LastAccountsDriveProvedCheckBlock: fmt.Sprintf("0x%x", a.LastAccountsDriveProvedCheckBlock), + LastWithdrawalCheckBlock: fmt.Sprintf("0x%x", a.LastWithdrawalCheckBlock), + EpochLength: fmt.Sprintf("0x%x", a.EpochLength), + ClaimStagingPeriod: fmt.Sprintf("0x%x", a.ClaimStagingPeriod), + ProcessedInputs: fmt.Sprintf("0x%x", a.ProcessedInputs), + ForecloseBlock: fmt.Sprintf("0x%x", a.ForecloseBlock), + AccountsDriveProvedBlock: fmt.Sprintf("0x%x", a.AccountsDriveProvedBlock), } return json.Marshal(aux) } @@ -79,14 +181,20 @@ 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"` - LastEpochCheckBlock string `json:"last_epoch_check_block"` - LastTournamentCheckBlock string `json:"last_tournament_check_block"` - EpochLength string `json:"epoch_length"` - ProcessedInputs string `json:"processed_inputs"` + DataAvailability string `json:"data_availability"` + IInputBoxBlock string `json:"iinputbox_block"` + LastInputCheckBlock string `json:"last_input_check_block"` + LastOutputCheckBlock string `json:"last_output_check_block"` + LastEpochCheckBlock string `json:"last_epoch_check_block"` + LastTournamentCheckBlock string `json:"last_tournament_check_block"` + LastForecloseCheckBlock string `json:"last_foreclose_check_block"` + LastAccountsDriveProvedCheckBlock string `json:"last_accounts_drive_proved_check_block"` + LastWithdrawalCheckBlock string `json:"last_withdrawal_check_block"` + EpochLength string `json:"epoch_length"` + ClaimStagingPeriod string `json:"claim_staging_period"` + ProcessedInputs string `json:"processed_inputs"` + ForecloseBlock string `json:"foreclose_block"` + AccountsDriveProvedBlock string `json:"accounts_drive_proved_block"` }{} var err error @@ -128,16 +236,56 @@ func (a *Application) UnmarshalJSON(in []byte) error { return err } + a.LastForecloseCheckBlock, err = ParseHexUint64(aux.LastForecloseCheckBlock) + if err != nil { + return err + } + + if aux.LastAccountsDriveProvedCheckBlock != "" { + a.LastAccountsDriveProvedCheckBlock, err = ParseHexUint64(aux.LastAccountsDriveProvedCheckBlock) + if err != nil { + return err + } + } + + if aux.LastWithdrawalCheckBlock != "" { + a.LastWithdrawalCheckBlock, err = ParseHexUint64(aux.LastWithdrawalCheckBlock) + if err != nil { + return err + } + } + a.EpochLength, err = ParseHexUint64(aux.EpochLength) if err != nil { return err } + if aux.ClaimStagingPeriod != "" { + a.ClaimStagingPeriod, err = ParseHexUint64(aux.ClaimStagingPeriod) + if err != nil { + return err + } + } + a.ProcessedInputs, err = ParseHexUint64(aux.ProcessedInputs) if err != nil { return err } + if aux.ForecloseBlock != "" { + a.ForecloseBlock, err = ParseHexUint64(aux.ForecloseBlock) + if err != nil { + return err + } + } + + if aux.AccountsDriveProvedBlock != "" { + a.AccountsDriveProvedBlock, err = ParseHexUint64(aux.AccountsDriveProvedBlock) + if err != nil { + return err + } + } + return nil } @@ -588,13 +736,14 @@ type Epoch struct { InputIndexLowerBound uint64 `json:"input_index_lower_bound"` InputIndexUpperBound uint64 `json:"input_index_upper_bound"` MachineHash *common.Hash `json:"machine_hash"` - OutputsMerkleRoot *common.Hash `json:"claim_hash"` + OutputsMerkleRoot *common.Hash `json:"outputs_merkle_root"` OutputsMerkleProof []common.Hash `json:"outputs_merkle_proof,omitempty"` ClaimTransactionHash *common.Hash `json:"claim_transaction_hash"` Commitment *common.Hash `json:"commitment"` CommitmentProof []common.Hash `json:"commitment_proof,omitempty"` TournamentAddress *common.Address `json:"tournament_address"` Status EpochStatus `json:"status"` + StagedAtBlock *uint64 `json:"staged_at_block"` VirtualIndex uint64 `json:"virtual_index"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -605,12 +754,13 @@ func (e *Epoch) MarshalJSON() ([]byte, error) { type Alias Epoch // Define a new structure that embeds the alias but overrides the hex fields. aux := &struct { - Index string `json:"index"` - FirstBlock string `json:"first_block"` - LastBlock string `json:"last_block"` - InputIndexLowerBound string `json:"input_index_lower_bound"` - InputIndexUpperBound string `json:"input_index_upper_bound"` - VirtualIndex string `json:"virtual_index"` + Index string `json:"index"` + FirstBlock string `json:"first_block"` + LastBlock string `json:"last_block"` + InputIndexLowerBound string `json:"input_index_lower_bound"` + InputIndexUpperBound string `json:"input_index_upper_bound"` + StagedAtBlock *string `json:"staged_at_block"` + VirtualIndex string `json:"virtual_index"` *Alias }{ Index: fmt.Sprintf("0x%x", e.Index), @@ -621,6 +771,10 @@ func (e *Epoch) MarshalJSON() ([]byte, error) { VirtualIndex: fmt.Sprintf("0x%x", e.VirtualIndex), Alias: (*Alias)(e), } + if e.StagedAtBlock != nil { + s := fmt.Sprintf("0x%x", *e.StagedAtBlock) + aux.StagedAtBlock = &s + } return json.Marshal(aux) } @@ -629,12 +783,13 @@ func (e *Epoch) UnmarshalJSON(in []byte) error { aux := &struct { *Alias - Index string `json:"index"` - FirstBlock string `json:"first_block"` - LastBlock string `json:"last_block"` - InputIndexLowerBound string `json:"input_index_lower_bound"` - InputIndexUpperBound string `json:"input_index_upper_bound"` - VirtualIndex string `json:"virtual_index"` + Index string `json:"index"` + FirstBlock string `json:"first_block"` + LastBlock string `json:"last_block"` + InputIndexLowerBound string `json:"input_index_lower_bound"` + InputIndexUpperBound string `json:"input_index_upper_bound"` + StagedAtBlock *string `json:"staged_at_block"` + VirtualIndex string `json:"virtual_index"` }{} var err error @@ -671,6 +826,14 @@ func (e *Epoch) UnmarshalJSON(in []byte) error { return err } + if aux.StagedAtBlock != nil { + v, err := ParseHexUint64(*aux.StagedAtBlock) + if err != nil { + return err + } + e.StagedAtBlock = &v + } + e.VirtualIndex, err = ParseHexUint64(aux.VirtualIndex) if err != nil { return err @@ -687,6 +850,7 @@ const ( EpochStatus_InputsProcessed EpochStatus = "INPUTS_PROCESSED" EpochStatus_ClaimComputed EpochStatus = "CLAIM_COMPUTED" EpochStatus_ClaimSubmitted EpochStatus = "CLAIM_SUBMITTED" + EpochStatus_ClaimStaged EpochStatus = "CLAIM_STAGED" EpochStatus_ClaimAccepted EpochStatus = "CLAIM_ACCEPTED" EpochStatus_ClaimRejected EpochStatus = "CLAIM_REJECTED" ) @@ -697,6 +861,7 @@ var EpochStatusAllValues = []EpochStatus{ EpochStatus_InputsProcessed, EpochStatus_ClaimComputed, EpochStatus_ClaimSubmitted, + EpochStatus_ClaimStaged, EpochStatus_ClaimAccepted, EpochStatus_ClaimRejected, } @@ -723,6 +888,8 @@ func (e *EpochStatus) Scan(value any) error { *e = EpochStatus_ClaimComputed case "CLAIM_SUBMITTED": *e = EpochStatus_ClaimSubmitted + case "CLAIM_STAGED": + *e = EpochStatus_ClaimStaged case "CLAIM_ACCEPTED": *e = EpochStatus_ClaimAccepted case "CLAIM_REJECTED": @@ -952,6 +1119,90 @@ func (o *Output) UnmarshalJSON(data []byte) error { return nil } +// Withdrawal records a Withdrawal(uint64 accountIndex, bytes account, bytes output) +// event emitted by an IApplication after the accounts drive has been proved. +// The node observes these only for applications with a non-zero ForecloseBlock +// and AccountsDriveProvedBlock; evmreader uses a FindTransitions scan on the +// on-chain getNumberOfWithdrawals counter to detect them. The contract marks +// each accountIndex as withdrawn, so the event fires at most once per slot. +// +// Account and Output are stored as raw bytes — the recipient encoding inside +// Account is defined by the per-app WithdrawalOutputBuilder and is opaque to +// the node. LogIndex is preserved (despite not being part of the primary key) +// so audits can locate the exact log on chain without re-querying. +type Withdrawal struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + AccountIndex uint64 `sql:"primary_key" json:"account_index"` + Account []byte `json:"account"` + Output []byte `json:"output"` + BlockNumber uint64 `json:"block_number"` + TransactionHash common.Hash `json:"transaction_hash"` + LogIndex uint `json:"log_index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (w *Withdrawal) MarshalJSON() ([]byte, error) { + type Alias Withdrawal + aux := &struct { + AccountIndex string `json:"account_index"` + Account string `json:"account"` + Output string `json:"output"` + BlockNumber string `json:"block_number"` + LogIndex string `json:"log_index"` + *Alias + }{ + AccountIndex: fmt.Sprintf("0x%x", w.AccountIndex), + Account: "0x" + hex.EncodeToString(w.Account), + Output: "0x" + hex.EncodeToString(w.Output), + BlockNumber: fmt.Sprintf("0x%x", w.BlockNumber), + LogIndex: fmt.Sprintf("0x%x", w.LogIndex), + Alias: (*Alias)(w), + } + return json.Marshal(aux) +} + +func (w *Withdrawal) UnmarshalJSON(data []byte) error { + type Alias Withdrawal + aux := &struct { + AccountIndex string `json:"account_index"` + Account string `json:"account"` + Output string `json:"output"` + BlockNumber string `json:"block_number"` + LogIndex string `json:"log_index"` + *Alias + }{Alias: (*Alias)(w)} + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + *w = Withdrawal(*aux.Alias) + + var err error + w.AccountIndex, err = ParseHexUint64(aux.AccountIndex) + if err != nil { + return fmt.Errorf("error on AccountIndex: %w", err) + } + w.Account, err = hexutil.Decode(aux.Account) + if err != nil { + return fmt.Errorf("error on Account: %w", err) + } + w.Output, err = hexutil.Decode(aux.Output) + if err != nil { + return fmt.Errorf("error on Output: %w", err) + } + w.BlockNumber, err = ParseHexUint64(aux.BlockNumber) + if err != nil { + return fmt.Errorf("error on BlockNumber: %w", err) + } + logIndex, err := ParseHexUint64(aux.LogIndex) + if err != nil { + return fmt.Errorf("error on LogIndex: %w", err) + } + w.LogIndex = uint(logIndex) + return nil +} + type Report struct { InputEpochApplicationID int64 `sql:"primary_key" json:"-"` EpochIndex uint64 `json:"epoch_index"` diff --git a/internal/repository/postgres/application.go b/internal/repository/postgres/application.go index 1514e56b9..1275119e7 100644 --- a/internal/repository/postgres/application.go +++ b/internal/repository/postgres/application.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" + "github.com/ethereum/go-ethereum/common" "github.com/go-jet/jet/v2/postgres" "github.com/cartesi/rollups-node/internal/model" @@ -33,6 +34,12 @@ func (r *PostgresRepository) CreateApplication( table.Application.TemplateHash, table.Application.TemplateURI, table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, table.Application.DataAvailability, table.Application.ConsensusType, table.Application.State, @@ -41,7 +48,15 @@ func (r *PostgresRepository) CreateApplication( table.Application.LastInputCheckBlock, table.Application.LastOutputCheckBlock, table.Application.LastTournamentCheckBlock, + table.Application.LastForecloseCheckBlock, + table.Application.LastAccountsDriveProvedCheckBlock, + table.Application.LastWithdrawalCheckBlock, table.Application.ProcessedInputs, + table.Application.ForecloseBlock, + table.Application.ForecloseTransaction, + table.Application.AccountsDriveProvedBlock, + table.Application.AccountsDriveProvedTransaction, + table.Application.AccountsDriveMerkleRoot, ). VALUES( app.Name, @@ -51,6 +66,12 @@ func (r *PostgresRepository) CreateApplication( app.TemplateHash, app.TemplateURI, app.EpochLength, + app.ClaimStagingPeriod, + app.WithdrawalConfig.Guardian, + app.WithdrawalConfig.Log2LeavesPerAccount, + app.WithdrawalConfig.Log2MaxNumOfAccounts, + app.WithdrawalConfig.AccountsDriveStartIndex, + app.WithdrawalConfig.WithdrawalOutputBuilder, app.DataAvailability, app.ConsensusType, app.State, @@ -59,7 +80,15 @@ func (r *PostgresRepository) CreateApplication( app.LastInputCheckBlock, app.LastOutputCheckBlock, app.LastTournamentCheckBlock, + app.LastForecloseCheckBlock, + app.LastAccountsDriveProvedCheckBlock, + app.LastWithdrawalCheckBlock, app.ProcessedInputs, + app.ForecloseBlock, + app.ForecloseTransaction, + app.AccountsDriveProvedBlock, + app.AccountsDriveProvedTransaction, + app.AccountsDriveMerkleRoot, ). RETURNING(table.Application.ID) @@ -150,6 +179,12 @@ func (r *PostgresRepository) GetApplication( table.Application.TemplateHash, table.Application.TemplateURI, table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, table.Application.DataAvailability, table.Application.ConsensusType, table.Application.State, @@ -159,7 +194,15 @@ func (r *PostgresRepository) GetApplication( table.Application.LastInputCheckBlock, table.Application.LastOutputCheckBlock, table.Application.LastTournamentCheckBlock, + table.Application.LastForecloseCheckBlock, + table.Application.LastAccountsDriveProvedCheckBlock, + table.Application.LastWithdrawalCheckBlock, table.Application.ProcessedInputs, + table.Application.ForecloseBlock, + table.Application.ForecloseTransaction, + table.Application.AccountsDriveProvedBlock, + table.Application.AccountsDriveProvedTransaction, + table.Application.AccountsDriveMerkleRoot, table.Application.CreatedAt, table.Application.UpdatedAt, table.ExecutionParameters.ApplicationID, @@ -200,6 +243,12 @@ func (r *PostgresRepository) GetApplication( &app.TemplateHash, &app.TemplateURI, &app.EpochLength, + &app.ClaimStagingPeriod, + &app.WithdrawalConfig.Guardian, + &app.WithdrawalConfig.Log2LeavesPerAccount, + &app.WithdrawalConfig.Log2MaxNumOfAccounts, + &app.WithdrawalConfig.AccountsDriveStartIndex, + &app.WithdrawalConfig.WithdrawalOutputBuilder, &app.DataAvailability, &app.ConsensusType, &app.State, @@ -209,7 +258,15 @@ func (r *PostgresRepository) GetApplication( &app.LastInputCheckBlock, &app.LastOutputCheckBlock, &app.LastTournamentCheckBlock, + &app.LastForecloseCheckBlock, + &app.LastAccountsDriveProvedCheckBlock, + &app.LastWithdrawalCheckBlock, &app.ProcessedInputs, + &app.ForecloseBlock, + &app.ForecloseTransaction, + &app.AccountsDriveProvedBlock, + &app.AccountsDriveProvedTransaction, + &app.AccountsDriveMerkleRoot, &app.CreatedAt, &app.UpdatedAt, &app.ExecutionParameters.ApplicationID, @@ -263,6 +320,15 @@ func (r *PostgresRepository) GetProcessedInputCount( } // UpdateApplication updates an existing application row. +// +// Foreclosure columns (foreclose_block, foreclose_transaction) are deliberately +// excluded: they are write-once and owned exclusively by +// [UpdateApplicationForeclosure], which guards the +// set-once invariant via `WHERE foreclose_block = 0`. Letting +// UpdateApplication carry these columns would let a stale in-memory +// `app.ForecloseBlock == 0` silently clear the marker on a foreclosed +// application — re-arming the drain protocol or stranding the app in ENABLED +// with on-chain reverts. func (r *PostgresRepository) UpdateApplication( ctx context.Context, app *model.Application, @@ -277,6 +343,12 @@ func (r *PostgresRepository) UpdateApplication( table.Application.TemplateHash, table.Application.TemplateURI, table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, table.Application.DataAvailability, table.Application.ConsensusType, table.Application.State, @@ -296,6 +368,12 @@ func (r *PostgresRepository) UpdateApplication( app.TemplateHash, app.TemplateURI, app.EpochLength, + app.ClaimStagingPeriod, + app.WithdrawalConfig.Guardian, + app.WithdrawalConfig.Log2LeavesPerAccount, + app.WithdrawalConfig.Log2MaxNumOfAccounts, + app.WithdrawalConfig.AccountsDriveStartIndex, + app.WithdrawalConfig.WithdrawalOutputBuilder, app.DataAvailability, app.ConsensusType, app.State, @@ -314,6 +392,185 @@ func (r *PostgresRepository) UpdateApplication( return err } +// UpdateApplicationLastForecloseCheckBlock advances the per-app record +// of how far the Foreclosure-event search has scanned. The clause +// `WHERE last_foreclose_check_block < blockNumber` makes the write +// strictly monotonic: out-of-order or duplicate observations from a slow +// tick cannot rewind the value and re-cause a long-window rescan. A no-op +// (0 rows affected) is not an error — it just means the caller's view is +// stale. +func (r *PostgresRepository) UpdateApplicationLastForecloseCheckBlock( + ctx context.Context, + appID int64, + blockNumber uint64, +) error { + updateStmt := table.Application. + UPDATE(table.Application.LastForecloseCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastForecloseCheckBlock.LT(uint64Expr(blockNumber))), + ) + + sqlStr, args := updateStmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + +// UpdateApplicationForeclosure records the one-shot Foreclosure() event and +// advances last_foreclose_check_block in the same +// transaction. If the marker was already recorded, this is an idempotent no-op. +func (r *PostgresRepository) UpdateApplicationForeclosure( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + blockNumber uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + updateStmt := table.Application. + UPDATE( + table.Application.ForecloseBlock, + table.Application.ForecloseTransaction, + ). + SET( + block, + &txHash, + ). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.ForecloseBlock.EQ(uint64Expr(0))), + ) + + sqlStr, args := updateStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + probeStmt := table.Application. + SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(postgres.Int(appID))) + psql, pargs := probeStmt.Sql() + var dummy int64 + err = tx.QueryRow(ctx, psql, pargs...).Scan(&dummy) + if errors.Is(err, sql.ErrNoRows) { + return repository.ErrNotFound + } + if err != nil { + return fmt.Errorf("probing application existence (id=%d): %w", appID, err) + } + return tx.Commit(ctx) + } + + cursorStmt := table.Application. + UPDATE(table.Application.LastForecloseCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastForecloseCheckBlock.LT(uint64Expr(blockNumber))), + ) + sqlStr, args = cursorStmt.Sql() + if _, err := tx.Exec(ctx, sqlStr, args...); err != nil { + return err + } + return tx.Commit(ctx) +} + +// UpdateAccountsDriveProved records the one-shot drive-prove transition and +// advances the scanner cursor in the same +// transaction. If the marker was already recorded, this is an idempotent no-op. +func (r *PostgresRepository) UpdateAccountsDriveProved( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + root common.Hash, + blockNumber uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + updateStmt := table.Application. + UPDATE( + table.Application.AccountsDriveProvedBlock, + table.Application.AccountsDriveProvedTransaction, + table.Application.AccountsDriveMerkleRoot, + ). + SET( + block, + &txHash, + &root, + ). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.AccountsDriveProvedBlock.EQ(uint64Expr(0))), + ) + + sqlStr, args := updateStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + probeStmt := table.Application. + SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(postgres.Int(appID))) + psql, pargs := probeStmt.Sql() + var dummy int64 + err = tx.QueryRow(ctx, psql, pargs...).Scan(&dummy) + if errors.Is(err, sql.ErrNoRows) { + return repository.ErrNotFound + } + if err != nil { + return fmt.Errorf("probing application existence (id=%d): %w", appID, err) + } + return tx.Commit(ctx) + } + + cursorStmt := table.Application. + UPDATE(table.Application.LastAccountsDriveProvedCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastAccountsDriveProvedCheckBlock.LT(uint64Expr(blockNumber))), + ) + sqlStr, args = cursorStmt.Sql() + if _, err := tx.Exec(ctx, sqlStr, args...); err != nil { + return err + } + return tx.Commit(ctx) +} + +// UpdateApplicationLastAccountsDriveProvedCheckBlock advances the per-app +// scanner cursor for the getAccountsDriveMerkleRoot().wasProved observer. +// Strictly monotonic — mirrors UpdateApplicationLastForecloseCheckBlock. +func (r *PostgresRepository) UpdateApplicationLastAccountsDriveProvedCheckBlock( + ctx context.Context, + appID int64, + blockNumber uint64, +) error { + updateStmt := table.Application. + UPDATE(table.Application.LastAccountsDriveProvedCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastAccountsDriveProvedCheckBlock.LT(uint64Expr(blockNumber))), + ) + + sqlStr, args := updateStmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + func (r *PostgresRepository) UpdateApplicationState( ctx context.Context, appID int64, @@ -528,6 +785,13 @@ func (r *PostgresRepository) ListApplications( if f.ConsensusType != nil { conditions = append(conditions, table.Application.ConsensusType.EQ(postgres.NewEnumValue(f.ConsensusType.String()))) } + if f.ForeclosureRecorded != nil { + if *f.ForeclosureRecorded { + conditions = append(conditions, table.Application.ForecloseBlock.GT(uint64Expr(0))) + } else { + conditions = append(conditions, table.Application.ForecloseBlock.EQ(uint64Expr(0))) + } + } tx, err := beginReadTx(ctx, r.db) if err != nil { @@ -557,6 +821,12 @@ func (r *PostgresRepository) ListApplications( table.Application.TemplateHash, table.Application.TemplateURI, table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, table.Application.DataAvailability, table.Application.ConsensusType, table.Application.State, @@ -566,7 +836,15 @@ func (r *PostgresRepository) ListApplications( table.Application.LastInputCheckBlock, table.Application.LastOutputCheckBlock, table.Application.LastTournamentCheckBlock, + table.Application.LastForecloseCheckBlock, + table.Application.LastAccountsDriveProvedCheckBlock, + table.Application.LastWithdrawalCheckBlock, table.Application.ProcessedInputs, + table.Application.ForecloseBlock, + table.Application.ForecloseTransaction, + table.Application.AccountsDriveProvedBlock, + table.Application.AccountsDriveProvedTransaction, + table.Application.AccountsDriveMerkleRoot, table.Application.CreatedAt, table.Application.UpdatedAt, table.ExecutionParameters.ApplicationID, @@ -623,6 +901,12 @@ func (r *PostgresRepository) ListApplications( &app.TemplateHash, &app.TemplateURI, &app.EpochLength, + &app.ClaimStagingPeriod, + &app.WithdrawalConfig.Guardian, + &app.WithdrawalConfig.Log2LeavesPerAccount, + &app.WithdrawalConfig.Log2MaxNumOfAccounts, + &app.WithdrawalConfig.AccountsDriveStartIndex, + &app.WithdrawalConfig.WithdrawalOutputBuilder, &app.DataAvailability, &app.ConsensusType, &app.State, @@ -632,7 +916,15 @@ func (r *PostgresRepository) ListApplications( &app.LastInputCheckBlock, &app.LastOutputCheckBlock, &app.LastTournamentCheckBlock, + &app.LastForecloseCheckBlock, + &app.LastAccountsDriveProvedCheckBlock, + &app.LastWithdrawalCheckBlock, &app.ProcessedInputs, + &app.ForecloseBlock, + &app.ForecloseTransaction, + &app.AccountsDriveProvedBlock, + &app.AccountsDriveProvedTransaction, + &app.AccountsDriveMerkleRoot, &app.CreatedAt, &app.UpdatedAt, &app.ExecutionParameters.ApplicationID, diff --git a/internal/repository/postgres/claimer.go b/internal/repository/postgres/claimer.go index bef1bd8a8..e657580b2 100644 --- a/internal/repository/postgres/claimer.go +++ b/internal/repository/postgres/claimer.go @@ -19,6 +19,13 @@ import ( // Retrieve the claim of each application with the smallest index. // The query may return either 0 or 1 entries per application. +// +// The returned model.Application is partially populated: the SELECT omits +// LastEpochCheckBlock, LastTournamentCheckBlock, LastForecloseCheckBlock, +// AccountsDriveProvedBlock, AccountsDriveProvedTransaction, and +// AccountsDriveMerkleRoot. Callers within the claimer pipeline only need +// the identity / consensus / state / foreclose-marker fields surfaced here; +// callers that need the omitted fields must use GetApplication instead. func (r *PostgresRepository) selectOldestClaimPerApp( ctx context.Context, tx pgx.Tx, @@ -28,7 +35,9 @@ func (r *PostgresRepository) selectOldestClaimPerApp( map[int64]*model.Application, error, ) { - if (epochStatus != model.EpochStatus_ClaimSubmitted) && (epochStatus != model.EpochStatus_ClaimComputed) { + if (epochStatus != model.EpochStatus_ClaimSubmitted) && + (epochStatus != model.EpochStatus_ClaimComputed) && + (epochStatus != model.EpochStatus_ClaimStaged) { return nil, nil, fmt.Errorf("invalid epoch status: %v", epochStatus) } @@ -40,9 +49,12 @@ func (r *PostgresRepository) selectOldestClaimPerApp( table.Epoch.Index, table.Epoch.FirstBlock, table.Epoch.LastBlock, + table.Epoch.MachineHash, table.Epoch.OutputsMerkleRoot, + table.Epoch.OutputsMerkleProof, table.Epoch.ClaimTransactionHash, table.Epoch.Status, + table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt, @@ -55,6 +67,12 @@ func (r *PostgresRepository) selectOldestClaimPerApp( table.Application.TemplateHash, table.Application.TemplateURI, table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, table.Application.DataAvailability, table.Application.ConsensusType, table.Application.State, @@ -63,6 +81,8 @@ func (r *PostgresRepository) selectOldestClaimPerApp( table.Application.LastInputCheckBlock, table.Application.LastOutputCheckBlock, table.Application.ProcessedInputs, + table.Application.ForecloseBlock, + table.Application.ForecloseTransaction, table.Application.CreatedAt, table.Application.UpdatedAt, ). @@ -101,9 +121,12 @@ func (r *PostgresRepository) selectOldestClaimPerApp( &epoch.Index, &epoch.FirstBlock, &epoch.LastBlock, + &epoch.MachineHash, &epoch.OutputsMerkleRoot, + &epoch.OutputsMerkleProof, &epoch.ClaimTransactionHash, &epoch.Status, + &epoch.StagedAtBlock, &epoch.VirtualIndex, &epoch.CreatedAt, &epoch.UpdatedAt, @@ -116,6 +139,12 @@ func (r *PostgresRepository) selectOldestClaimPerApp( &application.TemplateHash, &application.TemplateURI, &application.EpochLength, + &application.ClaimStagingPeriod, + &application.WithdrawalConfig.Guardian, + &application.WithdrawalConfig.Log2LeavesPerAccount, + &application.WithdrawalConfig.Log2MaxNumOfAccounts, + &application.WithdrawalConfig.AccountsDriveStartIndex, + &application.WithdrawalConfig.WithdrawalOutputBuilder, &application.DataAvailability, &application.ConsensusType, &application.State, @@ -124,6 +153,8 @@ func (r *PostgresRepository) selectOldestClaimPerApp( &application.LastInputCheckBlock, &application.LastOutputCheckBlock, &application.ProcessedInputs, + &application.ForecloseBlock, + &application.ForecloseTransaction, &application.CreatedAt, &application.UpdatedAt, ) @@ -139,18 +170,21 @@ func (r *PostgresRepository) selectOldestClaimPerApp( return epochs, applications, nil } -// Retrieve the newest accepted claim of each application -func (r *PostgresRepository) selectNewestAcceptedClaimPerApp( +// Retrieve the newest claim barrier of each application. +func (r *PostgresRepository) selectNewestClaimBarrierPerApp( ctx context.Context, tx pgx.Tx, - includeSubmitted bool, + statuses ...model.EpochStatus, ) ( map[int64]*model.Epoch, error, ) { - expr := table.Epoch.Status.EQ(postgres.NewEnumValue(model.EpochStatus_ClaimAccepted.String())) - if includeSubmitted { - expr = expr.OR(table.Epoch.Status.EQ(postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()))) + if len(statuses) == 0 { + return nil, fmt.Errorf("selecting newest claim barrier: no statuses provided") + } + statusExprs := make([]postgres.Expression, 0, len(statuses)) + for _, status := range statuses { + statusExprs = append(statusExprs, postgres.NewEnumValue(status.String())) } // NOTE(mpolitzer): DISTINCT ON is a postgres extension. To implement @@ -161,9 +195,12 @@ func (r *PostgresRepository) selectNewestAcceptedClaimPerApp( table.Epoch.Index, table.Epoch.FirstBlock, table.Epoch.LastBlock, + table.Epoch.MachineHash, table.Epoch.OutputsMerkleRoot, + table.Epoch.OutputsMerkleProof, table.Epoch.ClaimTransactionHash, table.Epoch.Status, + table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt, @@ -177,7 +214,8 @@ func (r *PostgresRepository) selectNewestAcceptedClaimPerApp( ), ). WHERE( - expr.AND(table.Application.State.EQ(enum.ApplicationState.Enabled)). + table.Epoch.Status.IN(statusExprs...). + AND(table.Application.State.EQ(enum.ApplicationState.Enabled)). AND(table.Application.ConsensusType.NOT_EQ(enum.Consensus.Prt)), ). ORDER_BY( @@ -188,7 +226,7 @@ func (r *PostgresRepository) selectNewestAcceptedClaimPerApp( sqlStr, args := stmt.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { - return nil, fmt.Errorf("querying newest accepted claim per app: %w", err) + return nil, fmt.Errorf("querying newest claim barrier per app: %w", err) } defer rows.Close() @@ -200,20 +238,23 @@ func (r *PostgresRepository) selectNewestAcceptedClaimPerApp( &epoch.Index, &epoch.FirstBlock, &epoch.LastBlock, + &epoch.MachineHash, &epoch.OutputsMerkleRoot, + &epoch.OutputsMerkleProof, &epoch.ClaimTransactionHash, &epoch.Status, + &epoch.StagedAtBlock, &epoch.VirtualIndex, &epoch.CreatedAt, &epoch.UpdatedAt, ) if err != nil { - return nil, fmt.Errorf("scanning accepted epoch row: %w", err) + return nil, fmt.Errorf("scanning claim barrier epoch row: %w", err) } epochs[epoch.ApplicationID] = &epoch } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterating accepted claim rows: %w", err) + return nil, fmt.Errorf("iterating claim barrier rows: %w", err) } return epochs, nil } @@ -239,12 +280,18 @@ func (r *PostgresRepository) SelectSubmittedClaimPairsPerApp(ctx context.Context return nil, nil, nil, fmt.Errorf("selecting oldest computed claim per app: %w", err) } - acceptedOrSubmitted, err := r.selectNewestAcceptedClaimPerApp(ctx, tx, true) + barriers, err := r.selectNewestClaimBarrierPerApp( + ctx, + tx, + model.EpochStatus_ClaimAccepted, + model.EpochStatus_ClaimSubmitted, + model.EpochStatus_ClaimStaged, + ) if err != nil { - return nil, nil, nil, fmt.Errorf("selecting newest accepted claim per app: %w", err) + return nil, nil, nil, fmt.Errorf("selecting newest claim barrier per app: %w", err) } - return acceptedOrSubmitted, computed, applications, err + return barriers, computed, applications, err } func (r *PostgresRepository) SelectAcceptedClaimPairsPerApp(ctx context.Context) ( @@ -268,7 +315,7 @@ func (r *PostgresRepository) SelectAcceptedClaimPairsPerApp(ctx context.Context) return nil, nil, nil, fmt.Errorf("selecting oldest submitted claim per app: %w", err) } - accepted, err := r.selectNewestAcceptedClaimPerApp(ctx, tx, false) + accepted, err := r.selectNewestClaimBarrierPerApp(ctx, tx, model.EpochStatus_ClaimAccepted) if err != nil { return nil, nil, nil, fmt.Errorf("selecting newest accepted claim per app: %w", err) } @@ -311,17 +358,152 @@ func (r *PostgresRepository) UpdateEpochWithSubmittedClaim( return nil } +// UpdateEpochWithAcceptedClaim transitions an epoch to CLAIM_ACCEPTED. The +// 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_SUBMITTED → CLAIM_ACCEPTED is permitted by the trigger but not +// reached by the v3 happy path. Kept for resilience. +// +// staged_at_block is intentionally left untouched: it is a permanent fact +// (the chain block at which staging happened), kept across the transition +// to ACCEPTED for audit/forensics — same convention as +// claim_transaction_hash. The relaxed staged_requires_block CHECK permits +// this. +// +// txHash is optional: +// - When non-nil, claim_transaction_hash is set to the supplied value. +// This is the catch-up path: an epoch coming directly from +// CLAIM_COMPUTED never went through CLAIM_SUBMITTED, so the column was +// never populated. Callers that observed the ClaimAccepted event pass +// the event's tx hash here for forensic continuity. +// - When nil, claim_transaction_hash is left untouched. This is the +// normal-flow path: the column was set during the CLAIM_SUBMITTED +// transition and carries through the rest of the FSM. func (r *PostgresRepository) UpdateEpochWithAcceptedClaim( ctx context.Context, applicationID int64, index uint64, + txHash *common.Hash, +) error { + whereClause := table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.IN( + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()), + postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), + postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()), + )) + + var updStmt postgres.UpdateStatement + if txHash == nil { + updStmt = table.Epoch. + UPDATE(table.Epoch.Status). + SET(postgres.NewEnumValue(model.EpochStatus_ClaimAccepted.String())). + FROM(table.Application). + WHERE(whereClause) + } else { + updStmt = table.Epoch. + UPDATE(table.Epoch.Status, table.Epoch.ClaimTransactionHash). + SET(postgres.NewEnumValue(model.EpochStatus_ClaimAccepted.String()), *txHash). + FROM(table.Application). + WHERE(whereClause) + } + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing update for accepted claim (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// RejectEpochAndSetApplicationInoperable atomically records that the local +// claim lost the applicable consensus/dispute process and halts the +// application. Quorum rejection is only a normal outcome before the local +// claim has staged; once CLAIM_STAGED is recorded, a different staged or +// accepted claim for the same epoch would violate the contract's single-staged +// claim invariant. Keeping both writes in one transaction avoids a half-state +// where the epoch disappears from claimer work maps while the app remains +// enabled. +func (r *PostgresRepository) RejectEpochAndSetApplicationInoperable( + ctx context.Context, + applicationID int64, + index uint64, + reason string, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning transaction for rejected claim update: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + rejectStmt := table.Epoch. + UPDATE(table.Epoch.Status). + SET(postgres.NewEnumValue(model.EpochStatus_ClaimRejected.String())). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.IN( + postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()), + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()), + )), + ) + + sqlStr, args := rejectStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing rejected claim update (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + + appStmt := table.Application. + UPDATE( + table.Application.State, + table.Application.Reason, + ). + SET( + model.ApplicationState_Inoperable, + &reason, + ). + WHERE(table.Application.ID.EQ(postgres.Int64(applicationID))) + + sqlStr, args = appStmt.Sql() + cmd, err = tx.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing inoperable application update (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + + return tx.Commit(ctx) +} + +// UpdateEpochToStaged transitions an epoch from CLAIM_SUBMITTED to +// CLAIM_STAGED, recording the on-chain staging block. +func (r *PostgresRepository) UpdateEpochToStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, ) error { updStmt := table.Epoch. UPDATE( table.Epoch.Status, + table.Epoch.StagedAtBlock, ). SET( - postgres.NewEnumValue(model.EpochStatus_ClaimAccepted.String()), + postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), + uint64Expr(stagedAtBlock), ). FROM( table.Application, @@ -329,16 +511,169 @@ func (r *PostgresRepository) UpdateEpochWithAcceptedClaim( WHERE( table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). AND(table.Epoch.Index.EQ(uint64Expr(index))). - AND(table.Epoch.Status.EQ(postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()))), + AND(table.Epoch.Status.EQ( + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()))), ) sqlStr, args := updStmt.Sql() cmd, err := r.db.Exec(ctx, sqlStr, args...) if err != nil { - return fmt.Errorf("executing update for accepted claim (app=%d, index=%d): %w", applicationID, index, err) + return fmt.Errorf("executing update to staged (app=%d, index=%d): %w", applicationID, index, err) } if cmd.RowsAffected() == 0 { return repository.ErrNoUpdate } return nil } + +// UpdateEpochThroughStaging atomically transitions an epoch from +// CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_STAGED in a single transaction. +// Used by the Authority/Quorum-deciding fast-path where the submit-tx +// receipt contains both ClaimSubmitted and ClaimStaged events; the trigger +// permits both legs and we record both transitions atomically so that a +// crash between them cannot leave the DB inconsistent with the chain. +func (r *PostgresRepository) UpdateEpochThroughStaging( + ctx context.Context, + applicationID int64, + index uint64, + transactionHash common.Hash, + stagedAtBlock uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning transaction for through-staging update: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + submitStmt := table.Epoch. + UPDATE( + table.Epoch.ClaimTransactionHash, + table.Epoch.Status, + ). + SET( + transactionHash, + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()), + ). + FROM( + table.Application, + ). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.EQ( + postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()))), + ) + sqlStr, args := submitStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing through-staging submit leg (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + + stageStmt := table.Epoch. + UPDATE( + table.Epoch.Status, + table.Epoch.StagedAtBlock, + ). + SET( + postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), + uint64Expr(stagedAtBlock), + ). + FROM( + table.Application, + ). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.EQ( + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()))), + ) + sqlStr, args = stageStmt.Sql() + cmd, err = tx.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing through-staging stage leg (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + + return tx.Commit(ctx) +} + +// 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). +func (r *PostgresRepository) UpdateEpochReconciledStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, +) error { + updStmt := table.Epoch. + UPDATE( + table.Epoch.Status, + table.Epoch.StagedAtBlock, + ). + SET( + postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), + uint64Expr(stagedAtBlock), + ). + FROM( + table.Application, + ). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.EQ( + postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing reconciled-staged update (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// SelectStagedClaimPairsPerApp returns, for each Authority/Quorum application +// with at least one CLAIM_STAGED epoch: +// - the oldest CLAIM_STAGED epoch (the next one waiting to be accepted), +// - the newest already-accepted epoch (for cross-checks), +// - the application row. +// +// Used by stageClaimsAndUpdateDatabase / acceptStagedClaimsAndIssueAcceptTx +// to drive the CLAIM_STAGED → CLAIM_ACCEPTED transitions. +func (r *PostgresRepository) SelectStagedClaimPairsPerApp(ctx context.Context) ( + map[int64]*model.Epoch, + map[int64]*model.Epoch, + map[int64]*model.Application, + error, +) { + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{ + IsoLevel: pgx.RepeatableRead, + AccessMode: pgx.ReadOnly, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("beginning read-only transaction for staged claims: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + staged, applications, err := r.selectOldestClaimPerApp(ctx, tx, model.EpochStatus_ClaimStaged) + if err != nil { + return nil, nil, nil, fmt.Errorf("selecting oldest staged claim per app: %w", err) + } + + accepted, err := r.selectNewestClaimBarrierPerApp(ctx, tx, model.EpochStatus_ClaimAccepted) + if err != nil { + return nil, nil, nil, fmt.Errorf("selecting newest accepted claim per app: %w", err) + } + + return accepted, staged, applications, err +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/epochstatus.go b/internal/repository/postgres/db/rollupsdb/public/enum/epochstatus.go index b0b04f8cc..7ac10a283 100644 --- a/internal/repository/postgres/db/rollupsdb/public/enum/epochstatus.go +++ b/internal/repository/postgres/db/rollupsdb/public/enum/epochstatus.go @@ -15,6 +15,7 @@ var EpochStatus = &struct { InputsProcessed postgres.StringExpression ClaimComputed postgres.StringExpression ClaimSubmitted postgres.StringExpression + ClaimStaged postgres.StringExpression ClaimAccepted postgres.StringExpression ClaimRejected postgres.StringExpression }{ @@ -23,6 +24,7 @@ var EpochStatus = &struct { InputsProcessed: postgres.NewEnumValue("INPUTS_PROCESSED"), ClaimComputed: postgres.NewEnumValue("CLAIM_COMPUTED"), ClaimSubmitted: postgres.NewEnumValue("CLAIM_SUBMITTED"), + ClaimStaged: postgres.NewEnumValue("CLAIM_STAGED"), ClaimAccepted: postgres.NewEnumValue("CLAIM_ACCEPTED"), ClaimRejected: postgres.NewEnumValue("CLAIM_REJECTED"), } diff --git a/internal/repository/postgres/db/rollupsdb/public/table/application.go b/internal/repository/postgres/db/rollupsdb/public/table/application.go index 8a855c89c..6d4f66032 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/application.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/application.go @@ -17,26 +17,40 @@ type applicationTable struct { postgres.Table // Columns - ID postgres.ColumnInteger - Name postgres.ColumnString - IapplicationAddress postgres.ColumnBytea - IconsensusAddress postgres.ColumnBytea - IinputboxAddress postgres.ColumnBytea - IinputboxBlock postgres.ColumnFloat - TemplateHash postgres.ColumnBytea - TemplateURI postgres.ColumnString - EpochLength postgres.ColumnFloat - DataAvailability postgres.ColumnBytea - ConsensusType postgres.ColumnString - State postgres.ColumnString - Reason postgres.ColumnString - LastEpochCheckBlock postgres.ColumnFloat - LastInputCheckBlock postgres.ColumnFloat - LastOutputCheckBlock postgres.ColumnFloat - LastTournamentCheckBlock postgres.ColumnFloat - ProcessedInputs postgres.ColumnFloat - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz + ID postgres.ColumnInteger + Name postgres.ColumnString + IapplicationAddress postgres.ColumnBytea + IconsensusAddress postgres.ColumnBytea + IinputboxAddress postgres.ColumnBytea + IinputboxBlock postgres.ColumnFloat + TemplateHash postgres.ColumnBytea + TemplateURI postgres.ColumnString + EpochLength postgres.ColumnFloat + ClaimStagingPeriod postgres.ColumnFloat + WithdrawalGuardian postgres.ColumnBytea + WithdrawalLog2LeavesPerAccount postgres.ColumnInteger + WithdrawalLog2MaxNumOfAccounts postgres.ColumnInteger + WithdrawalAccountsDriveStartIndex postgres.ColumnFloat + WithdrawalOutputBuilder postgres.ColumnBytea + DataAvailability postgres.ColumnBytea + ConsensusType postgres.ColumnString + State postgres.ColumnString + Reason postgres.ColumnString + LastEpochCheckBlock postgres.ColumnFloat + LastInputCheckBlock postgres.ColumnFloat + LastOutputCheckBlock postgres.ColumnFloat + LastTournamentCheckBlock postgres.ColumnFloat + LastForecloseCheckBlock postgres.ColumnFloat + LastAccountsDriveProvedCheckBlock postgres.ColumnFloat + LastWithdrawalCheckBlock postgres.ColumnFloat + ProcessedInputs postgres.ColumnFloat + ForecloseBlock postgres.ColumnFloat + ForecloseTransaction postgres.ColumnBytea + AccountsDriveProvedBlock postgres.ColumnFloat + AccountsDriveProvedTransaction postgres.ColumnBytea + AccountsDriveMerkleRoot postgres.ColumnBytea + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -78,55 +92,83 @@ func newApplicationTable(schemaName, tableName, alias string) *ApplicationTable func newApplicationTableImpl(schemaName, tableName, alias string) applicationTable { var ( - IDColumn = postgres.IntegerColumn("id") - NameColumn = postgres.StringColumn("name") - IapplicationAddressColumn = postgres.ByteaColumn("iapplication_address") - IconsensusAddressColumn = postgres.ByteaColumn("iconsensus_address") - IinputboxAddressColumn = postgres.ByteaColumn("iinputbox_address") - IinputboxBlockColumn = postgres.FloatColumn("iinputbox_block") - TemplateHashColumn = postgres.ByteaColumn("template_hash") - TemplateURIColumn = postgres.StringColumn("template_uri") - EpochLengthColumn = postgres.FloatColumn("epoch_length") - DataAvailabilityColumn = postgres.ByteaColumn("data_availability") - ConsensusTypeColumn = postgres.StringColumn("consensus_type") - StateColumn = postgres.StringColumn("state") - ReasonColumn = postgres.StringColumn("reason") - LastEpochCheckBlockColumn = postgres.FloatColumn("last_epoch_check_block") - LastInputCheckBlockColumn = postgres.FloatColumn("last_input_check_block") - LastOutputCheckBlockColumn = postgres.FloatColumn("last_output_check_block") - LastTournamentCheckBlockColumn = postgres.FloatColumn("last_tournament_check_block") - ProcessedInputsColumn = postgres.FloatColumn("processed_inputs") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{IDColumn, NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, DataAvailabilityColumn, ConsensusTypeColumn, StateColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, ProcessedInputsColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, DataAvailabilityColumn, ConsensusTypeColumn, StateColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, ProcessedInputsColumn, CreatedAtColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + IDColumn = postgres.IntegerColumn("id") + NameColumn = postgres.StringColumn("name") + IapplicationAddressColumn = postgres.ByteaColumn("iapplication_address") + IconsensusAddressColumn = postgres.ByteaColumn("iconsensus_address") + IinputboxAddressColumn = postgres.ByteaColumn("iinputbox_address") + IinputboxBlockColumn = postgres.FloatColumn("iinputbox_block") + TemplateHashColumn = postgres.ByteaColumn("template_hash") + TemplateURIColumn = postgres.StringColumn("template_uri") + EpochLengthColumn = postgres.FloatColumn("epoch_length") + ClaimStagingPeriodColumn = postgres.FloatColumn("claim_staging_period") + WithdrawalGuardianColumn = postgres.ByteaColumn("withdrawal_guardian") + WithdrawalLog2LeavesPerAccountColumn = postgres.IntegerColumn("withdrawal_log2_leaves_per_account") + 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") + StateColumn = postgres.StringColumn("state") + ReasonColumn = postgres.StringColumn("reason") + LastEpochCheckBlockColumn = postgres.FloatColumn("last_epoch_check_block") + LastInputCheckBlockColumn = postgres.FloatColumn("last_input_check_block") + LastOutputCheckBlockColumn = postgres.FloatColumn("last_output_check_block") + LastTournamentCheckBlockColumn = postgres.FloatColumn("last_tournament_check_block") + LastForecloseCheckBlockColumn = postgres.FloatColumn("last_foreclose_check_block") + LastAccountsDriveProvedCheckBlockColumn = postgres.FloatColumn("last_accounts_drive_proved_check_block") + LastWithdrawalCheckBlockColumn = postgres.FloatColumn("last_withdrawal_check_block") + ProcessedInputsColumn = postgres.FloatColumn("processed_inputs") + ForecloseBlockColumn = postgres.FloatColumn("foreclose_block") + ForecloseTransactionColumn = postgres.ByteaColumn("foreclose_transaction") + AccountsDriveProvedBlockColumn = postgres.FloatColumn("accounts_drive_proved_block") + AccountsDriveProvedTransactionColumn = postgres.ByteaColumn("accounts_drive_proved_transaction") + 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, StateColumn, 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, StateColumn, 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, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ForecloseBlockColumn, AccountsDriveProvedBlockColumn, CreatedAtColumn, UpdatedAtColumn} ) return applicationTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - ID: IDColumn, - Name: NameColumn, - IapplicationAddress: IapplicationAddressColumn, - IconsensusAddress: IconsensusAddressColumn, - IinputboxAddress: IinputboxAddressColumn, - IinputboxBlock: IinputboxBlockColumn, - TemplateHash: TemplateHashColumn, - TemplateURI: TemplateURIColumn, - EpochLength: EpochLengthColumn, - DataAvailability: DataAvailabilityColumn, - ConsensusType: ConsensusTypeColumn, - State: StateColumn, - Reason: ReasonColumn, - LastEpochCheckBlock: LastEpochCheckBlockColumn, - LastInputCheckBlock: LastInputCheckBlockColumn, - LastOutputCheckBlock: LastOutputCheckBlockColumn, - LastTournamentCheckBlock: LastTournamentCheckBlockColumn, - ProcessedInputs: ProcessedInputsColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, + ID: IDColumn, + Name: NameColumn, + IapplicationAddress: IapplicationAddressColumn, + IconsensusAddress: IconsensusAddressColumn, + IinputboxAddress: IinputboxAddressColumn, + IinputboxBlock: IinputboxBlockColumn, + TemplateHash: TemplateHashColumn, + TemplateURI: TemplateURIColumn, + EpochLength: EpochLengthColumn, + ClaimStagingPeriod: ClaimStagingPeriodColumn, + WithdrawalGuardian: WithdrawalGuardianColumn, + WithdrawalLog2LeavesPerAccount: WithdrawalLog2LeavesPerAccountColumn, + WithdrawalLog2MaxNumOfAccounts: WithdrawalLog2MaxNumOfAccountsColumn, + WithdrawalAccountsDriveStartIndex: WithdrawalAccountsDriveStartIndexColumn, + WithdrawalOutputBuilder: WithdrawalOutputBuilderColumn, + DataAvailability: DataAvailabilityColumn, + ConsensusType: ConsensusTypeColumn, + State: StateColumn, + Reason: ReasonColumn, + LastEpochCheckBlock: LastEpochCheckBlockColumn, + LastInputCheckBlock: LastInputCheckBlockColumn, + LastOutputCheckBlock: LastOutputCheckBlockColumn, + LastTournamentCheckBlock: LastTournamentCheckBlockColumn, + LastForecloseCheckBlock: LastForecloseCheckBlockColumn, + LastAccountsDriveProvedCheckBlock: LastAccountsDriveProvedCheckBlockColumn, + LastWithdrawalCheckBlock: LastWithdrawalCheckBlockColumn, + ProcessedInputs: ProcessedInputsColumn, + ForecloseBlock: ForecloseBlockColumn, + ForecloseTransaction: ForecloseTransactionColumn, + AccountsDriveProvedBlock: AccountsDriveProvedBlockColumn, + AccountsDriveProvedTransaction: AccountsDriveProvedTransactionColumn, + AccountsDriveMerkleRoot: AccountsDriveMerkleRootColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/epoch.go b/internal/repository/postgres/db/rollupsdb/public/table/epoch.go index 091e788f6..2823afa5e 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/epoch.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/epoch.go @@ -31,6 +31,7 @@ type epochTable struct { TournamentAddress postgres.ColumnBytea ClaimTransactionHash postgres.ColumnBytea Status postgres.ColumnString + StagedAtBlock postgres.ColumnFloat VirtualIndex postgres.ColumnFloat CreatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz @@ -89,11 +90,12 @@ func newEpochTableImpl(schemaName, tableName, alias string) epochTable { TournamentAddressColumn = postgres.ByteaColumn("tournament_address") ClaimTransactionHashColumn = postgres.ByteaColumn("claim_transaction_hash") StatusColumn = postgres.StringColumn("status") + StagedAtBlockColumn = postgres.FloatColumn("staged_at_block") VirtualIndexColumn = postgres.FloatColumn("virtual_index") CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, IndexColumn, FirstBlockColumn, LastBlockColumn, InputIndexLowerBoundColumn, InputIndexUpperBoundColumn, MachineHashColumn, OutputsMerkleRootColumn, OutputsMerkleProofColumn, CommitmentColumn, CommitmentProofColumn, TournamentAddressColumn, ClaimTransactionHashColumn, StatusColumn, VirtualIndexColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{FirstBlockColumn, LastBlockColumn, InputIndexLowerBoundColumn, InputIndexUpperBoundColumn, MachineHashColumn, OutputsMerkleRootColumn, OutputsMerkleProofColumn, CommitmentColumn, CommitmentProofColumn, TournamentAddressColumn, ClaimTransactionHashColumn, StatusColumn, VirtualIndexColumn, CreatedAtColumn, UpdatedAtColumn} + allColumns = postgres.ColumnList{ApplicationIDColumn, IndexColumn, FirstBlockColumn, LastBlockColumn, InputIndexLowerBoundColumn, InputIndexUpperBoundColumn, MachineHashColumn, OutputsMerkleRootColumn, OutputsMerkleProofColumn, CommitmentColumn, CommitmentProofColumn, TournamentAddressColumn, ClaimTransactionHashColumn, StatusColumn, StagedAtBlockColumn, VirtualIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{FirstBlockColumn, LastBlockColumn, InputIndexLowerBoundColumn, InputIndexUpperBoundColumn, MachineHashColumn, OutputsMerkleRootColumn, OutputsMerkleProofColumn, CommitmentColumn, CommitmentProofColumn, TournamentAddressColumn, ClaimTransactionHashColumn, StatusColumn, StagedAtBlockColumn, VirtualIndexColumn, CreatedAtColumn, UpdatedAtColumn} defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} ) @@ -115,6 +117,7 @@ func newEpochTableImpl(schemaName, tableName, alias string) epochTable { TournamentAddress: TournamentAddressColumn, ClaimTransactionHash: ClaimTransactionHashColumn, Status: StatusColumn, + StagedAtBlock: StagedAtBlockColumn, VirtualIndex: VirtualIndexColumn, CreatedAt: CreatedAtColumn, UpdatedAt: UpdatedAtColumn, 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 9865eb4cd..93fa66ad1 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 @@ -23,4 +23,5 @@ func UseSchema(schema string) { SchemaMigrations = SchemaMigrations.FromSchema(schema) StateHashes = StateHashes.FromSchema(schema) Tournaments = Tournaments.FromSchema(schema) + Withdrawal = Withdrawal.FromSchema(schema) } diff --git a/internal/repository/postgres/db/rollupsdb/public/table/withdrawal.go b/internal/repository/postgres/db/rollupsdb/public/table/withdrawal.go new file mode 100644 index 000000000..1686ad8a1 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/withdrawal.go @@ -0,0 +1,102 @@ +// +// 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 Withdrawal = newWithdrawalTable("public", "withdrawal", "") + +type withdrawalTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + AccountIndex postgres.ColumnFloat + Account postgres.ColumnBytea + Output postgres.ColumnBytea + BlockNumber postgres.ColumnFloat + TransactionHash postgres.ColumnBytea + LogIndex postgres.ColumnInteger + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type WithdrawalTable struct { + withdrawalTable + + EXCLUDED withdrawalTable +} + +// AS creates new WithdrawalTable with assigned alias +func (a WithdrawalTable) AS(alias string) *WithdrawalTable { + return newWithdrawalTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new WithdrawalTable with assigned schema name +func (a WithdrawalTable) FromSchema(schemaName string) *WithdrawalTable { + return newWithdrawalTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new WithdrawalTable with assigned table prefix +func (a WithdrawalTable) WithPrefix(prefix string) *WithdrawalTable { + return newWithdrawalTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new WithdrawalTable with assigned table suffix +func (a WithdrawalTable) WithSuffix(suffix string) *WithdrawalTable { + return newWithdrawalTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newWithdrawalTable(schemaName, tableName, alias string) *WithdrawalTable { + return &WithdrawalTable{ + withdrawalTable: newWithdrawalTableImpl(schemaName, tableName, alias), + EXCLUDED: newWithdrawalTableImpl("", "excluded", ""), + } +} + +func newWithdrawalTableImpl(schemaName, tableName, alias string) withdrawalTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + AccountIndexColumn = postgres.FloatColumn("account_index") + AccountColumn = postgres.ByteaColumn("account") + OutputColumn = postgres.ByteaColumn("output") + BlockNumberColumn = postgres.FloatColumn("block_number") + TransactionHashColumn = postgres.ByteaColumn("transaction_hash") + LogIndexColumn = postgres.IntegerColumn("log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, AccountIndexColumn, AccountColumn, OutputColumn, BlockNumberColumn, TransactionHashColumn, LogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{AccountColumn, OutputColumn, BlockNumberColumn, TransactionHashColumn, LogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return withdrawalTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + AccountIndex: AccountIndexColumn, + Account: AccountColumn, + Output: OutputColumn, + BlockNumber: BlockNumberColumn, + TransactionHash: TransactionHashColumn, + LogIndex: LogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/epoch.go b/internal/repository/postgres/epoch.go index 3b180c4ab..97a9dbaf5 100644 --- a/internal/repository/postgres/epoch.go +++ b/internal/repository/postgres/epoch.go @@ -242,6 +242,7 @@ func (r *PostgresRepository) GetEpoch( table.Epoch.ClaimTransactionHash, table.Epoch.TournamentAddress, table.Epoch.Status, + table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt, @@ -276,6 +277,7 @@ func (r *PostgresRepository) GetEpoch( &ep.ClaimTransactionHash, &ep.TournamentAddress, &ep.Status, + &ep.StagedAtBlock, &ep.VirtualIndex, &ep.CreatedAt, &ep.UpdatedAt, @@ -289,6 +291,95 @@ func (r *PostgresRepository) GetEpoch( return &ep, nil } +// HasUndrainedEpochsBeforeBlock returns true while any input belonging to +// appID has block_number < blockBound and is still status='NONE' (i.e. not +// yet advanced by the machine). PRT uses this to keep its post-foreclosure +// drain pending until all pre-foreclosure inputs have been advanced. +// +// The check is input-level rather than epoch-level for two reasons: +// +// 1. It naturally catches the "straddling open epoch" case: an epoch with +// first_block < blockBound but last_block >= blockBound still contains +// pre-foreclosure inputs that must be processed before drain can +// complete. A predicate on epoch.last_block < blockBound would skip +// such an epoch. +// 2. It correctly tolerates PRT's empty-epoch invariant — an empty open +// epoch straddling the foreclosure block has no inputs to wait on, so +// the gate returns false (whereas a predicate on +// epoch.first_block < blockBound would incorrectly stall PRT drain on +// the empty straddler). +// +// Authority/Quorum uses the broader [PostgresRepository.HasUnreconciledClaimsBeforeBlock] +// gate instead so it also waits for read-only claim reconciliation to +// complete. +func (r *PostgresRepository) HasUndrainedEpochsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + stmt := table.Input. + SELECT(table.Input.Index). + WHERE( + table.Input.EpochApplicationID.EQ(postgres.Int(appID)). + AND(table.Input.BlockNumber.LT(uint64Expr(blockBound))). + AND(table.Input.Status.EQ(enum.InputCompletionStatus.None)), + ). + LIMIT(1) + + sqlStr, args := stmt.Sql() + rows, err := r.db.Query(ctx, sqlStr, args...) + if err != nil { + return false, err + } + defer rows.Close() + return rows.Next(), rows.Err() +} + +// HasUnreconciledClaimsBeforeBlock returns true while any epoch for appID +// has first_block < blockBound AND status in OPEN/CLOSED/INPUTS_PROCESSED or +// CLAIM_COMPUTED/CLAIM_SUBMITTED/CLAIM_STAGED. The extra states ensure the +// Authority/Quorum claimer's foreclosure drain waits for the read-only +// CLAIM_COMPUTED → CLAIM_ACCEPTED reconciliation path to finish — otherwise +// a new-node bootstrap against an already-foreclosed app could drain to +// completion before mirroring pre-foreclosure on-chain-accepted claims into the +// local DB, leaving downstream tooling with a divergent final state. +// +// The predicate is `first_block < blockBound` (not `last_block < blockBound`) +// to catch straddling epochs: an epoch that started before the foreclosure +// block but extends past it is still pre-foreclosure work the claimer must +// drive to CLAIM_ACCEPTED. Authority/Quorum never creates empty epoch rows, +// so `first_block < blockBound` does not introduce false positives. +func (r *PostgresRepository) HasUnreconciledClaimsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + statuses := []postgres.Expression{ + enum.EpochStatus.Open, + enum.EpochStatus.Closed, + enum.EpochStatus.InputsProcessed, + enum.EpochStatus.ClaimComputed, + enum.EpochStatus.ClaimSubmitted, + enum.EpochStatus.ClaimStaged, + } + stmt := table.Epoch. + SELECT(table.Epoch.Index). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int(appID)). + AND(table.Epoch.FirstBlock.LT(uint64Expr(blockBound))). + AND(table.Epoch.Status.IN(statuses...)), + ). + LIMIT(1) + + sqlStr, args := stmt.Sql() + rows, err := r.db.Query(ctx, sqlStr, args...) + if err != nil { + return false, err + } + defer rows.Close() + return rows.Next(), rows.Err() +} + func (r *PostgresRepository) GetLastAcceptedEpochIndex( ctx context.Context, nameOrAddress string, @@ -352,6 +443,7 @@ func (r *PostgresRepository) GetLastNonOpenEpoch( table.Epoch.ClaimTransactionHash, table.Epoch.TournamentAddress, table.Epoch.Status, + table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt, @@ -388,6 +480,7 @@ func (r *PostgresRepository) GetLastNonOpenEpoch( &ep.ClaimTransactionHash, &ep.TournamentAddress, &ep.Status, + &ep.StagedAtBlock, &ep.VirtualIndex, &ep.CreatedAt, &ep.UpdatedAt, @@ -425,6 +518,7 @@ func (r *PostgresRepository) GetEpochByVirtualIndex( table.Epoch.ClaimTransactionHash, table.Epoch.TournamentAddress, table.Epoch.Status, + table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt, @@ -459,6 +553,7 @@ func (r *PostgresRepository) GetEpochByVirtualIndex( &ep.ClaimTransactionHash, &ep.TournamentAddress, &ep.Status, + &ep.StagedAtBlock, &ep.VirtualIndex, &ep.CreatedAt, &ep.UpdatedAt, @@ -693,6 +788,7 @@ func (r *PostgresRepository) ListEpochs( table.Epoch.ClaimTransactionHash, table.Epoch.TournamentAddress, table.Epoch.Status, + table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt, @@ -737,6 +833,7 @@ func (r *PostgresRepository) ListEpochs( &ep.ClaimTransactionHash, &ep.TournamentAddress, &ep.Status, + &ep.StagedAtBlock, &ep.VirtualIndex, &ep.CreatedAt, &ep.UpdatedAt, 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 94f548ce1..d015f36f2 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 @@ -31,6 +31,10 @@ DROP TABLE IF EXISTS "node_config"; DROP TRIGGER IF EXISTS "report_set_updated_at" ON "report"; DROP TABLE IF EXISTS "report"; +DROP TRIGGER IF EXISTS "withdrawal_set_updated_at" ON "withdrawal"; +DROP INDEX IF EXISTS "withdrawal_block_number_idx"; +DROP TABLE IF EXISTS "withdrawal"; + DROP TRIGGER IF EXISTS "output_set_updated_at" ON "output"; DROP INDEX IF EXISTS "output_raw_data_address_idx"; DROP INDEX IF EXISTS "output_raw_data_type_idx"; 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 3f2d86391..24409e6df 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 @@ -30,6 +30,7 @@ CREATE TYPE "EpochStatus" AS ENUM ( 'INPUTS_PROCESSED', 'CLAIM_COMPUTED', 'CLAIM_SUBMITTED', + 'CLAIM_STAGED', 'CLAIM_ACCEPTED', 'CLAIM_REJECTED'); @@ -80,6 +81,25 @@ CREATE TABLE "application" "template_hash" hash NOT NULL, "template_uri" VARCHAR(4096) NOT NULL, "epoch_length" uint64 NOT NULL, + -- claim_staging_period is a cache of the on-chain immutable returned by + -- IConsensus.getClaimStagingPeriod(). On-chain, submitClaim() always + -- both submits and stages in the same tx (Authority._submitClaim + + -- _stageClaim, atomic). acceptClaim() is a separate tx that requires + -- claim.status == STAGED AND block.number - stagingBlockNumber >= + -- claim_staging_period. With DEFAULT 0, acceptClaim can fire as early + -- as the block immediately after staging; with N > 0, accept must wait + -- N blocks past the staging block. A cached value lower than the chain + -- value causes ClaimStagingPeriodNotOverYet reverts that the claimer + -- reclassifies as retry-later (see handleAcceptClaimRevert) — one + -- wasted broadcast per tick until reality catches up; bounded. + -- The chain is the source of truth; this column is populated once at + -- register time from getClaimStagingPeriod(). + "claim_staging_period" uint64 NOT NULL DEFAULT 0, + "withdrawal_guardian" ethereum_address NOT NULL DEFAULT '\x0000000000000000000000000000000000000000', + "withdrawal_log2_leaves_per_account" SMALLINT NOT NULL DEFAULT 0 CHECK ("withdrawal_log2_leaves_per_account" BETWEEN 0 AND 255), + "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, "state" "ApplicationState" NOT NULL, @@ -88,14 +108,50 @@ CREATE TABLE "application" "last_input_check_block" uint64 NOT NULL, "last_output_check_block" uint64 NOT NULL, "last_tournament_check_block" uint64 NOT NULL, + "last_foreclose_check_block" uint64 NOT NULL DEFAULT 0, + "last_accounts_drive_proved_check_block" uint64 NOT NULL DEFAULT 0, + "last_withdrawal_check_block" uint64 NOT NULL DEFAULT 0, "processed_inputs" uint64 NOT NULL, + -- foreclose_block / accounts_drive_proved_block use 0 as the "not yet + -- observed" sentinel — block 0 is structurally unreachable for the + -- corresponding events. The companion hash columns are nullable: a Hash + -- has no natural unreachable value, so NULL is the canonical "not set" + -- indicator rather than a manufactured zero-hash literal. + "foreclose_block" uint64 NOT NULL DEFAULT 0, + "foreclose_transaction" hash, + "accounts_drive_proved_block" uint64 NOT NULL DEFAULT 0, + "accounts_drive_proved_transaction" hash, + "accounts_drive_merkle_root" hash, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "reason_required_for_failure_states" CHECK (NOT ("state" IN ('FAILED', 'INOPERABLE') AND ("reason" IS NULL OR LENGTH("reason") = 0))), + -- The foreclose pair is populated together by the atomic foreclosure + -- marker+cursor repository write (set-once, first-writer-wins via WHERE + -- foreclose_block = 0). This CHECK enforces the same invariant at the + -- schema level: either both unset (block = 0, tx IS NULL) or both set + -- together. A future code path that wrote one without the other is + -- rejected at the DB boundary. + CONSTRAINT "foreclose_block_and_tx_set_together" + CHECK (("foreclose_block" = 0) = ("foreclose_transaction" IS NULL)), + -- Same invariant for the drive-proved pair (set together by the atomic + -- drive-proved marker+cursor repository write); the merkle root is + -- recorded only when a proved-block is observed, so all three + -- drive-proved columns are co-set. + CONSTRAINT "accounts_drive_proved_columns_set_together" + CHECK (("accounts_drive_proved_block" = 0) + = ("accounts_drive_proved_transaction" IS NULL) + AND ("accounts_drive_proved_block" = 0) + = ("accounts_drive_merkle_root" IS NULL)), 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 +-- the scan an index-only seek even as the application table grows. +CREATE INDEX "application_foreclosed_idx" ON "application"("id") + WHERE "foreclose_block" > 0; CREATE TRIGGER "application_set_updated_at" BEFORE UPDATE ON "application" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); @@ -166,6 +222,7 @@ CREATE TABLE "epoch" "tournament_address" ethereum_address, "claim_transaction_hash" hash, "status" "EpochStatus" NOT NULL, + "staged_at_block" uint64, "virtual_index" uint64 NOT NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -173,11 +230,26 @@ CREATE TABLE "epoch" CONSTRAINT "epoch_application_id_virtual_index_unique" UNIQUE ("application_id", "virtual_index"), CONSTRAINT "epoch_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "application"("id") ON DELETE CASCADE, CONSTRAINT "epoch_block_bounds_check" CHECK ("first_block" <= "last_block"), - CONSTRAINT "epoch_input_bounds_check" CHECK ("input_index_lower_bound" <= "input_index_upper_bound") + CONSTRAINT "epoch_input_bounds_check" CHECK ("input_index_lower_bound" <= "input_index_upper_bound"), + -- staged_at_block is set when an epoch is staged on chain and is then + -- kept historically — same lifetime convention as claim_transaction_hash. + -- We only enforce the forward direction: if you're in CLAIM_STAGED you + -- must have a staging block. After transitioning out to CLAIM_ACCEPTED, + -- the column is retained as audit info. + CONSTRAINT "epoch_staged_requires_block" CHECK ("status" <> 'CLAIM_STAGED' OR "staged_at_block" IS NOT NULL) ); CREATE INDEX "epoch_last_block_idx" ON "epoch"("application_id", "last_block"); CREATE INDEX "epoch_status_idx" ON "epoch"("application_id", "status"); +-- Supports HasUnreconciledClaimsBeforeBlock: the broad Authority/Quorum drain +-- gate scans epoch rows for (application_id, first_block < $foreclose_block) +-- restricted to the set of pre-accept statuses. A partial index keyed on +-- (application_id, first_block) with the same status predicate keeps the +-- scan an index-only lookup; the bare epoch_status_idx covers the status +-- filter but adds a per-row comparison on first_block. +CREATE INDEX "epoch_unreconciled_idx" ON "epoch"("application_id", "first_block") + WHERE "status" IN ('OPEN','CLOSED','INPUTS_PROCESSED', + 'CLAIM_COMPUTED','CLAIM_SUBMITTED','CLAIM_STAGED'); CREATE TRIGGER "epoch_set_updated_at" BEFORE UPDATE ON "epoch" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); @@ -185,15 +257,12 @@ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Enforce valid epoch status transitions. -- The state machine is: -- OPEN → CLOSED → INPUTS_PROCESSED → CLAIM_COMPUTED --- CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_ACCEPTED --- CLAIM_COMPUTED → CLAIM_ACCEPTED (PRT skips SUBMITTED; also valid when --- syncing from scratch and the claim was --- already accepted, or in reader-only mode --- with tx submission disabled) --- CLAIM_COMPUTED → CLAIM_REJECTED (claim rejected on-chain before the node --- submits, e.g. a conflicting claim was --- already accepted) --- CLAIM_SUBMITTED → CLAIM_REJECTED +-- 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_REJECTED (conflicting Quorum claim staged/accepted before we submitted) +-- CLAIM_SUBMITTED → CLAIM_REJECTED (we submitted, then a different Quorum claim was staged/accepted) +-- CLAIM_STAGED → CLAIM_ACCEPTED (normal acceptance path) -- Any other transition (including backwards) is rejected. -- Same-status updates are allowed (idempotent no-ops). -- @@ -201,6 +270,10 @@ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- required proof fields are populated: -- All apps: machine_hash, outputs_merkle_root, outputs_merkle_proof -- PRT (DaveConsensus): additionally commitment, 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. CREATE FUNCTION enforce_epoch_status_transition() RETURNS trigger AS $$ DECLARE valid_transitions text[][] := ARRAY[ @@ -208,10 +281,13 @@ DECLARE ARRAY['CLOSED', 'INPUTS_PROCESSED'], ARRAY['INPUTS_PROCESSED', 'CLAIM_COMPUTED'], ARRAY['CLAIM_COMPUTED', 'CLAIM_SUBMITTED'], + ARRAY['CLAIM_COMPUTED', 'CLAIM_STAGED'], ARRAY['CLAIM_COMPUTED', 'CLAIM_ACCEPTED'], ARRAY['CLAIM_COMPUTED', 'CLAIM_REJECTED'], + ARRAY['CLAIM_SUBMITTED', 'CLAIM_STAGED'], ARRAY['CLAIM_SUBMITTED', 'CLAIM_ACCEPTED'], - ARRAY['CLAIM_SUBMITTED', 'CLAIM_REJECTED'] + ARRAY['CLAIM_SUBMITTED', 'CLAIM_REJECTED'], + ARRAY['CLAIM_STAGED', 'CLAIM_ACCEPTED'] ]; is_valid boolean := false; app_consensus text; @@ -255,6 +331,27 @@ 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. + 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; END; $$ LANGUAGE plpgsql; @@ -326,6 +423,26 @@ WHERE SUBSTRING("raw_data" FROM 1 FOR 4) IN ( CREATE TRIGGER "output_set_updated_at" BEFORE UPDATE ON "output" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TABLE "withdrawal" +( + "application_id" INT NOT NULL, + "account_index" uint64 NOT NULL, + "account" BYTEA NOT NULL, + "output" BYTEA NOT NULL, + "block_number" uint64 NOT NULL, + "transaction_hash" hash NOT NULL, + "log_index" INT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "withdrawal_pkey" PRIMARY KEY ("application_id", "account_index"), + CONSTRAINT "withdrawal_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "application"("id") ON DELETE CASCADE +); + +CREATE INDEX "withdrawal_block_number_idx" ON "withdrawal" ("application_id", "block_number"); + +CREATE TRIGGER "withdrawal_set_updated_at" BEFORE UPDATE ON "withdrawal" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + CREATE TABLE "report" ( "input_epoch_application_id" int4 NOT NULL, @@ -516,4 +633,3 @@ CREATE TRIGGER "state_hashes_set_updated_at" BEFORE UPDATE ON "state_hashes" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMIT; - diff --git a/internal/repository/postgres/withdrawal.go b/internal/repository/postgres/withdrawal.go new file mode 100644 index 000000000..f4dcd7ea7 --- /dev/null +++ b/internal/repository/postgres/withdrawal.go @@ -0,0 +1,287 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5/pgconn" + + "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" +) + +type withdrawalExecutor interface { + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +// InsertWithdrawal records a Withdrawal event observed on chain. Idempotent on +// the (application_id, account_index) primary key via ON CONFLICT DO NOTHING, +// so re-processing the same block on restart cannot fail and cannot diverge +// from the first write. The contract marks each account index as withdrawn, +// so the event fires at most once per slot per app — duplicate inserts are +// restart artifacts. +func (r *PostgresRepository) InsertWithdrawal( + ctx context.Context, + w *model.Withdrawal, +) error { + return insertWithdrawal(ctx, r.db, w) +} + +func insertWithdrawal(ctx context.Context, exec withdrawalExecutor, w *model.Withdrawal) error { + insertStmt := table.Withdrawal. + INSERT( + table.Withdrawal.ApplicationID, + table.Withdrawal.AccountIndex, + table.Withdrawal.Account, + table.Withdrawal.Output, + table.Withdrawal.BlockNumber, + table.Withdrawal.TransactionHash, + table.Withdrawal.LogIndex, + ). + VALUES( + w.ApplicationID, + w.AccountIndex, + w.Account, + w.Output, + w.BlockNumber, + w.TransactionHash.Bytes(), + int64(w.LogIndex), + ). + ON_CONFLICT(table.Withdrawal.ApplicationID, table.Withdrawal.AccountIndex). + DO_NOTHING() + + sqlStr, args := insertStmt.Sql() + _, err := exec.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("insert withdrawal (app=%d, index=%d): %w", w.ApplicationID, w.AccountIndex, err) + } + return nil +} + +// StoreWithdrawalEvents persists a completed withdrawal scan window atomically. +// Rows and cursor advancement are +// committed together so the DB withdrawal count remains the scanner's local +// previous counter for the next window. +func (r *PostgresRepository) StoreWithdrawalEvents( + ctx context.Context, + appID int64, + withdrawals []*model.Withdrawal, + blockNumber uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + for _, w := range withdrawals { + if w.ApplicationID != appID { + return fmt.Errorf("insert withdrawal (app=%d, index=%d): application id mismatch %d", + w.ApplicationID, w.AccountIndex, appID) + } + if err := insertWithdrawal(ctx, tx, w); err != nil { + return err + } + } + + updateStmt := table.Application. + UPDATE(table.Application.LastWithdrawalCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastWithdrawalCheckBlock.LT(uint64Expr(blockNumber))), + ) + + sqlStr, args := updateStmt.Sql() + if _, err := tx.Exec(ctx, sqlStr, args...); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *PostgresRepository) GetNumberOfWithdrawals( + ctx context.Context, + appID int64, +) (uint64, error) { + sel := table.Withdrawal. + SELECT(postgres.COUNT(postgres.STAR)). + FROM(table.Withdrawal). + WHERE(table.Withdrawal.ApplicationID.EQ(postgres.Int(appID))) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var count uint64 + err := row.Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +func (r *PostgresRepository) GetWithdrawal( + ctx context.Context, + nameOrAddress string, + accountIndex uint64, +) (*model.Withdrawal, error) { + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Withdrawal. + SELECT( + table.Withdrawal.ApplicationID, + table.Withdrawal.AccountIndex, + table.Withdrawal.Account, + table.Withdrawal.Output, + table.Withdrawal.BlockNumber, + table.Withdrawal.TransactionHash, + table.Withdrawal.LogIndex, + table.Withdrawal.CreatedAt, + table.Withdrawal.UpdatedAt, + ). + FROM( + table.Withdrawal.INNER_JOIN( + table.Application, + table.Withdrawal.ApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Withdrawal.AccountIndex.EQ(uint64Expr(accountIndex))), + ) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var w model.Withdrawal + var txHashBytes []byte + var logIndex int64 + err := row.Scan( + &w.ApplicationID, + &w.AccountIndex, + &w.Account, + &w.Output, + &w.BlockNumber, + &txHashBytes, + &logIndex, + &w.CreatedAt, + &w.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + copy(w.TransactionHash[:], txHashBytes) + w.LogIndex = uint(logIndex) + return &w, nil +} + +func (r *PostgresRepository) ListWithdrawals( + ctx context.Context, + nameOrAddress string, + f repository.WithdrawalFilter, + p repository.Pagination, + descending bool, +) ([]*model.Withdrawal, uint64, error) { + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + fromClause := table.Withdrawal.INNER_JOIN( + table.Application, + table.Withdrawal.ApplicationID.EQ(table.Application.ID), + ) + + conditions := []postgres.BoolExpression{whereClause} + if f.AccountIndex != nil { + conditions = append(conditions, table.Withdrawal.AccountIndex.EQ(uint64Expr(*f.AccountIndex))) + } + + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + countStmt := table.Withdrawal.SELECT(postgres.COUNT(postgres.STAR)). + FROM(fromClause).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil { + return nil, 0, err + } + if total == 0 { + return nil, 0, nil + } + + sel := table.Withdrawal. + SELECT( + table.Withdrawal.ApplicationID, + table.Withdrawal.AccountIndex, + table.Withdrawal.Account, + table.Withdrawal.Output, + table.Withdrawal.BlockNumber, + table.Withdrawal.TransactionHash, + table.Withdrawal.LogIndex, + table.Withdrawal.CreatedAt, + table.Withdrawal.UpdatedAt, + ). + FROM(fromClause). + WHERE(postgres.AND(conditions...)) + + if descending { + sel = sel.ORDER_BY(table.Withdrawal.AccountIndex.DESC()) + } else { + sel = sel.ORDER_BY(table.Withdrawal.AccountIndex.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 withdrawals []*model.Withdrawal + for rows.Next() { + var w model.Withdrawal + var txHashBytes []byte + var logIndex int64 + err := rows.Scan( + &w.ApplicationID, + &w.AccountIndex, + &w.Account, + &w.Output, + &w.BlockNumber, + &txHashBytes, + &logIndex, + &w.CreatedAt, + &w.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + copy(w.TransactionHash[:], txHashBytes) + w.LogIndex = uint(logIndex) + withdrawals = append(withdrawals, &w) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + if err := tx.Commit(ctx); err != nil { + return nil, 0, err + } + return withdrawals, total, nil +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index e479f8818..db91c3b2f 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -28,6 +28,11 @@ type ApplicationFilter struct { State *ApplicationState DataAvailability *DataAvailabilitySelector ConsensusType *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 + // without a recorded foreclosure. + ForeclosureRecorded *bool } type EpochFilter struct { @@ -81,12 +86,50 @@ type MatchFilter struct { TournamentAddress *string } +type WithdrawalFilter struct { + AccountIndex *uint64 +} + type ApplicationRepository interface { CreateApplication(ctx context.Context, app *Application, withExecutionParameters bool) (int64, error) GetApplication(ctx context.Context, nameOrAddress string) (*Application, error) GetProcessedInputCount(ctx context.Context, nameOrAddress string) (uint64, error) UpdateApplication(ctx context.Context, app *Application) error UpdateApplicationState(ctx context.Context, appID int64, state ApplicationState, reason *string) error + // UpdateApplicationForeclosure records the one-shot Foreclosure() event + // and advances the foreclosure scan cursor in + // one transaction. Used when the evmreader found the event in the scanned + // window; after this marker is recorded the scanner stops checking the app. + UpdateApplicationForeclosure( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + blockNumber uint64, + ) error + // UpdateApplicationLastForecloseCheckBlock advances the highest block + // the Foreclosure-event log search has scanned. The write is strictly + // monotonic: a lower or equal blockNumber is a no-op, so out-of-order + // ticks cannot rewind the value and re-cause a long [deployment, head] + // rescan. + UpdateApplicationLastForecloseCheckBlock(ctx context.Context, appID int64, blockNumber uint64) error + // UpdateAccountsDriveProved records the one-shot + // AccountsDriveMerkleRootProved event and advances the scan cursor + // in one transaction. Used when the evmreader found the event in the + // scanned window; after this marker is recorded the scanner stops checking + // the app and moves on to withdrawals. + UpdateAccountsDriveProved( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + root common.Hash, + blockNumber uint64, + ) error + // UpdateApplicationLastAccountsDriveProvedCheckBlock advances the highest + // block the accounts-drive-proved scan has examined. + // Strictly monotonic — out-of-order or duplicate ticks are silent no-ops. + UpdateApplicationLastAccountsDriveProvedCheckBlock(ctx context.Context, appID int64, blockNumber uint64) error DeleteApplication(ctx context.Context, id int64) error ListApplications(ctx context.Context, f ApplicationFilter, p Pagination, descending bool) ([]*Application, uint64, error) @@ -115,6 +158,29 @@ type EpochRepository interface { RepeatPreviousEpochOutputsProof(ctx context.Context, appID int64, epochIndex uint64) error ListEpochs(ctx context.Context, nameOrAddress string, f EpochFilter, p Pagination, descending bool) ([]*Epoch, uint64, error) + + // HasUndrainedEpochsBeforeBlock reports whether any input for the given + // application has block_number < blockBound and is still unprocessed. + // PRT uses this gate to keep the post-foreclosure drain pending until + // the advancer has processed every pre-foreclosure input — it does NOT + // wait for CLAIM_ACCEPTED because PRT tournaments cannot settle on a + // foreclosed IApplication, so waiting would stall forever. + HasUndrainedEpochsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + + // HasUnreconciledClaimsBeforeBlock is the broader gate used by the + // Authority/Quorum claimer: it returns true while any epoch for the + // given application is still in OPEN/CLOSED/INPUTS_PROCESSED OR in + // CLAIM_COMPUTED/CLAIM_SUBMITTED/CLAIM_STAGED with LastBlock < + // blockBound. The extra states cover the new-node-bootstrap path + // (a fresh DB entry for an already-foreclosed contract): each + // pre-foreclosure on-chain-accepted claim must be mirrored to + // CLAIM_ACCEPTED locally before the app is considered drained, + // otherwise downstream tooling (proof generation, withdrawal CLI) sees + // a final state that diverges from chain reality. Unlike PRT, + // Authority/Quorum reconciliation is purely read-only (no on-chain + // dependency that the foreclosure blocks), so waiting is safe and + // terminating. + HasUnreconciledClaimsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) } type InputRepository interface { @@ -140,6 +206,47 @@ type ReportRepository interface { ListReports(ctx context.Context, nameOrAddress string, f ReportFilter, p Pagination, descending bool) ([]*Report, uint64, error) } +type WithdrawalRepository interface { + // InsertWithdrawal records a Withdrawal(uint64 accountIndex, bytes account, + // bytes output) event observed on chain. Idempotent on the (application_id, + // account_index) primary key via ON CONFLICT DO NOTHING: re-processing the + // same block on restart cannot fail and cannot diverge from the first write. + // The contract marks each account index as withdrawn (see + // IApplication.wereAccountFundsWithdrawn), so the event fires at most once + // per slot per app — second observations are always restart artifacts. + InsertWithdrawal(ctx context.Context, w *Withdrawal) error + + // StoreWithdrawalEvents records Withdrawal events from a completed scanner + // window and advances the + // application's last_withdrawal_check_block in the same database + // transaction. This keeps the local withdrawal count and scanner cursor in + // sync: either both reflect the scanned window, or neither does. + StoreWithdrawalEvents( + ctx context.Context, + appID int64, + withdrawals []*Withdrawal, + blockNumber uint64, + ) error + + // GetNumberOfWithdrawals returns the number of Withdrawal rows stored for + // an application. The post-foreclosure scanner uses it as the local + // previous counter when resuming after last_withdrawal_check_block. + GetNumberOfWithdrawals(ctx context.Context, appID int64) (uint64, error) + + // GetWithdrawal returns a single withdrawal by (application, accountIndex). + // Returns (nil, nil) when the row does not exist — mirrors GetOutput and + // the project convention for Get* lookups; the JSON-RPC layer turns the + // nil into a resource-not-found error code. Application is identified by + // name or address. + GetWithdrawal(ctx context.Context, nameOrAddress string, accountIndex uint64) (*Withdrawal, error) + + // ListWithdrawals returns a paginated list for an application, optionally + // filtered by account_index. nameOrAddress + pagination/ordering shape + // mirror ListOutputs: default ascending by account_index, descending=true + // reverses it. + ListWithdrawals(ctx context.Context, nameOrAddress string, f WithdrawalFilter, p Pagination, descending bool) ([]*Withdrawal, uint64, error) +} + type StateHashRepository interface { ListStateHashes(ctx context.Context, nameOrAddress string, f StateHashFilter, p Pagination, descending bool) ([]*StateHash, uint64, error) } @@ -197,16 +304,58 @@ type ClaimerRepository interface { map[int64]*Application, error, ) + SelectStagedClaimPairsPerApp(ctx context.Context) ( + map[int64]*Epoch, + map[int64]*Epoch, + map[int64]*Application, + error, + ) UpdateEpochWithSubmittedClaim( ctx context.Context, applicationID int64, index uint64, transactionHash common.Hash, ) error + UpdateEpochToStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, + ) error + UpdateEpochThroughStaging( + ctx context.Context, + applicationID int64, + index uint64, + transactionHash common.Hash, + stagedAtBlock uint64, + ) error + UpdateEpochReconciledStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, + ) error + // UpdateEpochWithAcceptedClaim transitions an epoch to CLAIM_ACCEPTED. + // txHash is optional: pass non-nil to record claim_transaction_hash + // (catch-up reconciliations where the epoch never went through the + // CLAIM_SUBMITTED transition); pass nil to leave the column untouched + // (the normal-flow case where the column was populated during + // CLAIM_SUBMITTED). UpdateEpochWithAcceptedClaim( ctx context.Context, applicationID int64, index uint64, + txHash *common.Hash, + ) error + // RejectEpochAndSetApplicationInoperable atomically marks an epoch as + // CLAIM_REJECTED and the application as INOPERABLE. Used when Quorum + // consensus stages or accepts a different claim before the local claim has + // staged, making the local claim unreachable. + RejectEpochAndSetApplicationInoperable( + ctx context.Context, + applicationID int64, + index uint64, + reason string, ) error } @@ -224,6 +373,7 @@ type Repository interface { BulkOperationsRepository NodeConfigRepository ClaimerRepository + WithdrawalRepository Close() } diff --git a/internal/repository/repotest/application_test_cases.go b/internal/repository/repotest/application_test_cases.go index e8e39dbc8..a3dcd1e49 100644 --- a/internal/repository/repotest/application_test_cases.go +++ b/internal/repository/repotest/application_test_cases.go @@ -72,6 +72,8 @@ func (s *ApplicationSuite) TestGetApplication() { s.Equal(app.IInputBoxAddress, got.IInputBoxAddress) s.Equal(app.TemplateHash, got.TemplateHash) s.Equal(app.EpochLength, got.EpochLength) + s.Equal(app.ClaimStagingPeriod, got.ClaimStagingPeriod) + s.Equal(app.WithdrawalConfig, got.WithdrawalConfig) s.Equal(app.ConsensusType, got.ConsensusType) s.Equal(app.State, got.State) s.Equal(app.DataAvailability, got.DataAvailability) @@ -249,6 +251,36 @@ func (s *ApplicationSuite) TestListApplications() { s.Equal(Consensus_Authority, apps[0].ConsensusType) }) + // FilterByForeclosureRecorded pins the SQL behind the + // listEnabledForeclosedNonPRTApps query: ForecloseBlock > 0 selects only + // apps the evmreader has observed as foreclosed. An IS_NULL/IS_NOT_NULL + // swap or a GT/EQ swap in the SQL would silently disable the drain-from- + // idle path; the assertions here catch both directions. + s.Run("FilterByForeclosureRecorded", func() { + foreclosed := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure( + s.Ctx, foreclosed.ID, 1234, UniqueHash(), 1234)) + _ = NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) // not foreclosed + + yes := true + got, total, err := s.Repo.ListApplications(s.Ctx, + repository.ApplicationFilter{ForeclosureRecorded: &yes}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(got, 1) + s.Equal(uint64(1), total) + s.Equal(foreclosed.ID, got[0].ID) + + no := false + got, total, err = s.Repo.ListApplications(s.Ctx, + repository.ApplicationFilter{ForeclosureRecorded: &no}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(got, 1) + s.Equal(uint64(1), total) + s.NotEqual(foreclosed.ID, got[0].ID) + }) + s.Run("CombinedStateAndDataAvailability", func() { NewApplicationBuilder(). WithState(ApplicationState_Enabled). @@ -718,6 +750,264 @@ func (s *ApplicationSuite) TestUpdateApplication() { s.Require().NoError(err) s.Equal(uint64(20), got.EpochLength) }) + + // UpdateApplication must not touch foreclose_block / foreclose_transaction. + // Those columns are owned by the atomic foreclosure marker+cursor write. If + // UpdateApplication's column list ever re-includes them, a caller with a + // stale in-memory `app.ForecloseBlock == 0` would silently clear the marker + // on a foreclosed application — re-arming the drain protocol or stranding + // the app in ENABLED with on-chain reverts. + s.Run("DoesNotClobberForecloseColumns", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + block := uint64(12345) + txHash := crypto.Keccak256Hash([]byte("foreclose-tx")) + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, txHash, block) + s.Require().NoError(err) + + // Mutate an unrelated field on an in-memory copy whose + // ForecloseBlock / ForecloseTransaction are zero (simulating a caller + // that reads, modifies, and writes back without first refreshing the + // foreclosure state). UpdateApplication must leave the persisted + // foreclose columns alone. + app.EpochLength = 77 + s.Require().Zero(app.ForecloseBlock) + s.Require().Nil(app.ForecloseTransaction) + 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(77), got.EpochLength) + s.Require().NotZero(got.ForecloseBlock, "foreclose_block must not be cleared by UpdateApplication") + s.Equal(block, got.ForecloseBlock) + s.Require().NotNil(got.ForecloseTransaction) + s.Equal(txHash, *got.ForecloseTransaction) + }) +} + +func (s *ApplicationSuite) TestUpdateApplicationForeclosure() { + s.Run("WritesMarkerAndCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + block := uint64(1234) + head := uint64(1500) + txHash := UniqueHash() + + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, txHash, head) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(block, got.ForecloseBlock) + s.Require().NotNil(got.ForecloseTransaction) + s.Equal(txHash, *got.ForecloseTransaction) + s.Equal(head, got.LastForecloseCheckBlock) + }) + + s.Run("DoesNotRegressCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 500)) + + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 300, UniqueHash(), 400) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(500), got.LastForecloseCheckBlock) + s.Equal(uint64(300), got.ForecloseBlock) + }) + + s.Run("IdempotentWhenAlreadyForeclosed", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + firstBlock := uint64(1234) + firstHead := uint64(1500) + firstTx := UniqueHash() + s.Require().NoError( + s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, firstBlock, firstTx, firstHead)) + + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 9999, UniqueHash(), 2000) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(firstBlock, got.ForecloseBlock) + s.Require().NotNil(got.ForecloseTransaction) + s.Equal(firstTx, *got.ForecloseTransaction) + s.Equal(firstHead, got.LastForecloseCheckBlock) + }) + + s.Run("ReturnsNotFoundWhenRowMissing", func() { + err := s.Repo.UpdateApplicationForeclosure( + s.Ctx, int64(99_999_999), 1, UniqueHash(), 2) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +// TestUpdateApplicationLastForecloseCheckBlock pins the strictly monotonic +// semantics of the write. Out-of-order or duplicate observations from a +// slow tick must not rewind last_foreclose_check_block and re-cause a +// full [deployment, head] rescan on the next tick. +func (s *ApplicationSuite) TestUpdateApplicationLastForecloseCheckBlock() { + s.Run("AdvancesFromZero", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 1234) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1234), got.LastForecloseCheckBlock) + }) + + s.Run("AdvancesForward", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 100)) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 200)) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(200), got.LastForecloseCheckBlock) + }) + + // Out-of-order ticks: a stale call carrying a lower block number must + // be a silent no-op, not an error and not a regression of the stored + // value. The repo returns nil (matches LastInputCheckBlock-style + // conventions); the caller cannot distinguish "I was stale" from + // "I was current". That is intentional — the next tick's read will + // surface the true value. + s.Run("RejectsRegressionSilently", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 500)) + + err := s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 100) + s.Require().NoError(err, "regression attempts return nil; the WHERE guard makes it a no-op") + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(500), got.LastForecloseCheckBlock, + "last_foreclose_check_block must not regress below its previous value") + }) + + // Equal-value writes are also no-ops, mirroring the strict-less-than + // guard. Useful when two ticks happen to land on the same head block. + s.Run("RejectsEqualSilently", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 777)) + + err := s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 777) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(777), got.LastForecloseCheckBlock) + }) +} + +// TestUpdateApplicationLastAccountsDriveProvedCheckBlock mirrors the +// LastForecloseCheckBlock contract: strictly monotonic, regression and +// equal-value writes are silent no-ops. Out-of-order ticks must not rewind +// the cursor and re-cause a full [foreclose_block, head] rescan. +func (s *ApplicationSuite) TestUpdateApplicationLastAccountsDriveProvedCheckBlock() { + s.Run("AdvancesFromZero", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 1234)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1234), got.LastAccountsDriveProvedCheckBlock) + }) + + s.Run("AdvancesForward", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 100)) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 200)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(200), got.LastAccountsDriveProvedCheckBlock) + }) + + s.Run("RejectsRegressionSilently", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 500)) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 100)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(500), got.LastAccountsDriveProvedCheckBlock, + "last_accounts_drive_proved_check_block must not regress below its previous value") + }) + + s.Run("RejectsEqualSilently", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 777)) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 777)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(777), got.LastAccountsDriveProvedCheckBlock) + }) +} + +func (s *ApplicationSuite) TestUpdateAccountsDriveProved() { + s.Run("WritesMarkerAndCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + block := uint64(4242) + head := uint64(4300) + txHash := UniqueHash() + root := UniqueHash() + + err := s.Repo.UpdateAccountsDriveProved(s.Ctx, app.ID, block, txHash, root, head) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(block, got.AccountsDriveProvedBlock) + s.Require().NotNil(got.AccountsDriveProvedTransaction) + s.Equal(txHash, *got.AccountsDriveProvedTransaction) + s.Require().NotNil(got.AccountsDriveMerkleRoot) + s.Equal(root, *got.AccountsDriveMerkleRoot) + s.Equal(head, got.LastAccountsDriveProvedCheckBlock) + }) + + s.Run("DoesNotRegressCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 500)) + + err := s.Repo.UpdateAccountsDriveProved( + s.Ctx, app.ID, 300, UniqueHash(), UniqueHash(), 400) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(500), got.LastAccountsDriveProvedCheckBlock) + s.Equal(uint64(300), got.AccountsDriveProvedBlock) + }) + + s.Run("IdempotentWhenAlreadyProved", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + firstBlock := uint64(4242) + firstHead := uint64(4300) + firstTx := UniqueHash() + firstRoot := UniqueHash() + s.Require().NoError(s.Repo.UpdateAccountsDriveProved( + s.Ctx, app.ID, firstBlock, firstTx, firstRoot, firstHead)) + + err := s.Repo.UpdateAccountsDriveProved( + s.Ctx, app.ID, 9999, UniqueHash(), UniqueHash(), 5000) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(firstBlock, got.AccountsDriveProvedBlock) + s.Require().NotNil(got.AccountsDriveProvedTransaction) + s.Equal(firstTx, *got.AccountsDriveProvedTransaction) + s.Require().NotNil(got.AccountsDriveMerkleRoot) + s.Equal(firstRoot, *got.AccountsDriveMerkleRoot) + s.Equal(firstHead, got.LastAccountsDriveProvedCheckBlock) + }) + + s.Run("ReturnsNotFoundWhenRowMissing", func() { + err := s.Repo.UpdateAccountsDriveProved( + s.Ctx, int64(99_999_999), 1, UniqueHash(), UniqueHash(), 2) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) } func (s *ApplicationSuite) TestGetLastSnapshot() { diff --git a/internal/repository/repotest/builders.go b/internal/repository/repotest/builders.go index 449bf7314..d0050d74f 100644 --- a/internal/repository/repotest/builders.go +++ b/internal/repository/repotest/builders.go @@ -16,6 +16,19 @@ import ( "github.com/stretchr/testify/require" ) +// defaultWithdrawalConfig returns a deterministic, non-zero WithdrawalConfig +// so tests detect missing-column bugs as "wrong values" rather than silently +// passing on all-zero defaults. +func defaultWithdrawalConfig() WithdrawalConfig { + return WithdrawalConfig{ + Guardian: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 20, + AccountsDriveStartIndex: 33554432, + WithdrawalOutputBuilder: common.HexToAddress("0x2222222222222222222222222222222222222222"), + } +} + // counter provides unique values across all builders to avoid collisions. var counter atomic.Uint64 @@ -53,6 +66,8 @@ func NewApplicationBuilder() *ApplicationBuilder { TemplateHash: UniqueHash(), TemplateURI: fmt.Sprintf("/template/%d", id), EpochLength: 10, + ClaimStagingPeriod: 7, + WithdrawalConfig: defaultWithdrawalConfig(), DataAvailability: DataAvailability_InputBox[:], ConsensusType: Consensus_Authority, State: ApplicationState_Enabled, @@ -85,11 +100,21 @@ func (b *ApplicationBuilder) WithEpochLength(l uint64) *ApplicationBuilder { return b } +func (b *ApplicationBuilder) WithClaimStagingPeriod(p uint64) *ApplicationBuilder { + b.app.ClaimStagingPeriod = p + 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 +} + func (b *ApplicationBuilder) WithExecutionParameters(ep ExecutionParameters) *ApplicationBuilder { b.app.ExecutionParameters = ep b.withExecutionParameters = true @@ -174,6 +199,16 @@ func (b *EpochBuilder) WithMachineHash(h common.Hash) *EpochBuilder { return b } +// WithStagedAtBlock sets the block number at which the chain staged our +// claim. Required when Status is EpochStatus_ClaimStaged (enforced by the +// staged_requires_block CHECK constraint at the DB). May also be set on +// ACCEPTED/REJECTED epochs to retain the staging block historically; the +// relaxed CHECK does not require clearing on transitions out of STAGED. +func (b *EpochBuilder) WithStagedAtBlock(block uint64) *EpochBuilder { + b.epoch.StagedAtBlock = &block + return b +} + // Build returns a copy of the Epoch model without persisting it. func (b *EpochBuilder) Build() *Epoch { e := *b.epoch diff --git a/internal/repository/repotest/claimer_test_cases.go b/internal/repository/repotest/claimer_test_cases.go index cb108a639..4d64d016c 100644 --- a/internal/repository/repotest/claimer_test_cases.go +++ b/internal/repository/repotest/claimer_test_cases.go @@ -78,8 +78,8 @@ func (s *ClaimerSuite) TestSelectSubmittedClaimPairsPerApp() { s.Run("IncludesAcceptedOrSubmittedForMultipleApps", func() { // Create two apps, each with a submitted epoch. - // SelectSubmittedClaimPairsPerApp returns acceptedOrSubmitted - // via selectNewestAcceptedClaimPerApp(includeSubmitted=true). + // SelectSubmittedClaimPairsPerApp returns the submit barriers: + // accepted, submitted, and staged predecessors. app1 := s.createAppWithClaimComputedEpoch() app2 := s.createAppWithClaimComputedEpoch() @@ -99,6 +99,49 @@ func (s *ClaimerSuite) TestSelectSubmittedClaimPairsPerApp() { s.Contains(acceptedOrSubmitted, app2.ID) }) + s.Run("IncludesStagedPredecessorAsSubmitBarrier", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(10, 19).WithInputBounds(1, 1).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}}, 20) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch0, EpochStatus_ClaimComputed) + + txHash := UniqueHash() + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + + err = s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, 0, 30) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch1, EpochStatus_ClaimComputed) + + barriers, computed, apps, err := s.Repo.SelectSubmittedClaimPairsPerApp(s.Ctx) + s.Require().NoError(err) + + s.Require().Contains(barriers, app.ID) + s.Equal(uint64(0), barriers[app.ID].Index) + s.Equal(EpochStatus_ClaimStaged, barriers[app.ID].Status) + + s.Require().Contains(computed, app.ID) + s.Equal(uint64(1), computed[app.ID].Index) + s.Contains(apps, app.ID) + }) + // Regression guard: verify map keys are actual application IDs // and that each epoch is stored under the correct key. s.Run("MultiAppMapKeysMatchEpochApplicationIDs", func() { @@ -204,7 +247,7 @@ func (s *ClaimerSuite) TestSelectAcceptedClaimPairsPerApp() { s.Require().NoError(err) // Move to ClaimAccepted - err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0) + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) s.Require().NoError(err) accepted, _, _, err := s.Repo.SelectAcceptedClaimPairsPerApp(s.Ctx) @@ -222,7 +265,7 @@ func (s *ClaimerSuite) TestSelectAcceptedClaimPairsPerApp() { txHash := UniqueHash() err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) s.Require().NoError(err) - err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0) + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) s.Require().NoError(err) } @@ -243,7 +286,7 @@ func (s *ClaimerSuite) TestSelectAcceptedClaimPairsPerApp() { txHash := UniqueHash() err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) s.Require().NoError(err) - err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0) + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) s.Require().NoError(err) } @@ -292,7 +335,7 @@ func (s *ClaimerSuite) TestSelectAcceptedClaimPairsPerApp() { err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) s.Require().NoError(err) - err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0) + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) s.Require().NoError(err) accepted, submitted, apps, err := s.Repo.SelectAcceptedClaimPairsPerApp(s.Ctx) @@ -322,7 +365,7 @@ func (s *ClaimerSuite) TestSelectAcceptedClaimPairsPerApp() { err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) s.Require().NoError(err) - err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0) + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) s.Require().NoError(err) reason := "test disabled" @@ -364,7 +407,7 @@ func (s *ClaimerSuite) TestSelectAcceptedClaimPairsPerApp() { err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash0) s.Require().NoError(err) - err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0) + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) s.Require().NoError(err) // Move epoch 1 to ClaimSubmitted @@ -457,7 +500,22 @@ func (s *ClaimerSuite) TestUpdateEpochWithAcceptedClaim() { AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, app.IApplicationAddress.String(), epoch, EpochStatus_ClaimSubmitted) - err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0) + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + }) + + s.Run("ComputedToAcceptedIsAllowed", func() { + // In v3, CLAIM_COMPUTED → CLAIM_ACCEPTED is a legal transition + // (deep reader-mode catch-up and PRT). The trigger permits it and + // UpdateEpochWithAcceptedClaim's WHERE clause accepts COMPUTED as + // a valid source. This test pins that behavior. + app := s.createAppWithClaimComputedEpoch() + + err := s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) s.Require().NoError(err) got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) @@ -465,11 +523,151 @@ func (s *ClaimerSuite) TestUpdateEpochWithAcceptedClaim() { s.Equal(EpochStatus_ClaimAccepted, got.Status) }) - s.Run("ErrorWhenEpochNotClaimSubmitted", func() { - // Create an app with an epoch in ClaimComputed status (not ClaimSubmitted) + s.Run("ComputedToAcceptedWithNilTxHashLeavesColumnNull", func() { app := s.createAppWithClaimComputedEpoch() - err := s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0) + err := s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + s.Nil(got.ClaimTransactionHash, + "getClaim-driven COMPUTED -> ACCEPTED reconciliation has no event tx hash to record") + }) + + // Catch-up reconciliation path: an epoch coming from CLAIM_COMPUTED + // (the read-only scan caught a matching ClaimAccepted on chain) needs + // to record the observed event's tx hash, because the epoch never went + // through the CLAIM_SUBMITTED transition that normally populates the + // column. Pass a non-nil txHash and assert it lands on the row. + s.Run("ComputedToAcceptedRecordsTxHashWhenProvided", func() { + app := s.createAppWithClaimComputedEpoch() + txHash := UniqueHash() + + err := s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, &txHash) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + s.Require().NotNil(got.ClaimTransactionHash, + "catch-up reconciliation with a known event tx must populate claim_transaction_hash") + s.Equal(txHash, *got.ClaimTransactionHash) + }) + + // Symmetric to the above for the normal flow: when txHash is nil, + // claim_transaction_hash is left untouched. The submit-flow caller + // relies on this: the column was set during CLAIM_SUBMITTED and must + // carry through the CLAIM_STAGED → CLAIM_ACCEPTED steps unchanged. + s.Run("NilTxHashPreservesExistingColumn", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + // Drive through INPUTS_PROCESSED → CLAIM_COMPUTED (which seeds the + // proof fields via the test helper) then submit via the real + // repository method that records the submit-tx hash. This mirrors + // the production submit flow exactly. + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, EpochStatus_ClaimComputed) + submitTx := UniqueHash() + s.Require().NoError(s.Repo.UpdateEpochWithSubmittedClaim( + s.Ctx, app.ID, 0, submitTx)) + + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + s.Require().NotNil(got.ClaimTransactionHash, + "nil txHash must NOT clear an existing claim_transaction_hash") + s.Equal(submitTx, *got.ClaimTransactionHash, + "the value seeded during CLAIM_SUBMITTED must carry through to CLAIM_ACCEPTED") + }) +} + +func (s *ClaimerSuite) TestRejectEpochAndSetApplicationInoperable() { + assertRejected := func(app *Application, reason string) { + gotEpoch, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimRejected, gotEpoch.Status) + + gotApp, err := s.Repo.GetApplication(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(ApplicationState_Inoperable, gotApp.State) + s.Require().NotNil(gotApp.Reason) + s.Equal(reason, *gotApp.Reason) + } + + s.Run("RejectsComputedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + reason := "quorum_divergence_at_acceptance: rejected computed epoch" + + err := s.Repo.RejectEpochAndSetApplicationInoperable(s.Ctx, app.ID, 0, reason) + s.Require().NoError(err) + + assertRejected(app, reason) + }) + + s.Run("RejectsSubmittedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, UniqueHash()) + s.Require().NoError(err) + + reason := "quorum_divergence_at_staging: rejected submitted epoch" + err = s.Repo.RejectEpochAndSetApplicationInoperable(s.Ctx, app.ID, 0, reason) + s.Require().NoError(err) + + assertRejected(app, reason) + }) + + s.Run("DoesNotRejectStagedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, UniqueHash()) + s.Require().NoError(err) + err = s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, 0, 42) + s.Require().NoError(err) + + reason := "quorum_divergence_at_acceptance: staged epoch is not a normal rejection source" + err = s.Repo.RejectEpochAndSetApplicationInoperable(s.Ctx, app.ID, 0, reason) + s.Require().Error(err) + + gotEpoch, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimStaged, gotEpoch.Status) + + gotApp, err := s.Repo.GetApplication(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(ApplicationState_Enabled, gotApp.State) + s.Nil(gotApp.Reason) + }) + + s.Run("DoesNotMarkApplicationWhenEpochCannotBeRejected", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + err = s.Repo.RejectEpochAndSetApplicationInoperable( + s.Ctx, app.ID, 0, "must not be written") s.Require().Error(err) + + gotApp, err := s.Repo.GetApplication(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(ApplicationState_Enabled, gotApp.State) + s.Nil(gotApp.Reason) }) } diff --git a/internal/repository/repotest/epoch_test_cases.go b/internal/repository/repotest/epoch_test_cases.go index a54fabec8..d7818dab9 100644 --- a/internal/repository/repotest/epoch_test_cases.go +++ b/internal/repository/repotest/epoch_test_cases.go @@ -5,6 +5,7 @@ package repotest import ( "errors" + "strings" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" @@ -950,6 +951,31 @@ func (s *EpochSuite) TestEpochStatusTransitionTrigger() { s.Equal(EpochStatus_ClaimRejected, got.Status) }) + s.Run("RejectsStagedToRejected", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, + EpochStatus_ClaimComputed) + + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, seed.App.ID, seed.Epoch.Index, UniqueHash()) + s.Require().NoError(err) + + err = s.Repo.UpdateEpochToStaged(s.Ctx, seed.App.ID, seed.Epoch.Index, 42) + s.Require().NoError(err) + + seed.Epoch.Status = EpochStatus_ClaimRejected + err = s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().Error(err) + s.Contains(err.Error(), "invalid epoch status transition") + + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimStaged, got.Status) + }) + s.Run("AllowsSameStatusUpdate", func() { seed := Seed(s.Ctx, s.T(), s.Repo) @@ -1044,4 +1070,301 @@ func (s *EpochSuite) TestEpochStatusTransitionTrigger() { s.Require().Error(err) 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() { + 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). + Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, + EpochStatus_ClaimSubmitted) + + err = s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, epoch.Index, 42) + s.Require().Error(err) + s.Contains(err.Error(), "PRT") + }) + + // Verify the trigger / CHECK constraint rejects any transition into + // CLAIM_STAGED on a row whose staged_at_block is NULL. UpdateEpochStatus + // 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) + + // Sanity: staged_at_block is NULL on this freshly built row. + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().Nil(got.StagedAtBlock) + + seed.Epoch.Status = EpochStatus_ClaimStaged + err = s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().Error(err) + // The trigger surfaces first with this exact phrasing; if a future + // refactor disables the trigger, the CHECK constraint + // epoch_staged_requires_block fires with "violates check constraint" + // — either is acceptable evidence the invariant holds. + errStr := err.Error() + s.True( + strings.Contains(errStr, "CLAIM_STAGED requires staged_at_block") || + strings.Contains(errStr, "epoch_staged_requires_block"), + "unexpected error: %s", errStr, + ) + }) +} + +// TestDrainGates exercises both foreclosure-drain gates against the same +// fixtures so the contract difference is visible: +// +// HasUndrainedEpochsBeforeBlock (PRT — advancer/validator only) +// HasUnreconciledClaimsBeforeBlock (Authority/Quorum — also claimer) +// +// The narrow gate must return false for any epoch whose status is at least +// CLAIM_COMPUTED; the broad gate must continue to return true until the +// claimer drives every pre-foreclosure epoch to CLAIM_ACCEPTED. Both gates +// must ignore epochs at or after blockBound (the foreclose block). +func (s *EpochSuite) TestDrainGates() { + const forecloseBlock uint64 = 100 + + // advance creates one epoch with one input at block `first+1`. The + // input's status mirrors what the FSM would have produced for the + // target epoch status: epochs at or beyond INPUTS_PROCESSED imply the + // advancer has run and inputs have a non-NONE terminal status. + advance := func(app *Application, idx, first, last uint64, target EpochStatus) *Epoch { + ep := NewEpochBuilder(app.ID). + WithIndex(idx). + WithStatus(EpochStatus_Closed). + WithBlocks(first, last). + WithInputBounds(idx, idx). + Build() + + inputStatus := InputCompletionStatus_None + switch target { + case EpochStatus_InputsProcessed, + EpochStatus_ClaimComputed, + EpochStatus_ClaimSubmitted, + EpochStatus_ClaimStaged, + EpochStatus_ClaimAccepted, + EpochStatus_ClaimRejected: + inputStatus = InputCompletionStatus_Accepted + } + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: { + NewInputBuilder().WithIndex(idx).WithEpochIndex(idx). + WithBlockNumber(first + 1).WithStatus(inputStatus).Build(), + }}, last+1) + s.Require().NoError(err) + + if target != EpochStatus_Closed { + AdvanceEpochStatus(s.Ctx, s.T(), + s.Repo, app.IApplicationAddress.String(), ep, target) + } + return ep + } + + // emptyOpen creates a straddling OPEN epoch with no inputs. This + // mirrors a valid PRT state (empty epochs are legal on DaveConsensus); + // Authority/Quorum never persists empty epochs but the synthetic + // setup lets us pin the gate divergence on a single shared fixture. + emptyOpen := func(app *Application, idx, first, last uint64) *Epoch { + ep := NewEpochBuilder(app.ID). + WithIndex(idx). + WithStatus(EpochStatus_Open). + WithBlocks(first, last). + WithInputBounds(idx, idx). + Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: {}}, last+1) + s.Require().NoError(err) + return ep + } + + s.Run("OpenEpochUndrainedAndUnreconciled", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_Closed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(drained, "CLOSED before forecloseBlock counts as undrained for PRT") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, "CLOSED before forecloseBlock counts as unreconciled for claimer") + }) + + s.Run("ComputedEpochOnlyUnreconciled", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimComputed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained, + "PRT gate treats CLAIM_COMPUTED as drained — tournaments cannot settle "+ + "under foreclosure, so waiting would stall forever") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, + "claimer gate keeps the drain pending until CLAIM_ACCEPTED — the read-only "+ + "reconciliation path still has to mirror an on-chain ClaimAccepted") + }) + + s.Run("AcceptedEpochClearsBothGates", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimAccepted) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained) + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(recon) + }) + + s.Run("MixedEpochsBroadGateBlocksUntilAllAccepted", func() { + // Mirrors the foreclosure-replay scenario: three pre-foreclosure + // epochs at increasing block ranges, partially reconciled. The + // narrow gate has already flipped to false; the broad gate must + // still block until the remaining COMPUTED epochs are accepted. + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimAccepted) + _ = advance(app, 1, 10, 19, EpochStatus_ClaimComputed) + _ = advance(app, 2, 20, 29, EpochStatus_ClaimComputed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained, "no OPEN/CLOSED/INPUTS_PROCESSED rows remain") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, "two CLAIM_COMPUTED rows still need reconciliation") + }) + + s.Run("PostForecloseEpochsAreIgnoredByBothGates", func() { + // An epoch whose first_block >= forecloseBlock started entirely + // after the foreclosure point and has no on-chain claim to + // reconcile against. Both gates must exclude it via the + // first_block < blockBound filter (broad gate) and the + // input-level block_number < blockBound filter (narrow gate). + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + // Pre-foreclosure epoch: already accepted. + _ = advance(app, 0, 0, 9, EpochStatus_ClaimAccepted) + // Post-foreclosure epoch: first_block == forecloseBlock (not <). + _ = advance(app, 1, forecloseBlock, forecloseBlock+9, EpochStatus_ClaimComputed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained) + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(recon, + "post-foreclosure CLAIM_COMPUTED epochs must not block the drain — "+ + "the chain emits no ClaimAccepted for them so reconciliation cannot succeed") + }) + + // A straddling OPEN epoch with first_block < forecloseBlock and + // last_block >= forecloseBlock carries pre-foreclosure inputs that + // drain must wait for. A predicate of last_block < blockBound would + // exclude such straddlers and make the app look drained while the + // unprocessed pre-foreclosure inputs were still in the DB. + s.Run("StraddlingOpenEpochWithPreFInputsCaughtByBothGates", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ep := NewEpochBuilder(app.ID). + WithIndex(0). + WithStatus(EpochStatus_Open). + WithBlocks(forecloseBlock-10, forecloseBlock+10). + WithInputBounds(0, 0). + Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: { + NewInputBuilder().WithIndex(0).WithEpochIndex(0). + WithBlockNumber(forecloseBlock - 5). // pre-F, status defaults to NONE. + Build(), + }}, forecloseBlock+11) + s.Require().NoError(err) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(drained, + "narrow gate must see a NONE input at block_number < forecloseBlock — "+ + "abandoning it would lose pre-foreclosure work") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, + "broad gate must see the OPEN epoch with first_block < forecloseBlock") + }) + + // The PRT empty-epoch invariant: a straddling OPEN epoch with zero + // inputs is valid for DaveConsensus and represents no pending work + // for the narrow gate. The broad gate, by contrast, sees the row via + // the first_block predicate — this divergence is correct because + // Authority/Quorum apps never persist empty epoch rows so the broad + // gate's "false positive" here can never fire in production. + s.Run("EmptyStraddlingEpochOnlyBlocksBroadGate", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = emptyOpen(app, 0, forecloseBlock-10, forecloseBlock+10) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained, + "narrow gate (input-level) returns false on empty straddling epoch — "+ + "PRT's empty-epoch invariant means there is nothing to drain") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, + "broad gate matches the OPEN row by first_block < forecloseBlock; "+ + "Authority/Quorum never persists empty epochs so this branch is "+ + "unreachable in production but is exercised here to pin the divergence") + }) + + s.Run("SubmittedAndStagedBlockBroadGate", func() { + // CLAIM_SUBMITTED and CLAIM_STAGED are intermediate post-broadcast + // states; both must continue to register as unreconciled until the + // terminal CLAIM_ACCEPTED transition lands. + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimSubmitted) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained) + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon) + }) } diff --git a/internal/repository/repotest/repotest.go b/internal/repository/repotest/repotest.go index c594b249f..282317eef 100644 --- a/internal/repository/repotest/repotest.go +++ b/internal/repository/repotest/repotest.go @@ -101,4 +101,5 @@ func RunAllSuites(t *testing.T, factory RepositoryFactory) { t.Run("Commitment", func(t *testing.T) { suite.Run(t, NewCommitmentSuite(factory)) }) t.Run("Match", func(t *testing.T) { suite.Run(t, NewMatchSuite(factory)) }) t.Run("MatchAdvanced", func(t *testing.T) { suite.Run(t, NewMatchAdvancedSuite(factory)) }) + t.Run("Withdrawal", func(t *testing.T) { suite.Run(t, NewWithdrawalSuite(factory)) }) } diff --git a/internal/repository/repotest/withdrawal_test_cases.go b/internal/repository/repotest/withdrawal_test_cases.go new file mode 100644 index 000000000..54a2e61dc --- /dev/null +++ b/internal/repository/repotest/withdrawal_test_cases.go @@ -0,0 +1,304 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +type WithdrawalSuite struct { + BaseSuite +} + +func NewWithdrawalSuite(factory RepositoryFactory) *WithdrawalSuite { + return &WithdrawalSuite{BaseSuite: BaseSuite{factory: factory}} +} + +// newWithdrawalFixture builds a Withdrawal for the given application + index, +// with unique-enough auxiliary data so equality assertions catch any field +// silently swapping between rows. +func newWithdrawalFixture(appID int64, accountIndex uint64) *Withdrawal { + return &Withdrawal{ + ApplicationID: appID, + AccountIndex: accountIndex, + Account: []byte{0xaa, byte(accountIndex)}, + Output: []byte{0xbb, byte(accountIndex), byte(accountIndex >> 8)}, + BlockNumber: 1000 + accountIndex, + TransactionHash: UniqueHash(), + LogIndex: uint(accountIndex % 4), + } +} + +// TestInsertWithdrawal pins the idempotent-on-conflict contract of the +// (application_id, account_index) primary key. evmreader re-processes blocks +// on restart, so a second insert with the same key must be a silent no-op +// (not an error and not an overwrite); first writer wins matches the chain +// invariant that each account index is withdrawn at most once. +func (s *WithdrawalSuite) TestInsertWithdrawal() { + s.Run("Happy", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + w := newWithdrawalFixture(app.ID, 7) + + err := s.Repo.InsertWithdrawal(s.Ctx, w) + s.Require().NoError(err) + + got, err := s.Repo.GetWithdrawal(s.Ctx, app.Name, 7) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(w.ApplicationID, got.ApplicationID) + s.Equal(w.AccountIndex, got.AccountIndex) + s.Equal(w.Account, got.Account) + s.Equal(w.Output, got.Output) + s.Equal(w.BlockNumber, got.BlockNumber) + s.Equal(w.TransactionHash, got.TransactionHash) + s.Equal(w.LogIndex, got.LogIndex) + }) + + // Restart-safety: a second insert with the same (app, accountIndex) but + // different auxiliary fields must be a silent no-op. The chain marks + // each account index as withdrawn (wereAccountFundsWithdrawn), so a + // second observation is always a restart artifact — silently keeping + // the first observation is correct. + s.Run("IdempotentOnConflict", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + first := newWithdrawalFixture(app.ID, 7) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, first)) + + second := newWithdrawalFixture(app.ID, 7) + second.Account = []byte{0xff, 0xff, 0xff} + second.Output = []byte{0xee, 0xee} + second.BlockNumber = first.BlockNumber + 100 + second.TransactionHash = UniqueHash() + second.LogIndex = first.LogIndex + 1 + err := s.Repo.InsertWithdrawal(s.Ctx, second) + s.Require().NoError(err, "ON CONFLICT DO NOTHING must not surface the conflict as an error") + + got, err := s.Repo.GetWithdrawal(s.Ctx, app.Name, 7) + s.Require().NoError(err) + s.Require().NotNil(got) + // First writer wins on every field. + s.Equal(first.Account, got.Account) + s.Equal(first.Output, got.Output) + s.Equal(first.BlockNumber, got.BlockNumber) + s.Equal(first.TransactionHash, got.TransactionHash) + s.Equal(first.LogIndex, got.LogIndex) + }) + + s.Run("RequiresValidApplication", func() { + w := newWithdrawalFixture(99_999_999, 0) + err := s.Repo.InsertWithdrawal(s.Ctx, w) + s.Require().Error(err, "FK to application(id) must reject orphan inserts") + }) +} + +func (s *WithdrawalSuite) TestGetWithdrawal() { + s.Run("Found", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + w := newWithdrawalFixture(app.ID, 3) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, w)) + + got, err := s.Repo.GetWithdrawal(s.Ctx, app.Name, 3) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(uint64(3), got.AccountIndex) + }) + + // Project convention for Get* endpoints: not-found returns (nil, nil), + // not ErrNotFound. The JSON-RPC layer translates the nil into a + // resource-not-found error code. + s.Run("NotFoundForUnknownAccountIndex", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetWithdrawal(s.Ctx, app.Name, 99) + s.Require().NoError(err) + s.Nil(got) + }) + + s.Run("NotFoundForUnknownApplication", func() { + got, err := s.Repo.GetWithdrawal(s.Ctx, "no-such-app", 0) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *WithdrawalSuite) TestListWithdrawals() { + s.Run("Empty", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Empty(ws) + s.Equal(uint64(0), total) + }) + + // Default ordering is ascending by account_index. The on-chain order is + // unconstrained between blocks; ascending account_index gives clients a + // stable iteration order. + s.Run("MultipleAscending", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, idx := range []uint64{5, 1, 3} { + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, idx))) + } + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Equal(uint64(3), total) + s.Require().Len(ws, 3) + s.Equal(uint64(1), ws[0].AccountIndex) + s.Equal(uint64(3), ws[1].AccountIndex) + s.Equal(uint64(5), ws[2].AccountIndex) + }) + + s.Run("DescendingOrder", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, idx := range []uint64{1, 3, 5} { + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, idx))) + } + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{}, repository.Pagination{}, true) + s.Require().NoError(err) + s.Equal(uint64(3), total) + s.Require().Len(ws, 3) + s.Equal(uint64(5), ws[0].AccountIndex) + s.Equal(uint64(3), ws[1].AccountIndex) + s.Equal(uint64(1), ws[2].AccountIndex) + }) + + s.Run("FilteredByAccountIndex", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, idx := range []uint64{1, 3, 5} { + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, idx))) + } + want := uint64(3) + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{AccountIndex: &want}, + repository.Pagination{}, false) + s.Require().NoError(err) + s.Equal(uint64(1), total) + s.Require().Len(ws, 1) + s.Equal(uint64(3), ws[0].AccountIndex) + }) + + // Cross-app isolation: ListWithdrawals(appA) must not surface appB rows. + // FK cascades on application delete, but the filter must also stand + // alone since rows from multiple apps can coexist. + s.Run("CrossAppIsolation", func() { + appA := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + appB := NewApplicationBuilder().WithName("other-app").Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(appA.ID, 1))) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(appB.ID, 2))) + + wsA, totalA, err := s.Repo.ListWithdrawals( + s.Ctx, appA.Name, repository.WithdrawalFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Equal(uint64(1), totalA) + s.Require().Len(wsA, 1) + s.Equal(appA.ID, wsA[0].ApplicationID) + + wsB, totalB, err := s.Repo.ListWithdrawals( + s.Ctx, appB.Name, repository.WithdrawalFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Equal(uint64(1), totalB) + s.Require().Len(wsB, 1) + s.Equal(appB.ID, wsB[0].ApplicationID) + }) + + s.Run("Pagination", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, idx := range []uint64{1, 2, 3} { + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, idx))) + } + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{}, + repository.Pagination{Limit: 1, Offset: 1}, false) + s.Require().NoError(err) + s.Equal(uint64(3), total, "total_count reports the unpaginated cardinality") + s.Require().Len(ws, 1) + s.Equal(uint64(2), ws[0].AccountIndex, "default-ascending order, offset 1 → account_index 2") + }) +} + +func (s *WithdrawalSuite) TestGetNumberOfWithdrawals() { + s.Run("CountsRowsForOneApplication", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + other := NewApplicationBuilder().WithName("other-app").Create(s.Ctx, s.T(), s.Repo) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(0), count) + + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, 1))) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, 2))) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(other.ID, 3))) + + count, err = s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(2), count) + }) +} + +func (s *WithdrawalSuite) TestStoreWithdrawalEvents() { + s.Run("PersistsRowsAndCursorAtomically", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ws := []*Withdrawal{ + newWithdrawalFixture(app.ID, 1), + newWithdrawalFixture(app.ID, 2), + } + + err := s.Repo.StoreWithdrawalEvents(s.Ctx, app.ID, ws, 1234) + s.Require().NoError(err) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(2), count) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1234), got.LastWithdrawalCheckBlock) + }) + + s.Run("EmptyBatchStillAdvancesCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.StoreWithdrawalEvents( + s.Ctx, app.ID, []*Withdrawal{}, 777) + s.Require().NoError(err) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(0), count) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(777), got.LastWithdrawalCheckBlock) + }) + + s.Run("RollsBackRowsWhenBatchIsInvalid", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + other := NewApplicationBuilder().WithName("other-app").Create(s.Ctx, s.T(), s.Repo) + ws := []*Withdrawal{ + newWithdrawalFixture(app.ID, 1), + newWithdrawalFixture(other.ID, 2), + } + + err := s.Repo.StoreWithdrawalEvents(s.Ctx, app.ID, ws, 1234) + s.Require().Error(err) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(0), count) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(0), got.LastWithdrawalCheckBlock) + }) +} + +// Compile-time check that the Withdrawal-related fields on Application are +// not silently dropped on round-trip via JSON or the repo. The repository +// implementation has many SELECT/scan column lists; a missing column in any +// of them would surface here. +var _ = (*Withdrawal)(nil) +var _ = common.Hash{} From 404950cd05f01184e5eabe3a87174028e70942f5 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Fri, 22 May 2026 13:15:49 -0300 Subject: [PATCH 05/16] feat(jsonrpc): update API models and add withdrawal methods --- internal/jsonrpc/api/params.go | 15 ++ internal/jsonrpc/jsonrpc-discover.json | 210 +++++++++++++++++ internal/jsonrpc/jsonrpc.go | 93 ++++++++ internal/jsonrpc/jsonrpc_test.go | 305 +++++++++++++++++++++++++ 4 files changed, 623 insertions(+) diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index d3b743cae..632a386fa 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -163,3 +163,18 @@ type GetMatchAdvancedParams struct { IDHash string `json:"id_hash"` Parent string `json:"parent"` } + +// ListWithdrawalsParams aligns with the OpenRPC specification +type ListWithdrawalsParams struct { + Application string `json:"application"` + AccountIndex *string `json:"account_index,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetWithdrawalParams aligns with the OpenRPC specification +type GetWithdrawalParams struct { + Application string `json:"application"` + AccountIndex string `json:"account_index"` +} diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 3697c495e..2e8dfed03 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -498,6 +498,93 @@ } } }, + { + "name": "cartesi_listWithdrawals", + "summary": "List post-foreclosure withdrawals", + "description": "Returns a paginated list of Withdrawal events observed for a foreclosed application. Each row corresponds to one (account_index, account, output) tuple emitted on-chain by IApplication after the accounts drive has been proved. The event fires at most once per accountIndex, so listing all rows for an application enumerates every successful withdrawal.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "account_index", + "description": "Optional filter by accountIndex (hex encoded). When omitted, returns all withdrawals for the application.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of withdrawals to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of withdrawals to return.", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by account_index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WithdrawalListResult" + } + } + }, + { + "name": "cartesi_getWithdrawal", + "summary": "Get a specific withdrawal", + "description": "Fetches a single Withdrawal event by application and accountIndex.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "account_index", + "description": "The accountIndex of the withdrawal to retrieve (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WithdrawalGetResult" + } + } + }, { "name": "cartesi_listTournaments", "summary": "Retrieve a List of Tournaments", @@ -1020,6 +1107,12 @@ "epoch_length": { "$ref": "#/components/schemas/UnsignedInteger" }, + "claim_staging_period": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "withdrawal_config": { + "$ref": "#/components/schemas/WithdrawalConfig" + }, "data_availability": { "$ref": "#/components/schemas/ByteArray" }, @@ -1048,9 +1141,40 @@ "last_tournament_check_block": { "$ref": "#/components/schemas/UnsignedInteger" }, + "last_foreclose_check_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_accounts_drive_proved_check_block": { + "description": "Highest block scanned by the post-foreclosure accounts-drive-proved discovery loop. Strictly monotonic; only populated for foreclosed applications.", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_withdrawal_check_block": { + "description": "Highest block scanned by the post-foreclosure Withdrawal-event discovery loop. Strictly monotonic; only populated once the accounts drive has been proved.", + "$ref": "#/components/schemas/UnsignedInteger" + }, "processed_inputs": { "$ref": "#/components/schemas/UnsignedInteger" }, + "foreclose_block": { + "description": "Block where the on-chain Foreclosure event was observed. Zero means the node has not yet observed a foreclosure (block 0 is unreachable for the event, so it is an unambiguous sentinel). Non-zero is one-way: once set, the application stays ENABLED and evmreader transitions into post-foreclosure observation (drive-prove discovery, then Withdrawal indexing).", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "foreclose_transaction": { + "description": "Transaction hash of the Foreclosure event. All-zero (0x000...0) when foreclose_block is zero; otherwise the tx that emitted the event.", + "$ref": "#/components/schemas/Hash" + }, + "accounts_drive_proved_block": { + "description": "Block where the proveAccountsDriveMerkleRoot transaction landed for this foreclosed application. Zero means the drive has not yet been proved (or this application is not foreclosed). Non-zero gates withdrawal eligibility — the contract rejects withdraw() before the drive is proved.", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "accounts_drive_proved_transaction": { + "description": "Transaction hash of the proveAccountsDriveMerkleRoot call. Best-effort: when the per-block tx hunt cannot identify the producing transaction, this field is the zero hash. The (block, root) tuple is canonical regardless.", + "$ref": "#/components/schemas/Hash" + }, + "accounts_drive_merkle_root": { + "description": "On-chain accountsDriveMerkleRoot read at accounts_drive_proved_block. All-zero when the drive has not been proved.", + "$ref": "#/components/schemas/Hash" + }, "created_at": { "type": "string", "format": "date-time" @@ -1094,6 +1218,7 @@ "INPUTS_PROCESSED", "CLAIM_COMPUTED", "CLAIM_SUBMITTED", + "CLAIM_STAGED", "CLAIM_ACCEPTED", "CLAIM_REJECTED" ] @@ -1146,6 +1271,9 @@ "status": { "$ref": "#/components/schemas/EpochStatus" }, + "staged_at_block": { + "oneOf": [{"$ref": "#/components/schemas/UnsignedInteger"}, {"type": "null"}] + }, "virtual_index": { "$ref": "#/components/schemas/UnsignedInteger" }, @@ -1466,6 +1594,60 @@ } } }, + "Withdrawal": { + "type": "object", + "description": "A Withdrawal event observed for a foreclosed IApplication after its accounts drive has been proved. account and output are stored as raw bytes; recipient encoding inside account is defined by the per-app WithdrawalOutputBuilder and is opaque to the node.", + "properties": { + "account_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "account": { + "$ref": "#/components/schemas/ByteArray" + }, + "output": { + "$ref": "#/components/schemas/ByteArray" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "transaction_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "WithdrawalListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Withdrawal" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "WithdrawalGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Withdrawal" + } + } + }, "SnapshotPolicy": { "type": "string", "enum": [ @@ -1573,6 +1755,34 @@ "format": "hex-byte", "pattern": "^0x[a-fA-F0-9]{40}$" }, + "WithdrawalConfig": { + "type": "object", + "description": "On-chain WithdrawalConfig mirroring the five Application contract immutables (guardian, log2_leaves_per_account, log2_max_num_of_accounts, accounts_drive_start_index, withdrawal_output_builder). The all-zero shape encodes 'no withdrawal handling configured' — applications without a guardian cannot be foreclosed.", + "properties": { + "guardian": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "log2_leaves_per_account": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "log2_max_num_of_accounts": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "accounts_drive_start_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "withdrawal_output_builder": { + "$ref": "#/components/schemas/EthereumAddress" + } + }, + "required": [ + "guardian", + "log2_leaves_per_account", + "log2_max_num_of_accounts", + "accounts_drive_start_index", + "withdrawal_output_builder" + ] + }, "Hash": { "type": "string", "format": "hex-byte", diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 7b73514fa..0358408bd 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -57,6 +57,8 @@ var jsonrpcHandlers = dispatchTable{ "cartesi_getOutput": handleGetOutput, "cartesi_listReports": handleListReports, "cartesi_getReport": handleGetReport, + "cartesi_listWithdrawals": handleListWithdrawals, + "cartesi_getWithdrawal": handleGetWithdrawal, "cartesi_listTournaments": handleListTournaments, "cartesi_getTournament": handleGetTournament, "cartesi_listCommitments": handleListCommitments, @@ -693,6 +695,97 @@ func handleGetReport(s *Service, w http.ResponseWriter, r *http.Request, req RPC writeRPCResult(w, req.ID, api.SingleResponse[*model.Report]{Data: report}) } +func handleListWithdrawals(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { + var params api.ListWithdrawalsParams + if err := UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return + } + + 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 { + writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return + } + + withdrawalFilter := repository.WithdrawalFilter{} + if params.AccountIndex != nil { + accountIndex, err := config.ToIndexFromString(*params.AccountIndex) + if err != nil { + writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err), nil) + return + } + withdrawalFilter.AccountIndex = &accountIndex + } + + withdrawals, total, err := s.repository.ListWithdrawals( + r.Context(), params.Application, withdrawalFilter, + repository.Pagination{Limit: params.Limit, Offset: params.Offset}, + params.Descending, + ) + if err != nil { + s.Logger.Error("Unable to retrieve withdrawals from repository", "err", err) + writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return + } + + if len(withdrawals) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { + return + } + if withdrawals == nil { + withdrawals = []*model.Withdrawal{} + } + + writeRPCResult(w, req.ID, api.ListResponse[*model.Withdrawal]{ + Data: withdrawals, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }) +} + +func handleGetWithdrawal(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { + var params api.GetWithdrawalParams + if err := UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return + } + + if err := validateNameOrAddress(params.Application); err != nil { + writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return + } + + accountIndex, err := config.ToIndexFromString(params.AccountIndex) + if err != nil { + writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err), nil) + return + } + + withdrawal, err := s.repository.GetWithdrawal(r.Context(), params.Application, accountIndex) + if err != nil { + s.Logger.Error("Unable to retrieve withdrawal from repository", "err", err) + writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return + } + if withdrawal == nil { + writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Withdrawal not found", nil) + return + } + + writeRPCResult(w, req.ID, api.SingleResponse[*model.Withdrawal]{Data: withdrawal}) +} + func handleListTournaments(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { var params api.ListTournamentsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index ff77c9add..7a529da59 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -2726,6 +2726,311 @@ func TestMethod(t *testing.T) { }) }) + //////////////////////////////////////////////////////////////////////// + // getWithdrawal + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getWithdrawal", func(t *testing.T) { + method := getName(t.Name()) + + // failure: account_index not hex encoded -> invalid params + t.Run("malformed", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getWithdrawal", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), "not-hex")) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + }) + + // failure: application missing -> resource not found. + // GetWithdrawal's joined SELECT returns (nil, nil) for either + // missing application or missing account_index; both surface as + // "Withdrawal not found" — the discriminator is irrelevant to + // callers since neither path returns a row. + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(2) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getWithdrawal", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(0))) + + resp := testRPCResponse[*model.Withdrawal]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + }) + + // failure: application exists but no matching account_index -> + // resource not found. + t.Run("absentAccountIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + s.newTestApplication(ctx, t, app) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getWithdrawal", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(99))) + + resp := testRPCResponse[*model.Withdrawal]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Withdrawal not found", resp.Error.Message) + }) + + // success: account_index in DB -> return the row. + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(4) + appID := s.newTestApplication(ctx, t, app) + w := &model.Withdrawal{ + ApplicationID: appID, + AccountIndex: 7, + Account: []byte{0xaa, 0xbb}, + Output: []byte{0xcc, 0xdd}, + BlockNumber: 1234, + TransactionHash: common.HexToHash("0xcafe"), + LogIndex: 2, + } + require.NoError(t, s.repository.InsertWithdrawal(ctx, w)) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getWithdrawal", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(w.AccountIndex))) + + type Result struct { + AccountIndex hex64 `json:"account_index"` + Account string `json:"account"` + Output string `json:"output"` + BlockNumber hex64 `json:"block_number"` + TransactionHash common.Hash `json:"transaction_hash"` + LogIndex hex64 `json:"log_index"` + } + resp := testRPCResponse[Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, w.AccountIndex, uint64(resp.Result.Data.AccountIndex)) + assert.Equal(t, "0x"+common.Bytes2Hex(w.Account), resp.Result.Data.Account) + assert.Equal(t, "0x"+common.Bytes2Hex(w.Output), resp.Result.Data.Output) + assert.Equal(t, w.BlockNumber, uint64(resp.Result.Data.BlockNumber)) + assert.Equal(t, w.TransactionHash, resp.Result.Data.TransactionHash) + assert.Equal(t, uint64(w.LogIndex), uint64(resp.Result.Data.LogIndex)) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // listWithdrawals + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listWithdrawals", func(t *testing.T) { + method := getName(t.Name()) + + // failure: application missing -> resource not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Withdrawal]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: application present but no withdrawals -> empty list + t.Run("empty", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Withdrawal]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // failure: malformed account_index filter -> invalid params + t.Run("malformedAccountIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(2) + s.newTestApplication(ctx, t, app) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), "not-hex")) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + }) + + // success: many withdrawals, ascending + descending + pagination + filter + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + appID := s.newTestApplication(ctx, t, app) + + const many = uint64(10) + const limit = uint64(many / 2) + for i := uint64(0); i < many; i++ { + require.NoError(t, s.repository.InsertWithdrawal(ctx, &model.Withdrawal{ + ApplicationID: appID, + AccountIndex: i, + Account: []byte{0xaa, byte(i)}, + Output: []byte{0xbb, byte(i)}, + BlockNumber: 1000 + i, + TransactionHash: common.HexToHash(hexutil.EncodeUint64(i)), + LogIndex: uint(i % 4), + })) + } + + type Result struct { + AccountIndex hex64 `json:"account_index"` + } + + { // offset == 0, descending = false → ascending account_index 0..limit-1 + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, false)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + assert.Equal(t, i, uint64(resp.Result.Data[i].AccountIndex)) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, false)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + assert.Equal(t, i+1, uint64(resp.Result.Data[i].AccountIndex)) + } + } + + { // offset == 0, descending = true → last index first + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, true)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + assert.Equal(t, many-i-1, uint64(resp.Result.Data[i].AccountIndex)) + } + } + + { // account_index filter → exactly one row + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(3))) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 1, len(resp.Result.Data)) + assert.Equal(t, uint64(3), uint64(resp.Result.Data[0].AccountIndex)) + } + }) + }) + // tested methods, implemented methods and discover methods must match: data, err := discoverSpec.ReadFile("jsonrpc-discover.json") require.NoError(t, err) From 167be0899624725688b514f24608f3d107a81189 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:05:50 -0300 Subject: [PATCH 06/16] feat(cli): deploy and register v3 withdrawal config --- .../root/app/register/register.go | 143 ++++++++++++++--- .../root/app/register/register_test.go | 72 +++++++++ .../root/app/status/status.go | 19 ++- .../root/deploy/application.go | 40 ++++- .../root/deploy/authority.go | 13 +- cmd/cartesi-rollups-cli/root/deploy/deploy.go | 18 ++- .../root/deploy/withdrawal_config.go | 130 ++++++++++++++++ .../root/deploy/withdrawal_config_test.go | 146 ++++++++++++++++++ .../root/read/epochs/epochs.go | 2 +- 9 files changed, 547 insertions(+), 36 deletions(-) create mode 100644 cmd/cartesi-rollups-cli/root/app/register/register_test.go create mode 100644 cmd/cartesi-rollups-cli/root/deploy/withdrawal_config.go create mode 100644 cmd/cartesi-rollups-cli/root/deploy/withdrawal_config_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 0fc846fe2..f03ca82c9 100644 --- a/cmd/cartesi-rollups-cli/root/app/register/register.go +++ b/cmd/cartesi-rollups-cli/root/app/register/register.go @@ -17,8 +17,10 @@ import ( "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/iquorum" "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/ethclient" "github.com/spf13/cobra" @@ -48,6 +50,7 @@ var ( templatePath string templateHash string epochLength uint64 + claimStagingPeriod uint64 inputBoxBlockNumber uint64 inputBoxAddressFromEnv bool dataAvailability string @@ -80,6 +83,11 @@ func init() { "Consensus Epoch length. (DO NOT USE IN PRODUCTION)\nThis value is retrieved from the consensus contract", ) + Cmd.Flags().Uint64Var(&claimStagingPeriod, "claim-staging-period", 0, + "Consensus claim staging period in blocks (Authority/Quorum only). "+ + "(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", ) @@ -175,6 +183,20 @@ func run(cmd *cobra.Command, args []string) { } } + if !cmd.Flags().Changed("claim-staging-period") && !applicationTypePRT { + claimStagingPeriod, err = getClaimStagingPeriod(ctx, consensus) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get claim staging period from consensus: %v\n", err) + os.Exit(1) + } + } + + withdrawalConfig, err := readApplicationWithdrawalConfig(ctx, address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read withdrawal config from application: %v\n", err) + os.Exit(1) + } + inputBoxAddress, encodedDA, err := processDataAvailability( ctx, address, @@ -203,27 +225,33 @@ func run(cmd *cobra.Command, args []string) { os.Exit(1) } - consensusType := model.Consensus_Authority - if applicationTypePRT { - consensusType = model.Consensus_PRT + consensusType, err := getConsensusType(ctx, consensus, applicationTypePRT) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to detect consensus type: %v\n", err) + os.Exit(1) } application := model.Application{ - Name: validName, - IApplicationAddress: address, - IConsensusAddress: consensus, - IInputBoxAddress: *inputBoxAddress, - TemplateURI: templatePath, - TemplateHash: parsedTemplateHash, - EpochLength: epochLength, - DataAvailability: encodedDA, - ConsensusType: consensusType, - State: applicationState, - IInputBoxBlock: inputBoxBlockNumber, - LastEpochCheckBlock: 0, - LastInputCheckBlock: 0, - LastOutputCheckBlock: 0, - LastTournamentCheckBlock: 0, + Name: validName, + IApplicationAddress: address, + IConsensusAddress: consensus, + IInputBoxAddress: *inputBoxAddress, + TemplateURI: templatePath, + TemplateHash: parsedTemplateHash, + EpochLength: epochLength, + ClaimStagingPeriod: claimStagingPeriod, + WithdrawalConfig: withdrawalConfig, + DataAvailability: encodedDA, + ConsensusType: consensusType, + State: applicationState, + IInputBoxBlock: inputBoxBlockNumber, + LastEpochCheckBlock: 0, + LastInputCheckBlock: 0, + LastOutputCheckBlock: 0, + LastTournamentCheckBlock: 0, + LastForecloseCheckBlock: 0, + LastAccountsDriveProvedCheckBlock: 0, + LastWithdrawalCheckBlock: 0, } // load execution parameters from a file? @@ -320,6 +348,85 @@ func getEpochLength( return ethutil.GetEpochLength(ctx, client, consensusAddr) } +func getClaimStagingPeriod( + ctx context.Context, + consensusAddr common.Address, +) (uint64, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return 0, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := ethclient.Dial(ethEndpoint.Raw()) + if err != nil { + return 0, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetClaimStagingPeriod(ctx, client, consensusAddr) +} + +type quorumConsensusProbe interface { + NumOfValidators(opts *bind.CallOpts) (*big.Int, error) +} + +func getConsensusType( + ctx context.Context, + consensusAddr common.Address, + applicationTypePRT bool, +) (model.Consensus, error) { + if applicationTypePRT { + return model.Consensus_PRT, nil + } + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return "", fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + if err != nil { + return "", fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + quorum, err := iquorum.NewIQuorum(consensusAddr, client) + if err != nil { + return "", err + } + return consensusTypeFromQuorumProbe(applicationTypePRT, quorum) +} + +func consensusTypeFromQuorumProbe( + applicationTypePRT bool, + probe quorumConsensusProbe, +) (model.Consensus, error) { + if applicationTypePRT { + return model.Consensus_PRT, nil + } + numOfValidators, err := probe.NumOfValidators(nil) + if err != nil { + return model.Consensus_Authority, nil + } + if numOfValidators == nil || numOfValidators.Sign() == 0 { + return "", fmt.Errorf("quorum consensus reports zero validators") + } + return model.Consensus_Quorum, nil +} + +func readApplicationWithdrawalConfig( + ctx context.Context, + appAddr common.Address, +) (model.WithdrawalConfig, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return model.WithdrawalConfig{}, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := ethclient.Dial(ethEndpoint.Raw()) + if err != nil { + return model.WithdrawalConfig{}, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + wc, err := ethutil.GetApplicationWithdrawalConfig(ctx, client, appAddr) + if err != nil { + return model.WithdrawalConfig{}, err + } + return model.WithdrawalConfig(wc), nil +} + func getInputBoxDeploymentBlock( ctx context.Context, inputBoxAddress common.Address, diff --git a/cmd/cartesi-rollups-cli/root/app/register/register_test.go b/cmd/cartesi-rollups-cli/root/app/register/register_test.go new file mode 100644 index 000000000..8cfd58b59 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/register/register_test.go @@ -0,0 +1,72 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package register + +import ( + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/require" +) + +type quorumConsensusProbeStub struct { + numOfValidators *big.Int + err error + called bool +} + +func (p *quorumConsensusProbeStub) NumOfValidators(_ *bind.CallOpts) (*big.Int, error) { + p.called = true + return p.numOfValidators, p.err +} + +func TestConsensusTypeFromQuorumProbe_PRTSkipsProbe(t *testing.T) { + probe := &quorumConsensusProbeStub{ + numOfValidators: big.NewInt(3), + } + + consensusType, err := consensusTypeFromQuorumProbe(true, probe) + + require.NoError(t, err) + require.Equal(t, model.Consensus_PRT, consensusType) + require.False(t, probe.called) +} + +func TestConsensusTypeFromQuorumProbe_AuthorityWhenProbeFails(t *testing.T) { + probe := &quorumConsensusProbeStub{ + err: errors.New("execution reverted"), + } + + consensusType, err := consensusTypeFromQuorumProbe(false, probe) + + require.NoError(t, err) + require.Equal(t, model.Consensus_Authority, consensusType) + require.True(t, probe.called) +} + +func TestConsensusTypeFromQuorumProbe_QuorumWhenValidatorsExist(t *testing.T) { + probe := &quorumConsensusProbeStub{ + numOfValidators: big.NewInt(3), + } + + consensusType, err := consensusTypeFromQuorumProbe(false, probe) + + require.NoError(t, err) + require.Equal(t, model.Consensus_Quorum, consensusType) + require.True(t, probe.called) +} + +func TestConsensusTypeFromQuorumProbe_RejectsZeroValidatorQuorum(t *testing.T) { + probe := &quorumConsensusProbeStub{ + numOfValidators: big.NewInt(0), + } + + _, err := consensusTypeFromQuorumProbe(false, probe) + + require.ErrorContains(t, err, "zero validators") + require.True(t, probe.called) +} diff --git a/cmd/cartesi-rollups-cli/root/app/status/status.go b/cmd/cartesi-rollups-cli/root/app/status/status.go index 6ae81d824..8ee80de55 100644 --- a/cmd/cartesi-rollups-cli/root/app/status/status.go +++ b/cmd/cartesi-rollups-cli/root/app/status/status.go @@ -70,12 +70,29 @@ func run(cmd *cobra.Command, args []string) { os.Exit(1) } - // If no new status is provided, display the current status and reason + // If no new status is provided, display the current status and reason. + // Foreclose / drive-prove markers (zero == not observed) are surfaced + // so operators and integration tests can detect post-foreclosure + // progress without going through the JSON-RPC API. Foreclosure does + // not transition the app state, so these fields are the only signal + // that the chain has moved past the app's active lifecycle. if len(args) == 1 { fmt.Println(app.State) if app.Reason != nil && *app.Reason != "" { fmt.Printf("Reason: %s\n", *app.Reason) } + if app.ForecloseBlock != 0 { + fmt.Printf("Foreclose block: 0x%x\n", app.ForecloseBlock) + if app.ForecloseTransaction != nil { + fmt.Printf("Foreclose transaction: %s\n", app.ForecloseTransaction.Hex()) + } + } + if app.AccountsDriveProvedBlock != 0 { + fmt.Printf("Accounts drive proved block: 0x%x\n", app.AccountsDriveProvedBlock) + if app.AccountsDriveMerkleRoot != nil { + fmt.Printf("Accounts drive merkle root: %s\n", app.AccountsDriveMerkleRoot.Hex()) + } + } os.Exit(0) } diff --git a/cmd/cartesi-rollups-cli/root/deploy/application.go b/cmd/cartesi-rollups-cli/root/deploy/application.go index 9fb0bf1c9..10f006251 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/application.go +++ b/cmd/cartesi-rollups-cli/root/deploy/application.go @@ -250,8 +250,10 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.IInputBoxAddress = res.Deployment.InputBoxAddress 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) case *ethutil.ApplicationDeploymentResult: application.IApplicationAddress = res.ApplicationAddress @@ -259,8 +261,10 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.IInputBoxAddress = res.Deployment.InputBoxAddress 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) case *ethutil.PRTApplicationDeploymentResult: application.IApplicationAddress = res.ApplicationAddress @@ -271,6 +275,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.DataAvailability = res.DataAvailability application.IInputBoxBlock = res.IInputBoxBlock application.ConsensusType = model.Consensus_PRT + application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) default: panic("unimplemented deployment type\n") } @@ -401,7 +406,13 @@ func buildSelfhostedApplicationDeployment( return nil, fmt.Errorf("error on parameter salt: %w", err) } + request.WithdrawalConfig, err = parseWithdrawalConfig(withdrawalConfigParam, withdrawalConfigFileParam) + if err != nil { + return nil, err + } + request.EpochLength = epochLengthParam + request.ClaimStagingPeriod = claimStagingPeriodParam request.Verbose = verboseParam return request, nil } @@ -485,9 +496,14 @@ func buildApplicationOnlyDeployment( return nil, fmt.Errorf("error on parameter salt: %w", err) } + request.WithdrawalConfig, err = parseWithdrawalConfig(withdrawalConfigParam, withdrawalConfigFileParam) + if err != nil { + return nil, err + } + request.Verbose = verboseParam - request.Consensus, request.EpochLength, err = customConsensus(client, applicationConsensusAddressParam) + request.Consensus, request.EpochLength, request.ClaimStagingPeriod, err = customConsensus(client, applicationConsensusAddressParam) if err != nil { return nil, fmt.Errorf("error on parameter consensus: %w", err) } @@ -507,7 +523,7 @@ func buildPrtApplicationDeployment( if !cmd.Flags().Changed("prt-factory") { request.FactoryAddress, err = config.GetContractsDaveAppFactoryAddress() } else { - request.FactoryAddress, err = parseHexAddress(factoryAddressParam) + request.FactoryAddress, err = parseHexAddress(prtFactoryAddressParam) } if err != nil { return nil, fmt.Errorf("error on parameter factory: %w", err) @@ -531,6 +547,11 @@ func buildPrtApplicationDeployment( return nil, fmt.Errorf("error on parameter salt: %w", err) } + request.WithdrawalConfig, err = parseWithdrawalConfig(withdrawalConfigParam, withdrawalConfigFileParam) + if err != nil { + return nil, err + } + request.Verbose = verboseParam return request, nil } @@ -540,21 +561,26 @@ func parseHexHash(hash string) (common.Hash, error) { return out, out.UnmarshalText([]byte(hash)) } -func customConsensus(client *ethclient.Client, consensusString string) (common.Address, uint64, error) { +func customConsensus(client *ethclient.Client, consensusString string) (common.Address, uint64, uint64, error) { consensusAddress, err := parseHexAddress(consensusString) if err != nil { - return common.Address{}, 0, err + return common.Address{}, 0, 0, err } consensus, err := iconsensus.NewIConsensus(consensusAddress, client) if err != nil { - return common.Address{}, 0, err + return common.Address{}, 0, 0, err } epochLengthBig, err := consensus.GetEpochLength(nil) if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to retrieve consensus epoch length: %v", err) + return common.Address{}, 0, 0, fmt.Errorf("failed to retrieve consensus epoch length: %v", err) + } + + claimStagingPeriodBig, err := consensus.GetClaimStagingPeriod(nil) + if err != nil { + return common.Address{}, 0, 0, fmt.Errorf("failed to retrieve consensus claim staging period: %v", err) } - return consensusAddress, epochLengthBig.Uint64(), nil + return consensusAddress, epochLengthBig.Uint64(), claimStagingPeriodBig.Uint64(), nil } diff --git a/cmd/cartesi-rollups-cli/root/deploy/authority.go b/cmd/cartesi-rollups-cli/root/deploy/authority.go index afcbcde22..352f4aaae 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/authority.go +++ b/cmd/cartesi-rollups-cli/root/deploy/authority.go @@ -51,6 +51,8 @@ func init() { command.Flags().Lookup("salt").Hidden = false command.Flags().Lookup("json").Hidden = false command.Flags().Lookup("verbose").Hidden = false + // `claim-staging-period` is exposed on `authority` since it's + // the parameter for the authority contract being deployed. origHelpFunc(command, strings) }) } @@ -148,10 +150,11 @@ func buildAuthorityDeployment(cmd *cobra.Command, txOpts *bind.TransactOpts) (*e } return ðutil.AuthorityDeployment{ - FactoryAddress: authorityFactoryAddress, - OwnerAddress: authorityOwnerAddress, - EpochLength: epochLengthParam, - Salt: salt, - Verbose: verboseParam, + FactoryAddress: authorityFactoryAddress, + OwnerAddress: authorityOwnerAddress, + EpochLength: epochLengthParam, + ClaimStagingPeriod: claimStagingPeriodParam, + Salt: salt, + Verbose: verboseParam, }, nil } diff --git a/cmd/cartesi-rollups-cli/root/deploy/deploy.go b/cmd/cartesi-rollups-cli/root/deploy/deploy.go index 1ca3aaafa..d05c99bd3 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/deploy.go +++ b/cmd/cartesi-rollups-cli/root/deploy/deploy.go @@ -11,10 +11,13 @@ import ( ) var ( - epochLengthParam uint64 - saltParam string - asJSONParam bool - verboseParam bool + epochLengthParam uint64 + claimStagingPeriodParam uint64 + withdrawalConfigParam string + withdrawalConfigFileParam string + saltParam string + asJSONParam bool + verboseParam bool ) var Cmd = &cobra.Command{ @@ -27,6 +30,13 @@ func init() { Cmd.PersistentFlags().Uint64VarP(&epochLengthParam, "epoch-length", "", 10, // nolint: mnd "Epoch length") 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)") + Cmd.PersistentFlags().StringVar(&withdrawalConfigParam, "withdrawal-config", "", + "Inline JSON object describing the WithdrawalConfig "+ + "(see docs/withdrawal-config-guide.md). Omit to deploy without foreclosure.") + Cmd.PersistentFlags().StringVar(&withdrawalConfigFileParam, "withdrawal-config-file", "", + "Path to a JSON file describing the WithdrawalConfig. Mutually exclusive with --withdrawal-config.") Cmd.PersistentFlags().StringVar(&saltParam, "salt", "0000000000000000000000000000000000000000000000000000000000000000", "Salt value for contract deployment") Cmd.PersistentFlags().MarkHidden("salt") diff --git a/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config.go b/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config.go new file mode 100644 index 000000000..827a2623f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config.go @@ -0,0 +1,130 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/common" +) + +// withdrawalConfigJSON is the on-the-wire schema. All five fields are +// required when the user supplies any of them — partial configs are always a +// misconfiguration (see docs/withdrawal-config-guide.md §6). Pointers let us +// distinguish "missing" from "zero". +type withdrawalConfigJSON struct { + Guardian *string `json:"guardian"` + Log2LeavesPerAccount *uint8 `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts *uint8 `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex *uint64 `json:"accounts_drive_start_index"` + WithdrawalOutputBuilder *string `json:"withdrawal_output_builder"` +} + +// parseWithdrawalConfig reads the JSON object from one of the two sources +// (inline string or file path). Exactly one source must be non-empty; the +// caller is responsible for enforcing mutual exclusion via +// MarkFlagsMutuallyExclusive. When both are empty, returns the zero +// (no-foreclosure) config. +func parseWithdrawalConfig(inline, filePath string) (iapplicationfactory.WithdrawalConfig, error) { + var raw []byte + var src string + switch { + case inline != "" && filePath != "": + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config: --withdrawal-config and --withdrawal-config-file are mutually exclusive") + case inline != "": + raw = []byte(inline) + src = "--withdrawal-config" + case filePath != "": + b, err := os.ReadFile(filePath) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config: failed to read %s: %w", filePath, err) + } + raw = b + src = filePath + default: + return iapplicationfactory.WithdrawalConfig{}, nil + } + + var aux withdrawalConfigJSON + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&aux); err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): invalid JSON: %w", src, err) + } + + missing := aux.missingKeys() + if len(missing) > 0 { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): missing required keys: %s", + src, strings.Join(missing, ", ")) + } + + guardian, err := parseHexAddress(*aux.Guardian) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): invalid guardian address %q: %w", + src, *aux.Guardian, err) + } + builder, err := parseHexAddress(*aux.WithdrawalOutputBuilder) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): invalid withdrawal_output_builder address %q: %w", + src, *aux.WithdrawalOutputBuilder, err) + } + + wc := iapplicationfactory.WithdrawalConfig{ + Guardian: guardian, + Log2LeavesPerAccount: *aux.Log2LeavesPerAccount, + Log2MaxNumOfAccounts: *aux.Log2MaxNumOfAccounts, + AccountsDriveStartIndex: *aux.AccountsDriveStartIndex, + WithdrawalOutputBuilder: builder, + } + + if err := ethutil.ValidateWithdrawalConfig(wc); err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): %w", src, err) + } + + if guardian == (common.Address{}) { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): guardian address must not be the zero address "+ + "(omit the flag entirely to deploy without foreclosure)", src) + } + if builder == (common.Address{}) { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): withdrawal_output_builder address must not be the zero address", + src) + } + + return wc, nil +} + +func (w *withdrawalConfigJSON) missingKeys() []string { + var missing []string + if w.Guardian == nil { + missing = append(missing, "guardian") + } + if w.Log2LeavesPerAccount == nil { + missing = append(missing, "log2_leaves_per_account") + } + if w.Log2MaxNumOfAccounts == nil { + missing = append(missing, "log2_max_num_of_accounts") + } + if w.AccountsDriveStartIndex == nil { + missing = append(missing, "accounts_drive_start_index") + } + if w.WithdrawalOutputBuilder == nil { + missing = append(missing, "withdrawal_output_builder") + } + return missing +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config_test.go b/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config_test.go new file mode 100644 index 000000000..e2bdb3779 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config_test.go @@ -0,0 +1,146 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const validInlineJSON = `{ + "guardian": "0x1111111111111111111111111111111111111111", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + +func TestParseWithdrawalConfig_BothEmpty(t *testing.T) { + wc, err := parseWithdrawalConfig("", "") + require.NoError(t, err) + require.Equal(t, common.Address{}, wc.Guardian) + require.Equal(t, common.Address{}, wc.WithdrawalOutputBuilder) +} + +func TestParseWithdrawalConfig_BothSet(t *testing.T) { + _, err := parseWithdrawalConfig(validInlineJSON, "some/file.json") + require.Error(t, err) + require.Contains(t, err.Error(), "mutually exclusive") +} + +func TestParseWithdrawalConfig_ValidInline(t *testing.T) { + wc, err := parseWithdrawalConfig(validInlineJSON, "") + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), wc.Guardian) + require.Equal(t, common.HexToAddress("0x2222222222222222222222222222222222222222"), wc.WithdrawalOutputBuilder) + require.Equal(t, uint8(0), wc.Log2LeavesPerAccount) + require.Equal(t, uint8(20), wc.Log2MaxNumOfAccounts) + require.Equal(t, uint64(33554432), wc.AccountsDriveStartIndex) +} + +func TestParseWithdrawalConfig_ValidFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "wc.json") + require.NoError(t, os.WriteFile(path, []byte(validInlineJSON), 0o600)) + + wc, err := parseWithdrawalConfig("", path) + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), wc.Guardian) +} + +func TestParseWithdrawalConfig_FileNotFound(t *testing.T) { + _, err := parseWithdrawalConfig("", "/nonexistent/path/wc.json") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to read") +} + +func TestParseWithdrawalConfig_BadJSON(t *testing.T) { + _, err := parseWithdrawalConfig("not json", "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid JSON") +} + +func TestParseWithdrawalConfig_UnknownField(t *testing.T) { + bad := `{ + "guardian": "0x1111111111111111111111111111111111111111", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222", + "gardian": "0x0" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid JSON") +} + +func TestParseWithdrawalConfig_MissingKey(t *testing.T) { + bad := `{ + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "missing required keys") + require.Contains(t, err.Error(), "guardian") +} + +func TestParseWithdrawalConfig_BadGuardianAddress(t *testing.T) { + bad := `{ + "guardian": "not-an-address", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid guardian address") +} + +func TestParseWithdrawalConfig_FailsIsValid(t *testing.T) { + // log2_max + log2_leaves = 60 + 60 -> drive > 64 + bad := `{ + "guardian": "0x1111111111111111111111111111111111111111", + "log2_leaves_per_account": 60, + "log2_max_num_of_accounts": 60, + "accounts_drive_start_index": 0, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "larger than machine memory") +} + +func TestParseWithdrawalConfig_ZeroGuardianRejected(t *testing.T) { + bad := `{ + "guardian": "0x0000000000000000000000000000000000000000", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "guardian address must not be the zero address") +} + +func TestParseWithdrawalConfig_ZeroBuilderRejected(t *testing.T) { + bad := `{ + "guardian": "0x1111111111111111111111111111111111111111", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x0000000000000000000000000000000000000000" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "withdrawal_output_builder address must not be the zero address") +} diff --git a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go index ec966f48f..c52a4f209 100644 --- a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go +++ b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go @@ -55,7 +55,7 @@ var ( func init() { Cmd.Flags().StringVar(&status, "status", "", - "Filter epochs by status (OPEN, CLOSED, INPUTS_PROCESSED, CLAIM_COMPUTED, CLAIM_SUBMITTED, CLAIM_ACCEPTED, CLAIM_REJECTED)") + "Filter epochs by status (OPEN, CLOSED, INPUTS_PROCESSED, CLAIM_COMPUTED, CLAIM_SUBMITTED, CLAIM_STAGED, CLAIM_ACCEPTED, CLAIM_REJECTED)") Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd "Maximum number of epochs to return") Cmd.Flags().Uint64Var(&offset, "offset", 0, From e11edc9f676199c0ea56687df199996eeba9beb9 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 21 May 2026 11:32:35 -0300 Subject: [PATCH 07/16] feat(cli): add deploy quorum command --- .../root/deploy/application.go | 39 ++-- cmd/cartesi-rollups-cli/root/deploy/deploy.go | 15 +- cmd/cartesi-rollups-cli/root/deploy/quorum.go | 175 ++++++++++++++++++ .../root/deploy/quorum_test.go | 55 ++++++ internal/config/generate/Config.toml | 7 + internal/config/generated.go | 16 ++ 6 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 cmd/cartesi-rollups-cli/root/deploy/quorum.go create mode 100644 cmd/cartesi-rollups-cli/root/deploy/quorum_test.go diff --git a/cmd/cartesi-rollups-cli/root/deploy/application.go b/cmd/cartesi-rollups-cli/root/deploy/application.go index 10f006251..981b50219 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/application.go +++ b/cmd/cartesi-rollups-cli/root/deploy/application.go @@ -16,6 +16,7 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository/factory" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -264,6 +265,9 @@ func runDeployApplication(cmd *cobra.Command, args []string) { 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) + } application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) case *ethutil.PRTApplicationDeploymentResult: @@ -439,11 +443,6 @@ func buildApplicationOnlyDeployment( return nil, fmt.Errorf("error on parameter factory: %w", err) } - request.Consensus, err = parseHexAddress(applicationConsensusAddressParam) - if err != nil { - return nil, fmt.Errorf("error on parameter consensus: %w", err) - } - if !cmd.Flags().Changed("template-hash") { if len(args) >= 2 { // args[1] is mandatory if `template-hash` was absent request.TemplateHash, err = util.ReadRootHash(args[1]) @@ -503,10 +502,13 @@ func buildApplicationOnlyDeployment( request.Verbose = verboseParam - request.Consensus, request.EpochLength, request.ClaimStagingPeriod, err = customConsensus(client, applicationConsensusAddressParam) + var consensusType model.Consensus + request.Consensus, request.EpochLength, request.ClaimStagingPeriod, consensusType, err = + customConsensus(client, applicationConsensusAddressParam) if err != nil { return nil, fmt.Errorf("error on parameter consensus: %w", err) } + request.ConsensusType = consensusType.String() return request, nil } @@ -561,26 +563,39 @@ func parseHexHash(hash string) (common.Hash, error) { return out, out.UnmarshalText([]byte(hash)) } -func customConsensus(client *ethclient.Client, consensusString string) (common.Address, uint64, uint64, error) { +func customConsensus(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 + return common.Address{}, 0, 0, "", err } consensus, err := iconsensus.NewIConsensus(consensusAddress, client) if err != nil { - return common.Address{}, 0, 0, err + return common.Address{}, 0, 0, "", err } epochLengthBig, err := consensus.GetEpochLength(nil) 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: %v", err) } claimStagingPeriodBig, err := consensus.GetClaimStagingPeriod(nil) 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: %v", err) + } + + consensusType := model.Consensus_Authority + quorum, err := iquorum.NewIQuorum(consensusAddress, client) + if err != nil { + return common.Address{}, 0, 0, "", err + } + numOfValidators, err := quorum.NumOfValidators(nil) + if err == nil { + if numOfValidators.Sign() == 0 { + return common.Address{}, 0, 0, "", fmt.Errorf("quorum consensus reports zero validators") + } + consensusType = model.Consensus_Quorum } - return consensusAddress, epochLengthBig.Uint64(), claimStagingPeriodBig.Uint64(), nil + return consensusAddress, epochLengthBig.Uint64(), claimStagingPeriodBig.Uint64(), consensusType, nil } diff --git a/cmd/cartesi-rollups-cli/root/deploy/deploy.go b/cmd/cartesi-rollups-cli/root/deploy/deploy.go index d05c99bd3..1c77e1e4d 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/deploy.go +++ b/cmd/cartesi-rollups-cli/root/deploy/deploy.go @@ -11,13 +11,13 @@ import ( ) var ( - epochLengthParam uint64 - claimStagingPeriodParam uint64 - withdrawalConfigParam string - withdrawalConfigFileParam string - saltParam string - asJSONParam bool - verboseParam bool + epochLengthParam uint64 + claimStagingPeriodParam uint64 + withdrawalConfigParam string + withdrawalConfigFileParam string + saltParam string + asJSONParam bool + verboseParam bool ) var Cmd = &cobra.Command{ @@ -49,6 +49,7 @@ func init() { Cmd.AddCommand(applicationCmd) Cmd.AddCommand(authorityCmd) + Cmd.AddCommand(quorumCmd) } func run(cmd *cobra.Command, args []string) { diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum.go b/cmd/cartesi-rollups-cli/root/deploy/quorum.go new file mode 100644 index 000000000..3f8e5244a --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum.go @@ -0,0 +1,175 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "encoding/json" + "fmt" + "os" + + "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/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/spf13/cobra" +) + +var ( + quorumFactoryAddressParam string + quorumValidatorAddressArgs []string +) + +var quorumCmd = &cobra.Command{ + Use: "quorum", + Short: "Deploy a new quorum contract", + Example: quorumExamples, + Args: cobra.NoArgs, + Run: runDeployQuorum, + Long: ` +Supported Environment Variables: + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS Quorum Factory Address`, +} + +const quorumExamples = ` +# deploy a new quorum contract + - cli deploy quorum --validator 0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + +# deploy a new quorum contract with multiple validators and a custom factory address + - cli deploy quorum --validator 0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \ + --validator 0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB \ + --quorum-factory 0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC` + +func init() { + quorumCmd.Flags().StringVarP(&quorumFactoryAddressParam, "quorum-factory", "F", "", + "Quorum Factory Address. If not defined, it will be retrieved from configuration.") + quorumCmd.Flags().StringArrayVarP(&quorumValidatorAddressArgs, "validator", "v", nil, + "Quorum validator address. Repeat this flag for multiple validators.") + + origHelpFunc := quorumCmd.HelpFunc() + quorumCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("epoch-length").Hidden = false + command.Flags().Lookup("salt").Hidden = false + command.Flags().Lookup("json").Hidden = false + command.Flags().Lookup("verbose").Hidden = false + origHelpFunc(command, strings) + }) +} + +func runDeployQuorum(cmd *cobra.Command, args []string) { + var err error + + ctx := cmd.Context() + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) + + client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + cobra.CheckErr(err) + + chainID, err := client.ChainID(ctx) + cobra.CheckErr(err) + + txOpts, err := auth.GetTransactOpts(ctx, chainID) + cobra.CheckErr(err) + + deployment, err := buildQuorumDeployment(cmd) + cobra.CheckErr(err) + + if verboseParam { + fmt.Fprint(os.Stderr, deployment) + fmt.Fprintln(os.Stderr, "\twallet address: ", txOpts.From) + } + + if verboseParam { + fmt.Fprint(os.Stderr, "checking factory address...") + } + + factoryAddress := deployment.FactoryAddress + data, err := client.CodeAt(ctx, factoryAddress, nil) + cobra.CheckErr(err) + + if len(data) == 0 { + cobra.CheckErr(fmt.Errorf("No code at the factory address: %v", factoryAddress)) + } + if verboseParam { + fmt.Fprint(os.Stderr, "success\n") + } + + if verboseParam || !asJSONParam { + fmt.Fprintf(os.Stderr, "deploying quorum...") + } + deployment.Address, err = deployment.Deploy(ctx, client, txOpts) + cobra.CheckErr(err) + + 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, "", " ") + cobra.CheckErr(err) + fmt.Println(string(report)) + } +} + +func buildQuorumDeployment(cmd *cobra.Command) (*ethutil.QuorumDeployment, error) { + var err error + var quorumFactoryAddress common.Address + + if !cmd.Flags().Changed("quorum-factory") { + quorumFactoryAddress, err = config.GetContractsQuorumFactoryAddress() + } else { + quorumFactoryAddress, err = parseHexAddress(quorumFactoryAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter quorum-factory: %w", err) + } + + validators, err := parseValidatorAddresses(quorumValidatorAddressArgs) + if err != nil { + return nil, fmt.Errorf("error on parameter validator: %w", err) + } + + salt, err := ethutil.ParseSalt(saltParam) + if err != nil { + return nil, fmt.Errorf("error on parameter salt: %w", err) + } + + return ðutil.QuorumDeployment{ + FactoryAddress: quorumFactoryAddress, + Validators: validators, + EpochLength: epochLengthParam, + ClaimStagingPeriod: claimStagingPeriodParam, + Salt: salt, + Verbose: verboseParam, + }, nil +} + +func parseValidatorAddresses(values []string) ([]common.Address, error) { + if len(values) == 0 { + return nil, fmt.Errorf("at least one --validator address is required") + } + + validators := make([]common.Address, 0, len(values)) + seen := map[common.Address]struct{}{} + for _, value := range values { + if !common.IsHexAddress(value) { + return nil, fmt.Errorf("failed to parse hex address: %s", value) + } + validator := common.HexToAddress(value) + if validator == (common.Address{}) { + return nil, fmt.Errorf("zero address validator is not allowed") + } + if _, ok := seen[validator]; ok { + return nil, fmt.Errorf("duplicate validator address: %s", validator.Hex()) + } + seen[validator] = struct{}{} + validators = append(validators, validator) + } + return validators, nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go b/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go new file mode 100644 index 000000000..4692172c6 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go @@ -0,0 +1,55 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestParseValidatorAddresses_ValidRepeatedFlags(t *testing.T) { + got, err := parseValidatorAddresses([]string{ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + }) + + require.NoError(t, err) + require.Equal(t, []common.Address{ + common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + }, got) +} + +func TestParseValidatorAddresses_RequiresAtLeastOneValidator(t *testing.T) { + _, err := parseValidatorAddresses(nil) + + require.Error(t, err) + require.Contains(t, err.Error(), "at least one") +} + +func TestParseValidatorAddresses_RejectsInvalidAddress(t *testing.T) { + _, err := parseValidatorAddresses([]string{"not-an-address"}) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse") +} + +func TestParseValidatorAddresses_RejectsZeroAddress(t *testing.T) { + _, err := parseValidatorAddresses([]string{"0x0000000000000000000000000000000000000000"}) + + require.Error(t, err) + require.Contains(t, err.Error(), "zero address") +} + +func TestParseValidatorAddresses_RejectsDuplicates(t *testing.T) { + _, err := parseValidatorAddresses([]string{ + "0x1111111111111111111111111111111111111111", + "0x1111111111111111111111111111111111111111", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate") +} diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index 442bdfb10..e0d777521 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -273,6 +273,13 @@ Address of the AuthorityFactory contract. Not required, used only by the CLI and omit = true used-by = ["cli"] +[contracts.CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS] +go-type = "Address" +description = """ +Address of the QuorumFactory contract. Not required, used only by the CLI and tests""" +omit = true +used-by = ["cli"] + [contracts.CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS] go-type = "Address" description = """ diff --git a/internal/config/generated.go b/internal/config/generated.go index defadaaed..01d97c777 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -38,6 +38,7 @@ const ( CONTRACTS_AUTHORITY_FACTORY_ADDRESS = "CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS" CONTRACTS_DAVE_APP_FACTORY_ADDRESS = "CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS" CONTRACTS_INPUT_BOX_ADDRESS = "CARTESI_CONTRACTS_INPUT_BOX_ADDRESS" + CONTRACTS_QUORUM_FACTORY_ADDRESS = "CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS" 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" @@ -133,6 +134,8 @@ func SetDefaults() { // no default for CARTESI_CONTRACTS_INPUT_BOX_ADDRESS + // no default for CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS + // no default for CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS viper.SetDefault(DATABASE_CONNECTION, "") @@ -1920,6 +1923,19 @@ func GetContractsInputBoxAddress() (Address, error) { return notDefinedAddress(), fmt.Errorf("%s: %w", CONTRACTS_INPUT_BOX_ADDRESS, ErrNotDefined) } +// GetContractsQuorumFactoryAddress returns the value for the environment variable CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS. +func GetContractsQuorumFactoryAddress() (Address, error) { + s := viper.GetString(CONTRACTS_QUORUM_FACTORY_ADDRESS) + if s != "" { + v, err := toAddress(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CONTRACTS_QUORUM_FACTORY_ADDRESS, err) + } + return v, nil + } + return notDefinedAddress(), fmt.Errorf("%s: %w", CONTRACTS_QUORUM_FACTORY_ADDRESS, ErrNotDefined) +} + // GetContractsSelfHostedApplicationFactoryAddress returns the value for the environment variable CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS. func GetContractsSelfHostedApplicationFactoryAddress() (Address, error) { s := viper.GetString(CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS) From e22f6eb2c81eaaf55d56afbf4c2c0e195156f01d Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:06:43 -0300 Subject: [PATCH 08/16] feat(cli): show v3 contract state in inspector --- cmd/cartesi-rollups-cli/root/contract/app.go | 73 +++++++++++++--- .../root/contract/consensus.go | 13 ++- .../root/contract/contract.go | 59 ++++++++++++- .../root/contract/contract_test.go | 13 +++ .../root/contract/epoch.go | 4 +- .../root/contract/summary.go | 86 +++++++++++++------ .../root/contract/types.go | 53 +++++++----- 7 files changed, 236 insertions(+), 65 deletions(-) diff --git a/cmd/cartesi-rollups-cli/root/contract/app.go b/cmd/cartesi-rollups-cli/root/contract/app.go index c0ce92039..e1d6a6990 100644 --- a/cmd/cartesi-rollups-cli/root/contract/app.go +++ b/cmd/cartesi-rollups-cli/root/contract/app.go @@ -11,6 +11,7 @@ import ( "os" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" ) @@ -91,6 +92,16 @@ func (c *chainClient) queryApp() (*AppResult, error) { return nil, fmt.Errorf("GetDataAvailability: %w", err) } + isForeclosed, err := app.IsForeclosed(c.callOpts) + if err != nil { + return nil, fmt.Errorf("IsForeclosed: %w", err) + } + + wc, err := ethutil.GetApplicationWithdrawalConfig(c.callOpts.Context, c.eth, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetApplicationWithdrawalConfig: %w", err) + } + // Detect consensus type for display. consensusLabel := consensusUnknown.String() if err := c.ensureContract(consensusAddr, "consensus"); err == nil { @@ -108,30 +119,64 @@ func (c *chainClient) queryApp() (*AppResult, error) { } return &AppResult{ - Address: formatAddr(c.appAddr), - Owner: formatAddr(owner), - TemplateHash: formatHash(templateHash), - DeploymentBlock: deploymentBlock, - ExecutedOutputs: executedOutputs, - ConsensusAddress: formatAddr(consensusAddr), - ConsensusType: consensusLabel, - DataAvailability: "0x" + hex.EncodeToString(dataAvailability), + Address: formatAddr(c.appAddr), + Owner: formatAddr(owner), + TemplateHash: formatHash(templateHash), + DeploymentBlock: deploymentBlock, + ExecutedOutputs: executedOutputs, + ConsensusAddress: formatAddr(consensusAddr), + ConsensusType: consensusLabel, + DataAvailability: "0x" + hex.EncodeToString(dataAvailability), + IsForeclosed: isForeclosed, + Guardian: formatAddr(wc.Guardian), + WithdrawalOutputBuilder: formatAddr(wc.WithdrawalOutputBuilder), + Log2LeavesPerAccount: wc.Log2LeavesPerAccount, + Log2MaxNumOfAccounts: wc.Log2MaxNumOfAccounts, + AccountsDriveStartIndex: wc.AccountsDriveStartIndex, }, nil } func printApp(r *AppResult, blockNum, chainID, blockTime uint64) { p := &printer{w: os.Stdout} p.withSection(fmt.Sprintf("Application %s", r.Address), func() { - p.field("Template Hash", r.TemplateHash) - p.field("Owner", r.Owner) - 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) + printAppFields(p, r) }) p.footer(blockNum, chainID, blockTime) } +// printAppFields renders the body of the Application section. Shared by the +// standalone "contract app" command and "contract summary". +func printAppFields(p *printer, r *AppResult) { + p.field("Template Hash", r.TemplateHash) + p.field("Owner", r.Owner) + 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("Foreclosed", formatBool(r.IsForeclosed)) + // WithdrawalConfig is logically grouped — a zero guardian means + // no foreclosure was configured on deploy, so other fields are + // meaningless and we condense the display. + if r.Guardian == formatAddr(common.Address{}) { + p.field("WithdrawalConfig", "(disabled — no foreclosure)") + } else { + p.field("Guardian", r.Guardian) + p.field("Withdrawal Output Builder", r.WithdrawalOutputBuilder) + p.field("Log2 Leaves Per Account", fmt.Sprintf("%d", r.Log2LeavesPerAccount)) + p.field("Log2 Max Num of Accounts", fmt.Sprintf("%d", r.Log2MaxNumOfAccounts)) + p.field("Accounts Drive Start Index", + fmt.Sprintf("%d", r.AccountsDriveStartIndex)) + } +} + +// formatBool renders a bool as a short human-readable string. +func formatBool(b bool) string { + if b { + return "yes" + } + return "no" +} + func outputJSON(v any) error { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") diff --git a/cmd/cartesi-rollups-cli/root/contract/consensus.go b/cmd/cartesi-rollups-cli/root/contract/consensus.go index 7ef8f65ff..f162e9114 100644 --- a/cmd/cartesi-rollups-cli/root/contract/consensus.go +++ b/cmd/cartesi-rollups-cli/root/contract/consensus.go @@ -44,7 +44,7 @@ func runConsensus(cmd *cobra.Command, args []string) error { case consensusAuthority: return cc.printAuthority(consensusAddr, contractVersion) case consensusQuorum: - return cc.printQuorum(consensusAddr) + return cc.printQuorum(consensusAddr, contractVersion) case consensusDave: return cc.printDave(consensusAddr) case consensusUnknown: @@ -67,15 +67,17 @@ func (c *chainClient) printAuthority(addr common.Address, contractVersion string p.withSection(fmt.Sprintf("Authority %s", result.Address), func() { p.field("Owner (Validator)", result.Owner) p.field("Epoch Length", fmt.Sprintf("%d blocks", result.EpochLength)) + p.field("Claim Staging Period", fmt.Sprintf("%d blocks", result.ClaimStagingPeriod)) p.field("Accepted Claims", fmt.Sprintf("%d", result.AcceptedClaims)) + p.field("Staged Claims", fmt.Sprintf("%d", result.StagedClaims)) p.field("IConsensus Version", result.ContractVersion) }) p.footer(c.blockNum, c.chainID, c.resolveTimestamp(c.blockNum)) return nil } -func (c *chainClient) printQuorum(addr common.Address) error { - result, err := c.queryQuorum(addr) +func (c *chainClient) printQuorum(addr common.Address, contractVersion string) error { + result, err := c.queryQuorum(addr, contractVersion) if err != nil { return err } @@ -90,7 +92,12 @@ func (c *chainClient) printQuorum(addr common.Address) error { p.field("Quorum Threshold", fmt.Sprintf("%d (computed: strict majority)", result.QuorumThreshold)) p.field("Epoch Length", fmt.Sprintf("%d blocks", result.EpochLength)) + p.field("Claim Staging Period", fmt.Sprintf("%d blocks", result.ClaimStagingPeriod)) p.field("Accepted Claims", fmt.Sprintf("%d", result.AcceptedClaims)) + p.field("Staged Claims", fmt.Sprintf("%d", result.StagedClaims)) + if result.ContractVersion != "" { + p.field("IConsensus Version", result.ContractVersion) + } for i, v := range result.Validators { p.field(fmt.Sprintf(" Validator #%d", i+1), v) } diff --git a/cmd/cartesi-rollups-cli/root/contract/contract.go b/cmd/cartesi-rollups-cli/root/contract/contract.go index f64320960..3e7ceb540 100644 --- a/cmd/cartesi-rollups-cli/root/contract/contract.go +++ b/cmd/cartesi-rollups-cli/root/contract/contract.go @@ -14,6 +14,7 @@ import ( "time" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -118,14 +119,58 @@ var ( iDataProviderInterfaceID = [4]byte{0x7a, 0x96, 0xf4, 0x80} // IConsensus interface IDs by version (own functions only, excluding inherited // isOutputsMerkleRootValid). Checked in order; first match wins. + // v3.0.0-alpha: computed at init from the binding's ABI to stay in lockstep + // with the contract — see computeIConsensusV3InterfaceID. + iConsensusInterfaceIDv30 = computeIConsensusV3InterfaceID() // v2.2.0: submitClaim ^ getEpochLength ^ getNumberOfAcceptedClaims ^ getNumberOfSubmittedClaims iConsensusInterfaceIDv220 = [4]byte{0x90, 0xb2, 0xf3, 0x46} // v2.1.x: submitClaim ^ getEpochLength ^ getNumberOfAcceptedClaims (no getNumberOfSubmittedClaims) iConsensusInterfaceIDv21x = [4]byte{0x7e, 0xec, 0xfc, 0xec} - // IQuorum: own 7 functions (excluding inherited IConsensus). Same across versions. + // IQuorum: own 7 functions (excluding inherited IConsensus). Signatures (types) + // are identical across v2.1.x / v2.2.0 / v3 — only Solidity param NAMES changed, + // which do not affect the selector. iQuorumInterfaceID = [4]byte{0x3c, 0x92, 0x5a, 0x62} ) +// computeIConsensusV3InterfaceID returns the ERC-165 interface ID of v3 IConsensus. +// Per Solidity's `type(I).interfaceId`, the ID is the XOR of selectors of the +// functions DECLARED in IConsensus (8 of them); inherited functions +// (`isOutputsMerkleRootValid` from IOutputsMerkleRootValidator, `version` from +// IVersionGetter, IApplicationChecker which has no functions) are excluded. +// Computed at package init from the binding's embedded ABI so a contract-level +// rename or signature change is automatically reflected. +func computeIConsensusV3InterfaceID() [4]byte { + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + panic(fmt.Errorf("computeIConsensusV3InterfaceID: parse ABI: %w", err)) + } + // Methods declared in IConsensus.sol (v3), excluding inherited functions. + methodNames := []string{ + "submitClaim", + "acceptClaim", + "getEpochLength", + "getClaimStagingPeriod", + "getNumberOfAcceptedClaims", + "getNumberOfStagedClaims", + "getNumberOfSubmittedClaims", + "getClaim", + } + var id [4]byte + for _, name := range methodNames { + m, ok := parsed.Methods[name] + if !ok { + panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q not found in IConsensus ABI", name)) + } + if len(m.ID) != 4 { + panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q selector is %d bytes, expected 4", name, len(m.ID))) + } + for i := range 4 { + id[i] ^= m.ID[i] + } + } + return id +} + // chainClient holds the shared Ethereum client and call options for all subcommands. // All view functions are called through this client to ensure consistent block-number // queries. The block number is ALWAYS pinned to a concrete value (never nil/latest). @@ -252,6 +297,7 @@ type iConsensusVersion struct { } var iConsensusVersions = []iConsensusVersion{ + {iConsensusInterfaceIDv30, "v3.0.0-alpha"}, {iConsensusInterfaceIDv220, "v2.2.0"}, {iConsensusInterfaceIDv21x, "v2.1.x"}, } @@ -281,6 +327,17 @@ func (c *chainClient) detectConsensus( return consensusUnknown, "", fmt.Errorf("supportsInterface(IQuorum): %w", err) } if isQuorum { + // Quorum is also an IConsensus; probe the current IConsensus interface + // to surface the contract version. Older Quorum versions (pre-v3) report + // empty and the caller renders the label without the version suffix. + isCurrent, err := caller.SupportsInterface(c.callOpts, iConsensusInterfaceIDv30) + if err != nil { + return consensusUnknown, "", fmt.Errorf( + "supportsInterface(IConsensus v3.0.0-alpha) for Quorum: %w", err) + } + if isCurrent { + return consensusQuorum, "v3.0.0-alpha", nil + } return consensusQuorum, "", nil } diff --git a/cmd/cartesi-rollups-cli/root/contract/contract_test.go b/cmd/cartesi-rollups-cli/root/contract/contract_test.go index 28cb7b34f..35c6a411c 100644 --- a/cmd/cartesi-rollups-cli/root/contract/contract_test.go +++ b/cmd/cartesi-rollups-cli/root/contract/contract_test.go @@ -168,3 +168,16 @@ func TestConsensusTypeString(t *testing.T) { assert.Equal(t, "DaveConsensus (PRT)", consensusDave.String()) assert.Equal(t, "Unknown", consensusUnknown.String()) } + +// TestIConsensusV3InterfaceID locks down the v3 interface ID computation. +// If a method is renamed in the binding or this list drifts from the +// IConsensus.sol interface, this test surfaces the change as a value mismatch. +func TestIConsensusV3InterfaceID(t *testing.T) { + // Non-zero, distinct from the v2 IDs. + assert.NotEqual(t, [4]byte{}, iConsensusInterfaceIDv30, + "v3 interface ID should be non-zero") + assert.NotEqual(t, iConsensusInterfaceIDv220, iConsensusInterfaceIDv30, + "v3 interface ID should differ from v2.2.0") + assert.NotEqual(t, iConsensusInterfaceIDv21x, iConsensusInterfaceIDv30, + "v3 interface ID should differ from v2.1.x") +} diff --git a/cmd/cartesi-rollups-cli/root/contract/epoch.go b/cmd/cartesi-rollups-cli/root/contract/epoch.go index 45c49889b..719023f97 100644 --- a/cmd/cartesi-rollups-cli/root/contract/epoch.go +++ b/cmd/cartesi-rollups-cli/root/contract/epoch.go @@ -255,7 +255,7 @@ func (c *chainClient) epochHistoryAuthority( oracle := func(ctx context.Context, block uint64) (*big.Int, error) { opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} - return consensusCaller.GetNumberOfAcceptedClaims(opts) + return consensusCaller.GetNumberOfAcceptedClaims(opts, c.appAddr) } var claims []ClaimEvent @@ -425,7 +425,7 @@ func (c *chainClient) epochHistoryQuorum( // Pass 1: FindTransitions for ClaimAccepted. oracle := func(ctx context.Context, block uint64) (*big.Int, error) { opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} - return consensusCaller.GetNumberOfAcceptedClaims(opts) + return consensusCaller.GetNumberOfAcceptedClaims(opts, c.appAddr) } onHit := func(block uint64) error { diff --git a/cmd/cartesi-rollups-cli/root/contract/summary.go b/cmd/cartesi-rollups-cli/root/contract/summary.go index a678c94e6..a54924594 100644 --- a/cmd/cartesi-rollups-cli/root/contract/summary.go +++ b/cmd/cartesi-rollups-cli/root/contract/summary.go @@ -90,7 +90,7 @@ func runSummary(cmd *cobra.Command, args []string) error { case consensusAuthority: cResult, cErr = cc.queryAuthority(consensusAddr, contractVersion) case consensusQuorum: - cResult, cErr = cc.queryQuorum(consensusAddr) + cResult, cErr = cc.queryQuorum(consensusAddr, contractVersion) case consensusDave: cResult, cErr = cc.queryDave(consensusAddr) case consensusUnknown: @@ -167,13 +167,7 @@ func runSummary(cmd *cobra.Command, args []string) error { }) } else if appResult != nil { p.withSection(fmt.Sprintf("Application %s", appResult.Address), func() { - p.field("Template Hash", appResult.TemplateHash) - p.field("Owner", appResult.Owner) - p.field("Deployment Block", fmt.Sprintf("%d", appResult.DeploymentBlock)) - p.field("Executed Outputs", fmt.Sprintf("%d", appResult.ExecutedOutputs)) - p.field("Consensus", - fmt.Sprintf("%s (%s)", appResult.ConsensusAddress, appResult.ConsensusType)) - p.field("Data Availability", appResult.DataAvailability) + printAppFields(p, appResult) }) } @@ -234,6 +228,9 @@ func printConsensusSummary(p *printer, cr consensusResult) { fmt.Sprintf("%d (computed: strict majority)", r.QuorumThreshold)) p.field("Epoch Length", fmt.Sprintf("%d blocks", r.EpochLength)) p.field("Accepted Claims", fmt.Sprintf("%d", r.AcceptedClaims)) + if r.ContractVersion != "" { + p.field("IConsensus Version", r.ContractVersion) + } }) case *DaveConsensusResult: p.withSection(fmt.Sprintf("DaveConsensus %s", r.Address), func() { @@ -271,7 +268,7 @@ func (c *chainClient) queryAuthority( return nil, err } - claimsRaw, err := caller.GetNumberOfAcceptedClaims(c.callOpts) + claimsRaw, err := caller.GetNumberOfAcceptedClaims(c.callOpts, c.appAddr) if err != nil { return nil, fmt.Errorf("GetNumberOfAcceptedClaims: %w", err) } @@ -280,19 +277,39 @@ func (c *chainClient) queryAuthority( return nil, 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 + } + + stagedRaw, err := caller.GetNumberOfStagedClaims(c.callOpts, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetNumberOfStagedClaims: %w", err) + } + staged, err := safeUint64(stagedRaw, "staged claims") + if err != nil { + return nil, err + } + return &AuthorityConsensusResult{ - Type: "Authority", - Address: formatAddr(addr), - Owner: formatAddr(owner), - EpochLength: epochLength, - AcceptedClaims: claims, - ContractVersion: contractVersion, + Type: "Authority", + Address: formatAddr(addr), + Owner: formatAddr(owner), + EpochLength: epochLength, + ClaimStagingPeriod: stagingPeriod, + AcceptedClaims: claims, + StagedClaims: staged, + ContractVersion: contractVersion, }, nil } // queryQuorum returns a structured Quorum result. func (c *chainClient) queryQuorum( - addr common.Address, + addr common.Address, contractVersion string, ) (*QuorumConsensusResult, error) { if err := c.ensureContract(addr, "Quorum"); err != nil { return nil, err @@ -311,7 +328,7 @@ func (c *chainClient) queryQuorum( return nil, err } - claimsRaw, err := caller.GetNumberOfAcceptedClaims(c.callOpts) + claimsRaw, err := caller.GetNumberOfAcceptedClaims(c.callOpts, c.appAddr) if err != nil { return nil, fmt.Errorf("GetNumberOfAcceptedClaims: %w", err) } @@ -346,14 +363,35 @@ func (c *chainClient) queryQuorum( threshold := 1 + numVal/2 //nolint:mnd + 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 + } + + stagedRaw, err := caller.GetNumberOfStagedClaims(c.callOpts, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetNumberOfStagedClaims: %w", err) + } + staged, err := safeUint64(stagedRaw, "staged claims") + if err != nil { + return nil, err + } + return &QuorumConsensusResult{ - Type: "Quorum", - Address: formatAddr(addr), - NumValidators: numVal, - QuorumThreshold: threshold, - Validators: validators, - EpochLength: epochLength, - AcceptedClaims: claims, + Type: "Quorum", + Address: formatAddr(addr), + NumValidators: numVal, + QuorumThreshold: threshold, + Validators: validators, + EpochLength: epochLength, + ClaimStagingPeriod: stagingPeriod, + AcceptedClaims: claims, + StagedClaims: staged, + ContractVersion: contractVersion, }, nil } diff --git a/cmd/cartesi-rollups-cli/root/contract/types.go b/cmd/cartesi-rollups-cli/root/contract/types.go index 49e6c0f7f..56f010865 100644 --- a/cmd/cartesi-rollups-cli/root/contract/types.go +++ b/cmd/cartesi-rollups-cli/root/contract/types.go @@ -7,35 +7,46 @@ import "encoding/json" // AppResult is the JSON output of "contract app". type AppResult struct { - Address string `json:"address"` - Owner string `json:"owner"` - TemplateHash string `json:"template_hash"` - DeploymentBlock uint64 `json:"deployment_block"` - ExecutedOutputs uint64 `json:"executed_outputs"` - ConsensusAddress string `json:"consensus_address"` - ConsensusType string `json:"consensus_type"` - DataAvailability string `json:"data_availability"` + Address string `json:"address"` + Owner string `json:"owner"` + TemplateHash string `json:"template_hash"` + DeploymentBlock uint64 `json:"deployment_block"` + ExecutedOutputs uint64 `json:"executed_outputs"` + ConsensusAddress string `json:"consensus_address"` + ConsensusType string `json:"consensus_type"` + DataAvailability string `json:"data_availability"` + IsForeclosed bool `json:"is_foreclosed"` + Guardian string `json:"guardian"` + WithdrawalOutputBuilder string `json:"withdrawal_output_builder"` + Log2LeavesPerAccount uint8 `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts uint8 `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex uint64 `json:"accounts_drive_start_index"` } // AuthorityConsensusResult is the JSON output for Authority consensus. type AuthorityConsensusResult struct { - Type string `json:"type"` - Address string `json:"address"` - Owner string `json:"owner"` - EpochLength uint64 `json:"epoch_length"` - AcceptedClaims uint64 `json:"accepted_claims"` - ContractVersion string `json:"contract_version"` + Type string `json:"type"` + Address string `json:"address"` + Owner string `json:"owner"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + AcceptedClaims uint64 `json:"accepted_claims"` + StagedClaims uint64 `json:"staged_claims"` + ContractVersion string `json:"contract_version"` } // QuorumConsensusResult is the JSON output for Quorum consensus. type QuorumConsensusResult struct { - Type string `json:"type"` - Address string `json:"address"` - NumValidators uint64 `json:"num_validators"` - QuorumThreshold uint64 `json:"quorum_threshold"` - Validators []string `json:"validators"` - EpochLength uint64 `json:"epoch_length"` - AcceptedClaims uint64 `json:"accepted_claims"` + Type string `json:"type"` + Address string `json:"address"` + NumValidators uint64 `json:"num_validators"` + QuorumThreshold uint64 `json:"quorum_threshold"` + Validators []string `json:"validators"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + AcceptedClaims uint64 `json:"accepted_claims"` + StagedClaims uint64 `json:"staged_claims"` + ContractVersion string `json:"contract_version"` } // DaveConsensusResult is the JSON output for DaveConsensus. From c38e2eeb75e42a1b9128d8fd2c85660b4e82f280 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:08:27 -0300 Subject: [PATCH 09/16] feat(cli): add foreclose, prove-drive-root, withdraw commands --- .../root/foreclose/foreclose.go | 143 ++++++++++++ .../root/provedriveroot/provedriveroot.go | 184 +++++++++++++++ .../provedriveroot/provedriveroot_test.go | 146 ++++++++++++ cmd/cartesi-rollups-cli/root/root.go | 6 + .../root/withdraw/withdraw.go | 221 ++++++++++++++++++ .../root/withdraw/withdraw_test.go | 140 +++++++++++ cmd/cartesi-rollups-cli/util/util.go | 45 ++++ cmd/cartesi-rollups-cli/util/util_test.go | 76 ++++++ 8 files changed, 961 insertions(+) create mode 100644 cmd/cartesi-rollups-cli/root/foreclose/foreclose.go create mode 100644 cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go create mode 100644 cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot_test.go create mode 100644 cmd/cartesi-rollups-cli/root/withdraw/withdraw.go create mode 100644 cmd/cartesi-rollups-cli/root/withdraw/withdraw_test.go create mode 100644 cmd/cartesi-rollups-cli/util/util_test.go diff --git a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go new file mode 100644 index 000000000..1c6d27215 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go @@ -0,0 +1,143 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +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/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/config/auth" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +var Cmd = &cobra.Command{ + Use: "foreclose [app-name-or-address]", + Short: "Foreclose an application (guardian-only)", + Example: examples, + Args: cobra.ExactArgs(1), + Run: run, + Long: ` +Calls IApplication.foreclose() on the application contract. The transaction +must be signed by the guardian wallet configured at deploy time, otherwise it +reverts with NotGuardian. The signer is the wallet configured via +CARTESI_AUTH_*; override CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX to pick a +different derived account when the guardian differs from the node's default +signer. + +The [app-name-or-address] argument accepts EITHER an application name +(looked up in the local rollups-node database) OR an Ethereum address (used +directly without any DB access — useful on remote/reader hosts). + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID signer + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, +} + +const examples = `# Foreclose by application name (guardian signs from CARTESI_AUTH_*): +cartesi-rollups-cli foreclose echo-dapp + +# Foreclose by application address with the second derived mnemonic account as guardian: +CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1 cartesi-rollups-cli foreclose 0x7Ba726B1bc58b1fca5BD28fE3A752D57228891cC + +# Skip the confirmation prompt: +cartesi-rollups-cli foreclose echo-dapp --yes` + +var ( + skipConfirmation bool + asJSONParam bool +) + +func init() { + Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + + 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) + }) +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + cobra.CheckErr(err) + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) + + client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + cobra.CheckErr(err) + + chainId, err := client.ChainID(ctx) + cobra.CheckErr(err) + + txOpts, err := auth.GetTransactOpts(ctx, chainId) + cobra.CheckErr(err) + + appContract, err := iapplication.NewIApplication(appAddr, client) + cobra.CheckErr(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 guardian != txOpts.From { + fmt.Fprintf(os.Stderr, + "warning: signer %s does not match the application guardian %s — foreclose() will revert with NotGuardian\n", + txOpts.From, guardian) + } + + if !skipConfirmation { + fmt.Printf("Preparing to foreclose application %v with signer %v\n", + appAddr, txOpts.From) + + confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") + cobra.CheckErr(promptErr) + if !confirmed { + fmt.Println("Transaction cancelled") + os.Exit(0) + } + } + + 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()) + } + cobra.CheckErr(err) + txHash := tx.Hash() + + if asJSONParam { + result := struct { + TransactionHash string `json:"transaction_hash"` + 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) + } +} diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go new file mode 100644 index 000000000..f17f4dc60 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go @@ -0,0 +1,184 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package provedriveroot + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "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/config/auth" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +var Cmd = &cobra.Command{ + Use: "prove-drive-root [app-name-or-address]", + Short: "Anchor the accounts-drive Merkle root on a foreclosed application", + Example: examples, + Args: cobra.ExactArgs(1), + Run: run, + Long: ` +Calls IApplication.proveAccountsDriveMerkleRoot(accountsDriveMerkleRoot, proof). +This must be done ONCE per foreclosed application before any user can call +withdraw(). The signer is just the gas-payer; the call is permissionless. + +The [app-name-or-address] argument accepts EITHER an application name +(looked up in the local rollups-node database) OR an Ethereum address (used +directly without any DB access — useful on remote/reader hosts). + +The proof data is consumed verbatim from --proof-file (JSON). Suggested shape: + + { + "accounts_drive_merkle_root": "0x... 32 bytes ...", + "proof": ["0x...", "0x...", ...] + } + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID signer + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, +} + +const examples = `# Anchor the accounts-drive Merkle root from a JSON proof file: +cartesi-rollups-cli prove-drive-root echo-dapp --proof-file ./drive-root-proof.json + +# Skip the confirmation prompt: +cartesi-rollups-cli prove-drive-root echo-dapp --proof-file ./drive-root-proof.json --yes` + +type proveDriveRootJSON struct { + AccountsDriveMerkleRoot string `json:"accounts_drive_merkle_root"` + Proof []string `json:"proof"` +} + +var ( + proofFileParam string + skipConfirmation bool + asJSONParam bool +) + +func init() { + Cmd.Flags().StringVar(&proofFileParam, "proof-file", "", + "Path to the JSON proof file emitted by the accounts-drive proof generation tool") + 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") + + 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) + }) +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + root, proof, err := loadProof(proofFileParam) + cobra.CheckErr(err) + + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + cobra.CheckErr(err) + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) + client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + cobra.CheckErr(err) + + chainID, err := client.ChainID(ctx) + cobra.CheckErr(err) + txOpts, err := auth.GetTransactOpts(ctx, chainID) + cobra.CheckErr(err) + + appContract, err := iapplication.NewIApplication(appAddr, client) + cobra.CheckErr(err) + + if !skipConfirmation { + fmt.Printf("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 !confirmed { + fmt.Println("Transaction cancelled") + os.Exit(0) + } + } + + 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()) + } + cobra.CheckErr(err) + txHash := tx.Hash() + + if asJSONParam { + result := struct { + TransactionHash string `json:"transaction_hash"` + 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) + } +} + +func loadProof(path string) ([32]byte, [][32]byte, error) { + raw, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return [32]byte{}, nil, fmt.Errorf("read proof file %s: %w", path, err) + } + var aux proveDriveRootJSON + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&aux); err != nil { + return [32]byte{}, nil, fmt.Errorf("parse proof file %s: %w", path, err) + } + + rootBytes, err := hexutil.Decode(aux.AccountsDriveMerkleRoot) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("invalid accounts_drive_merkle_root: %w", err) + } + if len(rootBytes) != 32 { //nolint:mnd + return [32]byte{}, nil, fmt.Errorf( + "accounts_drive_merkle_root must be 32 bytes, got %d", len(rootBytes)) + } + var root [32]byte + copy(root[:], rootBytes) + + proof := make([][32]byte, len(aux.Proof)) + for i, s := range aux.Proof { + b, err := hexutil.Decode(s) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("invalid proof[%d]: %w", i, err) + } + if len(b) != 32 { //nolint:mnd + return [32]byte{}, nil, fmt.Errorf("proof[%d] must be 32 bytes, got %d", i, len(b)) + } + copy(proof[i][:], b) + } + return root, proof, nil +} diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot_test.go b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot_test.go new file mode 100644 index 000000000..dd5062e19 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot_test.go @@ -0,0 +1,146 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package provedriveroot + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// loadProof tests pin the JSON proof-file parser used by `prove-drive-root`. +// A malformed root or sibling must abort with a clear error before the tx +// is constructed; the on-chain `proveAccountsDriveMerkleRoot` reverts with +// less context. + +const validDriveRootProofJSON = `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002" + ] +}` + +func writeProofFile(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "proof.json") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func TestLoadProof_Valid(t *testing.T) { + path := writeProofFile(t, validDriveRootProofJSON) + root, proof, err := loadProof(path) + require.NoError(t, err) + require.Equal(t, byte(0x42), root[31]) + require.Len(t, proof, 2) + require.Equal(t, byte(0x01), proof[0][31]) + require.Equal(t, byte(0x02), proof[1][31]) +} + +func TestLoadProof_EmptyProofArray(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": [] + }` + path := writeProofFile(t, body) + _, proof, err := loadProof(path) + require.NoError(t, err) + require.Len(t, proof, 0, + "empty proof array is structurally valid here; the contract validates depth") +} + +func TestLoadProof_FileNotFound(t *testing.T) { + _, _, err := loadProof("/nonexistent/path/proof.json") + require.Error(t, err) + require.Contains(t, err.Error(), "read proof file") +} + +func TestLoadProof_InvalidJSON(t *testing.T) { + path := writeProofFile(t, `{not valid json`) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse proof file") +} + +func TestLoadProof_UnknownField(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": [], + "extra_field": "rejected" + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse proof file") +} + +func TestLoadProof_RootWrongLength(t *testing.T) { + cases := []struct { + name string + hex string + }{ + {"31_bytes", "0x" + repeatHex("aa", 31)}, + {"33_bytes", "0x" + repeatHex("aa", 33)}, + {"empty", "0x"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "` + tc.hex + `", + "proof": [] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "accounts_drive_merkle_root") + require.Contains(t, err.Error(), "32 bytes") + }) + } +} + +func TestLoadProof_BadRootHex(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "not-hex", + "proof": [] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid accounts_drive_merkle_root") +} + +func TestLoadProof_SiblingWrongLength(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": ["0x` + repeatHex("aa", 31) + `"] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "proof[0]") + require.Contains(t, err.Error(), "32 bytes") +} + +func TestLoadProof_BadSiblingHex(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": ["not-hex"] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid proof[0]") +} + +func repeatHex(b string, n int) string { + out := make([]byte, 0, n*len(b)) + for range n { + out = append(out, b...) + } + return string(out) +} diff --git a/cmd/cartesi-rollups-cli/root/root.go b/cmd/cartesi-rollups-cli/root/root.go index a5b378d01..ad6e11065 100644 --- a/cmd/cartesi-rollups-cli/root/root.go +++ b/cmd/cartesi-rollups-cli/root/root.go @@ -9,10 +9,13 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/db" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/deploy" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/execute" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/foreclose" "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/send" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/validate" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/withdraw" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/version" @@ -63,6 +66,9 @@ func init() { Cmd.AddCommand(inspect.Cmd) Cmd.AddCommand(validate.Cmd) Cmd.AddCommand(execute.Cmd) + Cmd.AddCommand(foreclose.Cmd) + Cmd.AddCommand(provedriveroot.Cmd) + Cmd.AddCommand(withdraw.Cmd) Cmd.AddCommand(app.Cmd) Cmd.AddCommand(db.Cmd) Cmd.AddCommand(deploy.Cmd) diff --git a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go new file mode 100644 index 000000000..01623f633 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go @@ -0,0 +1,221 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package withdraw + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "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/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/config/auth" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +var Cmd = &cobra.Command{ + Use: "withdraw [app-name-or-address]", + Short: "Withdraw the funds of a single account from a foreclosed application", + Example: examples, + Args: cobra.ExactArgs(1), + Run: run, + Long: ` +Calls IApplication.withdraw(account, AccountValidityProof). The signer is just +the gas-payer; the recipient of the funds is encoded inside the 'account' +bytes per the application's WithdrawalOutputBuilder convention. The same +wallet that pays gas does NOT need to match (or own) the account being +withdrawn — they can be different. + +The [app-name-or-address] argument accepts EITHER an application name +(looked up in the local rollups-node database) OR an Ethereum address (used +directly without any DB access — useful on remote/reader hosts). + +The proof data is consumed verbatim from --proof-file (JSON). Suggested shape: + + { + "account": "0x... bytes ...", + "account_index": "0x...", + "account_root_siblings": ["0x...", "0x...", ...] + } + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID signer + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, +} + +const examples = `# Withdraw one account from a foreclosed application: +cartesi-rollups-cli withdraw echo-dapp --proof-file ./account-proof.json + +# Skip the confirmation prompt: +cartesi-rollups-cli withdraw echo-dapp --proof-file ./account-proof.json --yes` + +type withdrawProofJSON struct { + Account string `json:"account"` + AccountIndex string `json:"account_index"` + AccountRootSiblings []string `json:"account_root_siblings"` +} + +var ( + proofFileParam string + skipConfirmation bool + asJSONParam bool +) + +func init() { + Cmd.Flags().StringVar(&proofFileParam, "proof-file", "", + "Path to the JSON account proof file emitted by the proof generation tool") + 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") + + 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) + }) +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + account, proof, err := loadProof(proofFileParam) + cobra.CheckErr(err) + + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + cobra.CheckErr(err) + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) + client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + cobra.CheckErr(err) + + chainID, err := client.ChainID(ctx) + cobra.CheckErr(err) + txOpts, err := auth.GetTransactOpts(ctx, chainID) + cobra.CheckErr(err) + + appContract, err := iapplication.NewIApplication(appAddr, client) + cobra.CheckErr(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) + accountDesc, matched, err := ethutil.DescribeWithdrawalAccount(ctx, client, builderAddr, account) + cobra.CheckErr(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, + "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 !skipConfirmation || !matched { + fmt.Printf("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"+ + " account index: %d\n"+ + " proof siblings: %d\n", + appAddr, txOpts.From, builderAddr, + len(account), proof.AccountIndex, len(proof.AccountRootSiblings)) + if matched { + fmt.Println(accountDesc) + } + confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") + cobra.CheckErr(promptErr) + if !confirmed { + fmt.Println("Transaction cancelled") + os.Exit(0) + } + } + + 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()) + } + cobra.CheckErr(err) + txHash := tx.Hash() + + if asJSONParam { + result := struct { + TransactionHash string `json:"transaction_hash"` + 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) + } +} + +func loadProof(path string) ([]byte, iapplication.AccountValidityProof, error) { + zero := iapplication.AccountValidityProof{} + raw, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return nil, zero, fmt.Errorf("read proof file %s: %w", path, err) + } + var aux withdrawProofJSON + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&aux); err != nil { + return nil, zero, fmt.Errorf("parse proof file %s: %w", path, err) + } + + account, err := hexutil.Decode(aux.Account) + if err != nil { + return nil, zero, fmt.Errorf("invalid account: %w", err) + } + + idx, err := hexutil.DecodeUint64(aux.AccountIndex) + if err != nil { + return nil, zero, fmt.Errorf("invalid account_index: %w", err) + } + + siblings := make([][32]byte, len(aux.AccountRootSiblings)) + for i, s := range aux.AccountRootSiblings { + b, err := hexutil.Decode(s) + if err != nil { + return nil, zero, fmt.Errorf("invalid account_root_siblings[%d]: %w", i, err) + } + if len(b) != 32 { //nolint:mnd + return nil, zero, fmt.Errorf( + "account_root_siblings[%d] must be 32 bytes, got %d", i, len(b)) + } + copy(siblings[i][:], b) + } + return account, iapplication.AccountValidityProof{ + AccountIndex: idx, + AccountRootSiblings: siblings, + }, nil +} diff --git a/cmd/cartesi-rollups-cli/root/withdraw/withdraw_test.go b/cmd/cartesi-rollups-cli/root/withdraw/withdraw_test.go new file mode 100644 index 000000000..337eb5a82 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/withdraw/withdraw_test.go @@ -0,0 +1,140 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package withdraw + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// loadProof tests pin the JSON proof-file parser used by `withdraw`. The +// parser is the last sanity gate before a fund-moving tx is constructed — +// a malformed account or proof must abort with a clear error, never +// silently produce a self-consistent garbage proof. + +const validWithdrawProofJSON = `{ + "account": "0xaabbccdd", + "account_index": "0x7", + "account_root_siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002" + ] +}` + +func writeProofFile(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "proof.json") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func TestLoadProof_Valid(t *testing.T) { + path := writeProofFile(t, validWithdrawProofJSON) + account, proof, err := loadProof(path) + require.NoError(t, err) + require.Equal(t, []byte{0xaa, 0xbb, 0xcc, 0xdd}, account) + require.Equal(t, uint64(7), proof.AccountIndex) + require.Len(t, proof.AccountRootSiblings, 2) + require.Equal(t, byte(0x01), proof.AccountRootSiblings[0][31]) + require.Equal(t, byte(0x02), proof.AccountRootSiblings[1][31]) +} + +func TestLoadProof_FileNotFound(t *testing.T) { + _, _, err := loadProof("/nonexistent/path/proof.json") + require.Error(t, err) + require.Contains(t, err.Error(), "read proof file") +} + +func TestLoadProof_InvalidJSON(t *testing.T) { + path := writeProofFile(t, `{not valid json`) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse proof file") +} + +func TestLoadProof_UnknownField(t *testing.T) { + body := `{ + "account": "0x00", + "account_index": "0x0", + "account_root_siblings": [], + "extra_field": "this should not be accepted" + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse proof file") +} + +func TestLoadProof_BadAccountHex(t *testing.T) { + body := `{ + "account": "not-hex", + "account_index": "0x0", + "account_root_siblings": [] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid account") +} + +func TestLoadProof_BadAccountIndex(t *testing.T) { + body := `{ + "account": "0xaa", + "account_index": "not-hex", + "account_root_siblings": [] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid account_index") +} + +func TestLoadProof_SiblingWrongLength(t *testing.T) { + cases := []struct { + name string + hex string + }{ + {"31_bytes", "0x" + repeatHex("aa", 31)}, + {"33_bytes", "0x" + repeatHex("aa", 33)}, + {"empty", "0x"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{ + "account": "0x00", + "account_index": "0x0", + "account_root_siblings": ["` + tc.hex + `"] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "account_root_siblings") + require.Contains(t, err.Error(), "32 bytes") + }) + } +} + +func TestLoadProof_BadSiblingHex(t *testing.T) { + body := `{ + "account": "0x00", + "account_index": "0x0", + "account_root_siblings": ["not-hex"] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "account_root_siblings[0]") +} + +func repeatHex(b string, n int) string { + out := make([]byte, 0, n*len(b)) + for range n { + out = append(out, b...) + } + return string(out) +} diff --git a/cmd/cartesi-rollups-cli/util/util.go b/cmd/cartesi-rollups-cli/util/util.go index e3f74942a..e00f700b9 100644 --- a/cmd/cartesi-rollups-cli/util/util.go +++ b/cmd/cartesi-rollups-cli/util/util.go @@ -4,13 +4,58 @@ package util import ( + "context" + "fmt" "io" "os" "path" + "strings" "github.com/ethereum/go-ethereum/common" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/repository/factory" ) +// ResolveApplicationAddress returns the IApplication address corresponding +// to a name-or-address CLI argument. +// +// - If the input is a 0x-prefixed string, it is treated as an Ethereum +// address and returned directly. No DB connection is made. This lets the +// CLI operate against an application that is NOT registered in any local +// repository (remote use, ad-hoc inspection, foreclosure flow on a +// reader-only host). +// - Otherwise the input is treated as an application name and looked up +// in the local repository. A DB connection is required and an error is +// returned if the application is not found. +func ResolveApplicationAddress(ctx context.Context, nameOrAddress string) (common.Address, error) { + if strings.HasPrefix(nameOrAddress, "0x") || strings.HasPrefix(nameOrAddress, "0X") { + if !common.IsHexAddress(nameOrAddress) { + return common.Address{}, fmt.Errorf("invalid Ethereum address %q", nameOrAddress) + } + return common.HexToAddress(nameOrAddress), nil + } + dsn, err := config.GetDatabaseConnection() + if err != nil { + return common.Address{}, fmt.Errorf( + "resolving application %q by name requires the database; pass the application address (0x…) "+ + "instead to skip the local repository: %w", nameOrAddress, err) + } + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + if err != nil { + return common.Address{}, err + } + defer repo.Close() + app, err := repo.GetApplication(ctx, nameOrAddress) + if err != nil { + return common.Address{}, err + } + if app == nil { + return common.Address{}, fmt.Errorf("application %q not found in the database", nameOrAddress) + } + return app.IApplicationAddress, nil +} + // Reads the Cartesi Machine hash from machineDir. Returns it as a commonHash // or an error func ReadRootHash(machineDir string) (common.Hash, error) { diff --git a/cmd/cartesi-rollups-cli/util/util_test.go b/cmd/cartesi-rollups-cli/util/util_test.go new file mode 100644 index 000000000..bcd71a249 --- /dev/null +++ b/cmd/cartesi-rollups-cli/util/util_test.go @@ -0,0 +1,76 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package util + +import ( + "context" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// The 0x-bypass invariant is the whole point of allowing remote / reader-only +// hosts to use the foreclose / prove-drive-root / withdraw subcommands +// against an application that is NOT registered in any local repository. +// If a future change reorders the function so the database lookup happens +// before the prefix check, every one of those CLIs silently starts requiring +// CARTESI_DATABASE_CONNECTION. These tests pin the invariant by setting the +// DB env to something deliberately broken — a real DB lookup against this +// value would fail loudly, so a passing test means the 0x branch ran first. + +func TestResolveApplicationAddress_HexBypassesDB(t *testing.T) { + t.Setenv("CARTESI_DATABASE_CONNECTION", "postgres://nobody@nowhere:1/nodb") + t.Setenv("CARTESI_DATABASE_CONNECTION_FILE", "") + + addr := "0x1111111111111111111111111111111111111111" + got, err := ResolveApplicationAddress(context.Background(), addr) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(addr), got) +} + +func TestResolveApplicationAddress_UppercaseHexPrefixAlsoBypasses(t *testing.T) { + t.Setenv("CARTESI_DATABASE_CONNECTION", "postgres://nobody@nowhere:1/nodb") + t.Setenv("CARTESI_DATABASE_CONNECTION_FILE", "") + + addr := "0X2222222222222222222222222222222222222222" + got, err := ResolveApplicationAddress(context.Background(), addr) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(addr), got) +} + +func TestResolveApplicationAddress_InvalidHex(t *testing.T) { + t.Setenv("CARTESI_DATABASE_CONNECTION", "postgres://nobody@nowhere:1/nodb") + t.Setenv("CARTESI_DATABASE_CONNECTION_FILE", "") + + cases := []string{ + "0xnothex", + "0x123", // too short + "0x11111111111111111111111111111111111111111111", // too long + "0xZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", // non-hex chars + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + _, err := ResolveApplicationAddress(context.Background(), in) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid Ethereum address") + }) + } +} + +// When the caller passes a name and CARTESI_DATABASE_CONNECTION is not set, +// the error message must point the user at the 0x-bypass alternative — the +// CLI's documented escape hatch for remote / reader-only operation. +func TestResolveApplicationAddress_NameWithoutDBPointsAtBypass(t *testing.T) { + t.Setenv("CARTESI_DATABASE_CONNECTION", "") + t.Setenv("CARTESI_DATABASE_CONNECTION_FILE", "") + + _, err := ResolveApplicationAddress(context.Background(), "some-app-name") + require.Error(t, err) + msg := err.Error() + require.True(t, + strings.Contains(msg, "0x") && strings.Contains(msg, "address"), + "name-without-DB error must point at the 0x-bypass: got %q", msg) +} From b175eb7a8cb68db822dd498aeb57e4853df0fdc4 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:09:02 -0300 Subject: [PATCH 10/16] feat(evmreader): observe Foreclosure events --- internal/evmreader/accounts_drive_proved.go | 178 ++++++ .../evmreader/accounts_drive_proved_test.go | 339 +++++++++++ internal/evmreader/application_adapter.go | 190 +++++++ internal/evmreader/edge_cases_test.go | 3 + internal/evmreader/evmreader.go | 38 ++ internal/evmreader/foreclosure.go | 220 ++++++++ internal/evmreader/foreclosure_test.go | 524 ++++++++++++++++++ internal/evmreader/input.go | 42 +- internal/evmreader/mocks_test.go | 101 +++- internal/evmreader/output.go | 23 +- internal/evmreader/output_test.go | 5 + internal/evmreader/post_foreclosure.go | 61 ++ .../evmreader/post_foreclosure_withdrawal.go | 180 ++++++ .../post_foreclosure_withdrawal_test.go | 491 ++++++++++++++++ 14 files changed, 2379 insertions(+), 16 deletions(-) create mode 100644 internal/evmreader/accounts_drive_proved.go create mode 100644 internal/evmreader/accounts_drive_proved_test.go create mode 100644 internal/evmreader/foreclosure.go create mode 100644 internal/evmreader/foreclosure_test.go create mode 100644 internal/evmreader/post_foreclosure.go create mode 100644 internal/evmreader/post_foreclosure_withdrawal.go create mode 100644 internal/evmreader/post_foreclosure_withdrawal_test.go diff --git a/internal/evmreader/accounts_drive_proved.go b/internal/evmreader/accounts_drive_proved.go new file mode 100644 index 000000000..fdeca1968 --- /dev/null +++ b/internal/evmreader/accounts_drive_proved.go @@ -0,0 +1,178 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "math/big" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +// checkForDriveProved runs once per evmreader tick for each post-foreclosure +// app whose `accounts_drive_proved_block` is still zero. It first checks the +// one-way getAccountsDriveMerkleRoot().wasProved flag at mostRecent. If the +// flag is false, the scan cursor advances because no prove event exists up to +// that block. If the flag is true, it does a FilterLogs over +// `[max(foreclose_block, last_accounts_drive_proved_check_block+1), mostRecent]` +// for `AccountsDriveMerkleRootProved` events on the IApplication contract. +// +// The contract reverts on a second `proveAccountsDriveMerkleRoot` call +// (`AccountsDriveMerkleRootAlreadyProved`), so at most one event can fire per +// app over its lifetime. On the (at most one) event in the window: +// +// 1. Persist via +// UpdateAccountsDriveProved with the +// event's (block, txHash, root) and the scanner cursor. Idempotent on +// first observation. +// 2. Mirror the values onto app.application so this tick's downstream +// dispatcher (checkPostForeclosure) sees the drive-proved marker and +// routes the next tick to the withdrawal scan. +// +// If the on-chain flag says proved but the log scan returns no event, the +// cursor is left unchanged so a transient eth_call/eth_getLogs disagreement +// cannot skip the only prove event. +func (r *Service) checkForDriveProved( + ctx context.Context, + app appContracts, + mostRecentBlockNumber uint64, +) { + startBlock := app.application.LastAccountsDriveProvedCheckBlock + 1 + if floor := app.application.ForecloseBlock; startBlock < floor { + startBlock = floor + } + if startBlock > mostRecentBlockNumber { + // Cursor already past head (rare; e.g. defaultBlock policy drift). + // Nothing to scan; do not regress the cursor. + return + } + + proved, _, err := app.applicationContract.GetAccountsDriveMerkleRoot(&bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber), + }) + if err != nil { + if abortPostForeclosureLoop(r, err, "getAccountsDriveMerkleRoot") { + return + } + r.Logger.Error("Failed to query accounts drive proved state", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "block", mostRecentBlockNumber, + "error", err) + return + } + if !proved { + r.advanceLastAccountsDriveProvedCheckBlock(ctx, app.application.ID, mostRecentBlockNumber) + app.application.LastAccountsDriveProvedCheckBlock = mostRecentBlockNumber + return + } + + events, err := app.applicationContract.RetrieveAccountsDriveProvedEvents(&bind.FilterOpts{ + Context: ctx, + Start: startBlock, + End: &mostRecentBlockNumber, + }) + if err != nil { + if abortPostForeclosureLoop(r, err, "retrieveAccountsDriveProvedEvents") { + return + } + r.Logger.Error("Failed to scan accounts-drive-proved events", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber, + "error", err) + return + } + if len(events) == 0 { + r.Logger.Warn( + "getAccountsDriveMerkleRoot() is proved but no AccountsDriveMerkleRootProved event found; will retry same window", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber) + return + } + + // The contract caps lifetime emissions at one; defensively take the first + // if more than one slipped through. + ev := events[0] + if err := r.persistDriveProved(ctx, app, ev, mostRecentBlockNumber); err != nil { + return + } + app.application.LastAccountsDriveProvedCheckBlock = mostRecentBlockNumber +} + +// persistDriveProved writes the (block, txHash, root) tuple from the on-chain +// event and the scan cursor to the application row in one repository +// transaction, then mirrors the marker onto the in-memory model. Returning an +// error tells the caller to leave the in-memory cursor unchanged so the event +// can be retried on the next tick. +func (r *Service) persistDriveProved( + ctx context.Context, + app appContracts, + ev *iapplication.IApplicationAccountsDriveMerkleRootProved, + mostRecentBlockNumber uint64, +) error { + block := ev.Raw.BlockNumber + txHash := ev.Raw.TxHash + root := common.Hash(ev.AccountsDriveMerkleRoot) + + err := r.repository.UpdateAccountsDriveProved( + ctx, app.application.ID, block, txHash, root, mostRecentBlockNumber, + ) + if errors.Is(err, repository.ErrNotFound) { + // Row was deleted between the ListApplications scan and now. + // Skip the in-memory marker write — diverging from a row that no + // longer exists is worse than missing a marker. + r.Logger.Warn( + "Drive-prove observed but application row is missing; skipping", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "accounts_drive_proved_block", block, + ) + return nil + } + if err != nil { + r.Logger.Error("Failed to record accounts drive proved", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "accounts_drive_proved_block", block, + "error", err) + return err + } + + app.application.AccountsDriveProvedBlock = block + txHashCopy := txHash + rootCopy := root + app.application.AccountsDriveProvedTransaction = &txHashCopy + app.application.AccountsDriveMerkleRoot = &rootCopy + + r.Logger.Info("Accounts drive proved observed", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "accounts_drive_proved_block", block, + "accounts_drive_proved_transaction", txHash, + "accounts_drive_merkle_root", root, + ) + return nil +} + +// advanceLastAccountsDriveProvedCheckBlock persists the new cursor value +// and logs (does not surface) any DB error. A failed write is non-fatal: +// the next tick will re-scan the same window, paying the cost but producing +// correct behavior. +func (r *Service) advanceLastAccountsDriveProvedCheckBlock(ctx context.Context, appID int64, head uint64) { + if err := r.repository.UpdateApplicationLastAccountsDriveProvedCheckBlock(ctx, appID, head); err != nil { + r.Logger.Warn("Failed to advance last_accounts_drive_proved_check_block", + "application_id", appID, + "head", head, + "error", err) + } +} diff --git a/internal/evmreader/accounts_drive_proved_test.go b/internal/evmreader/accounts_drive_proved_test.go new file mode 100644 index 000000000..1a280d8f9 --- /dev/null +++ b/internal/evmreader/accounts_drive_proved_test.go @@ -0,0 +1,339 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "log/slog" + "math/big" + "os" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/service" + "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/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newPostForeclosureFixture builds the minimal Service surface needed by +// the post-foreclosure scans (drive-prove + withdrawal). +func newPostForeclosureFixture(t *testing.T) ( + *Service, *MockApplicationContract, *MockRepository, +) { + t.Helper() + repo := newMockRepository() + appContract := newMockApplicationContract() + s := &Service{ + repository: repo, + } + require.NoError(t, service.Create( + context.Background(), + &service.CreateInfo{Name: "evm-reader", Impl: s, Logger: slog.New(slog.NewTextHandler(os.Stdout, nil))}, + &s.Service, + )) + return s, appContract, repo +} + +// driveProvedTestApp builds a foreclosed Application with foreclose_block +// set to the given foreclose block; accounts_drive_proved_block is zero so +// the dispatcher routes here. +func driveProvedTestApp(id int64, forecloseBlock uint64) *Application { + return &Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(big.NewInt(id)), + State: ApplicationState_Enabled, + ForecloseBlock: forecloseBlock, + } +} + +// makeDriveProvedEvent builds a synthetic IApplicationAccountsDriveMerkleRootProved +// event with the given block / tx / root for stubbing +// RetrieveAccountsDriveProvedEvents. +func makeDriveProvedEvent( + block uint64, txHash common.Hash, root common.Hash, +) *iapplication.IApplicationAccountsDriveMerkleRootProved { + return &iapplication.IApplicationAccountsDriveMerkleRootProved{ + AccountsDriveMerkleRoot: [32]byte(root), + Raw: types.Log{ + BlockNumber: block, + TxHash: txHash, + }, + } +} + +// --------------------------------------------------------------------------- +// checkForDriveProved +// --------------------------------------------------------------------------- + +// TestCheckForDriveProved_NoEvent verifies the steady-state path: the on-chain +// proved flag is false, so no event scan or UpdateAccountsDriveProved call is +// made. The cursor still advances to mostRecent so the next tick scans only the +// new slice. +func TestCheckForDriveProved_NoEvent(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(false, common.Hash{}, nil).Once() + repo.On("UpdateApplicationLastAccountsDriveProvedCheckBlock", + mock.Anything, app.ID, head).Return(nil).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastAccountsDriveProvedCheckBlock, + "in-memory cursor mirrors the DB advance") + assert.Zero(t, app.AccountsDriveProvedBlock, + "AccountsDriveProvedBlock must remain zero when no event was observed") +} + +// TestCheckForDriveProved_PersistsAndMirrors walks the happy path: one +// AccountsDriveMerkleRootProved event in the window; the persist call +// receives the event's (block, txHash, root); the in-memory marker is +// mirrored so the next tick's dispatcher routes to the withdrawal scan. +func TestCheckForDriveProved_PersistsAndMirrors(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + const eventBlock = uint64(110) + txHash := common.HexToHash("0xcafe") + root := common.HexToHash("0xfeed") + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, root, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + // startBlock = max(foreclose_block=100, last_cursor+1=1) = 100. + return opts.Start == 100 && opts.End != nil && *opts.End == head + })).Return([]*iapplication.IApplicationAccountsDriveMerkleRootProved{ + makeDriveProvedEvent(eventBlock, txHash, root), + }, nil).Once() + + repo.On("UpdateAccountsDriveProved", + mock.Anything, app.ID, eventBlock, txHash, root, head, + ).Return(nil).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, eventBlock, app.AccountsDriveProvedBlock, + "in-memory AccountsDriveProvedBlock must mirror the DB write") + if assert.NotNil(t, app.AccountsDriveProvedTransaction) { + assert.Equal(t, txHash, *app.AccountsDriveProvedTransaction) + } + if assert.NotNil(t, app.AccountsDriveMerkleRoot) { + assert.Equal(t, root, *app.AccountsDriveMerkleRoot) + } + assert.Equal(t, head, app.LastAccountsDriveProvedCheckBlock) +} + +// TestCheckForDriveProved_TakesFirstWhenMultiple is defensive: the contract +// caps emissions at one (AccountsDriveMerkleRootAlreadyProved on a second +// call), but if FilterLogs ever returns more than one we must persist the +// first and ignore the rest rather than overwriting with the later event. +func TestCheckForDriveProved_TakesFirstWhenMultiple(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + firstTx := common.HexToHash("0xaaaa") + firstRoot := common.HexToHash("0x1111") + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, firstRoot, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.Anything).Return( + []*iapplication.IApplicationAccountsDriveMerkleRootProved{ + makeDriveProvedEvent(110, firstTx, firstRoot), + makeDriveProvedEvent(115, common.HexToHash("0xbbbb"), common.HexToHash("0x2222")), + }, nil, + ).Once() + + repo.On("UpdateAccountsDriveProved", + mock.Anything, app.ID, uint64(110), firstTx, firstRoot, head, + ).Return(nil).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + if assert.NotNil(t, app.AccountsDriveMerkleRoot) { + assert.Equal(t, firstRoot, *app.AccountsDriveMerkleRoot, + "in-memory marker must hold the FIRST event's data") + } +} + +// TestCheckForDriveProved_CursorRespectsForecloseBlockAsFloor pins the +// search-window lower bound. When LastAccountsDriveProvedCheckBlock is 0 +// and the foreclose block is mid-range, the scan must start at +// ForecloseBlock (not 1, not 0) — drive-prove cannot land before the +// foreclosure that gates it. If the proved state is true but the event is +// missing, the cursor remains unchanged so the window is retried. +func TestCheckForDriveProved_CursorRespectsForecloseBlockAsFloor(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 500) + const head = uint64(600) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, common.Hash{}, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 500 + })).Return([]*iapplication.IApplicationAccountsDriveMerkleRootProved{}, nil).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastAccountsDriveProvedCheckBlock) +} + +// TestCheckForDriveProved_SkipsWhenCursorPastHead verifies the +// short-circuit: a previous tick already advanced the cursor past the +// current head (defaultBlock policy drift, reorg recovery, etc.). The +// function must not issue any RetrieveAccountsDriveProvedEvents call and +// must not regress the cursor. +func TestCheckForDriveProved_SkipsWhenCursorPastHead(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + app.LastAccountsDriveProvedCheckBlock = 200 + const head = uint64(150) + + // No mock expectations — assertion is by negation. + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, uint64(200), app.LastAccountsDriveProvedCheckBlock, + "in-memory cursor must not regress when head < last cursor") +} + +// TestCheckForDriveProved_DoesNotAdvanceCursorOnQueryError verifies that when +// the FilterLogs call errors, the cursor remains unchanged so the next tick +// retries the same range instead of permanently skipping the event. +func TestCheckForDriveProved_DoesNotAdvanceCursorOnQueryError(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, common.Hash{}, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.Anything). + Return([]*iapplication.IApplicationAccountsDriveMerkleRootProved(nil), + errors.New("eth_getLogs failed")).Once() + + // No atomic drive-proved marker write — the scan errored before any persist + // could fire. + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastAccountsDriveProvedCheckBlock, + "scan failure keeps cursor unchanged for retry") +} + +// TestCheckForDriveProved_AbortsOnDeadlineExceeded verifies the +// context-error semantics: a DeadlineExceeded mid-scan must abort the +// loop with one ERROR log; the cursor must NOT advance (otherwise we'd +// silently mask a stuck tick by claiming progress). +func TestCheckForDriveProved_AbortsOnDeadlineExceeded(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(false, common.Hash{}, context.DeadlineExceeded).Once() + + // No cursor advance expected — abort path. + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastAccountsDriveProvedCheckBlock, + "DeadlineExceeded aborts before cursor advance") +} + +// TestCheckForDriveProved_ErrNotFoundSkipsInMemoryMarker verifies the +// row-deleted-between-scan-and-write path. The repository returns +// ErrNotFound for the atomic drive-proved marker write; the in-memory marker must +// NOT be written (writing it would diverge from a row that no longer +// exists). Subsequent ticks have nothing to repair because the row is +// gone. +func TestCheckForDriveProved_ErrNotFoundSkipsInMemoryMarker(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + const eventBlock = uint64(110) + txHash := common.HexToHash("0xcafe") + root := common.HexToHash("0xfeed") + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, root, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.Anything).Return( + []*iapplication.IApplicationAccountsDriveMerkleRootProved{ + makeDriveProvedEvent(eventBlock, txHash, root), + }, nil).Once() + repo.On("UpdateAccountsDriveProved", + mock.Anything, app.ID, eventBlock, txHash, root, head, + ).Return(repository.ErrNotFound).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.AccountsDriveProvedBlock, + "ErrNotFound must not set the in-memory marker — row is gone") + assert.Nil(t, app.AccountsDriveProvedTransaction) + assert.Nil(t, app.AccountsDriveMerkleRoot) +} + +// TestCheckForDriveProved_DoesNotAdvanceCursorOnPersistError verifies that a +// failed write of the observed event leaves the scan cursor unchanged. This +// prevents the node from moving past the only window where the event was seen. +func TestCheckForDriveProved_DoesNotAdvanceCursorOnPersistError(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + const eventBlock = uint64(110) + txHash := common.HexToHash("0xcafe") + root := common.HexToHash("0xfeed") + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, root, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.Anything).Return( + []*iapplication.IApplicationAccountsDriveMerkleRootProved{ + makeDriveProvedEvent(eventBlock, txHash, root), + }, nil).Once() + repo.On("UpdateAccountsDriveProved", + mock.Anything, app.ID, eventBlock, txHash, root, head, + ).Return(errors.New("db unavailable")).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.AccountsDriveProvedBlock) + assert.Zero(t, app.LastAccountsDriveProvedCheckBlock, + "persist failure keeps cursor unchanged for retry") +} diff --git a/internal/evmreader/application_adapter.go b/internal/evmreader/application_adapter.go index b4d0649c9..8d38b3935 100644 --- a/internal/evmreader/application_adapter.go +++ b/internal/evmreader/application_adapter.go @@ -21,8 +21,20 @@ type ApplicationContractAdapter interface { RetrieveOutputExecutionEvents( opts *bind.FilterOpts, ) ([]*iapplication.IApplicationOutputExecuted, error) + RetrieveForeclosureEvents( + opts *bind.FilterOpts, + ) ([]*iapplication.IApplicationForeclosure, error) + RetrieveWithdrawalEvents( + opts *bind.FilterOpts, + ) ([]*iapplication.IApplicationWithdrawal, error) + RetrieveAccountsDriveProvedEvents( + opts *bind.FilterOpts, + ) ([]*iapplication.IApplicationAccountsDriveMerkleRootProved, error) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) + GetAccountsDriveMerkleRoot(opts *bind.CallOpts) (bool, common.Hash, error) GetNumberOfExecutedOutputs(opts *bind.CallOpts) (*big.Int, error) + GetNumberOfWithdrawals(opts *bind.CallOpts) (*big.Int, error) + IsForeclosed(opts *bind.CallOpts) (bool, error) } // IApplication Wrapper @@ -108,6 +120,184 @@ func (a *ApplicationContractAdapterImpl) GetDeploymentBlockNumber(opts *bind.Cal return a.application.GetDeploymentBlockNumber(opts) } +func (a *ApplicationContractAdapterImpl) GetAccountsDriveMerkleRoot(opts *bind.CallOpts) (bool, common.Hash, error) { + result, err := a.application.GetAccountsDriveMerkleRoot(opts) + if err != nil { + return false, common.Hash{}, err + } + return result.WasAccountsDriveMerkleRootProved, common.Hash(result.AccountsDriveMerkleRoot), nil +} + func (a *ApplicationContractAdapterImpl) GetNumberOfExecutedOutputs(opts *bind.CallOpts) (*big.Int, error) { return a.application.GetNumberOfExecutedOutputs(opts) } + +func (a *ApplicationContractAdapterImpl) IsForeclosed(opts *bind.CallOpts) (bool, error) { + return a.application.IsForeclosed(opts) +} + +func (a *ApplicationContractAdapterImpl) GetNumberOfWithdrawals(opts *bind.CallOpts) (*big.Int, error) { + return a.application.GetNumberOfWithdrawals(opts) +} + +func buildForeclosureFilterQuery( + opts *bind.FilterOpts, + applicationAddress common.Address, +) (q ethereum.FilterQuery, err error) { + c, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return q, err + } + + topics, err := abi.MakeTopics( + []any{c.Events["Foreclosure"].ID}, + ) + if err != nil { + return q, err + } + + q = ethereum.FilterQuery{ + Addresses: []common.Address{applicationAddress}, + FromBlock: new(big.Int).SetUint64(opts.Start), + Topics: topics, + } + if opts.End != nil { + q.ToBlock = new(big.Int).SetUint64(*opts.End) + } + return q, err +} + +func (a *ApplicationContractAdapterImpl) RetrieveForeclosureEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationForeclosure, error) { + q, err := buildForeclosureFilterQuery(opts, a.applicationAddress) + if err != nil { + return nil, err + } + + itr, err := a.filter.ChunkedFilterLogs(opts.Context, a.client, q) + if err != nil { + return nil, err + } + + var events []*iapplication.IApplicationForeclosure + for log, err := range itr { + if err != nil { + return nil, err + } + ev, err := a.application.ParseForeclosure(*log) + if err != nil { + return nil, err + } + events = append(events, ev) + } + return events, nil +} + +func buildWithdrawalFilterQuery( + opts *bind.FilterOpts, + applicationAddress common.Address, +) (q ethereum.FilterQuery, err error) { + c, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return q, err + } + + topics, err := abi.MakeTopics( + []any{c.Events["Withdrawal"].ID}, + ) + if err != nil { + return q, err + } + + q = ethereum.FilterQuery{ + Addresses: []common.Address{applicationAddress}, + FromBlock: new(big.Int).SetUint64(opts.Start), + Topics: topics, + } + if opts.End != nil { + q.ToBlock = new(big.Int).SetUint64(*opts.End) + } + return q, err +} + +func (a *ApplicationContractAdapterImpl) RetrieveWithdrawalEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationWithdrawal, error) { + q, err := buildWithdrawalFilterQuery(opts, a.applicationAddress) + if err != nil { + return nil, err + } + + itr, err := a.filter.ChunkedFilterLogs(opts.Context, a.client, q) + if err != nil { + return nil, err + } + + var events []*iapplication.IApplicationWithdrawal + for log, err := range itr { + if err != nil { + return nil, err + } + ev, err := a.application.ParseWithdrawal(*log) + if err != nil { + return nil, err + } + events = append(events, ev) + } + return events, nil +} + +func buildAccountsDriveProvedFilterQuery( + opts *bind.FilterOpts, + applicationAddress common.Address, +) (q ethereum.FilterQuery, err error) { + c, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return q, err + } + + topics, err := abi.MakeTopics( + []any{c.Events["AccountsDriveMerkleRootProved"].ID}, + ) + if err != nil { + return q, err + } + + q = ethereum.FilterQuery{ + Addresses: []common.Address{applicationAddress}, + FromBlock: new(big.Int).SetUint64(opts.Start), + Topics: topics, + } + if opts.End != nil { + q.ToBlock = new(big.Int).SetUint64(*opts.End) + } + return q, err +} + +func (a *ApplicationContractAdapterImpl) RetrieveAccountsDriveProvedEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationAccountsDriveMerkleRootProved, error) { + q, err := buildAccountsDriveProvedFilterQuery(opts, a.applicationAddress) + if err != nil { + return nil, err + } + + itr, err := a.filter.ChunkedFilterLogs(opts.Context, a.client, q) + if err != nil { + return nil, err + } + + var events []*iapplication.IApplicationAccountsDriveMerkleRootProved + for log, err := range itr { + if err != nil { + return nil, err + } + ev, err := a.application.ParseAccountsDriveMerkleRootProved(*log) + if err != nil { + return nil, err + } + events = append(events, ev) + } + return events, nil +} diff --git a/internal/evmreader/edge_cases_test.go b/internal/evmreader/edge_cases_test.go index 4c4b4c3ee..cc81e9d94 100644 --- a/internal/evmreader/edge_cases_test.go +++ b/internal/evmreader/edge_cases_test.go @@ -353,6 +353,9 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { // Catch-all for sentinel header repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). Return([]*Application{}, uint64(0), nil) + repo.On("UpdateApplicationLastForecloseCheckBlock", + mock.Anything, int64(1), mock.Anything, + ).Return(nil).Maybe() s.evmReader.repository = repo factory := newMockAdapterFactory() diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index 6b4815ce2..d50b29050 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -24,6 +24,30 @@ import ( // Interface for the node repository type EvmReaderRepository interface { + UpdateApplicationForeclosure( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + blockNumber uint64, + ) error + UpdateApplicationLastForecloseCheckBlock(ctx context.Context, appID int64, blockNumber uint64) error + UpdateAccountsDriveProved( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + root common.Hash, + blockNumber uint64, + ) error + UpdateApplicationLastAccountsDriveProvedCheckBlock(ctx context.Context, appID int64, blockNumber uint64) error + StoreWithdrawalEvents( + ctx context.Context, + appID int64, + withdrawals []*Withdrawal, + 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) UpdateApplicationState(ctx context.Context, appID int64, state ApplicationState, reason *string) error UpdateEventLastCheckBlock(ctx context.Context, appIDs []int64, event MonitoredEvent, blockNumber uint64) error @@ -313,11 +337,25 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) header.Number.Uint64(), r.defaultBlock)) } + // Detect foreclosure first so subsequent checks observe the marker. + // checkForForeclosure internally skips apps whose foreclose_block is + // already non-zero; the other pre-foreclosure scans behave similarly + // (no new inputs / epochs / output-execution after foreclosure). + r.checkForForeclosure(ctx, apps, blockNumber) + r.checkForEpochsAndInputs(ctx, daveConsensusApps, blockNumber) r.checkForNewInputs(ctx, iconsensusApps, blockNumber) r.checkForOutputExecution(ctx, apps, blockNumber) + + // Post-foreclosure observation. For each app with foreclose_block + // non-zero, dispatches to drive-prove discovery (while + // accounts_drive_proved_block == 0) or withdrawal indexing (once + // the drive has been proved). Apps without a recorded foreclosure + // are skipped inside the function. See accounts_drive_proved.go + // and post_foreclosure_withdrawal.go. + r.checkPostForeclosure(ctx, apps, blockNumber) } } diff --git a/internal/evmreader/foreclosure.go b/internal/evmreader/foreclosure.go new file mode 100644 index 000000000..2f9f373f2 --- /dev/null +++ b/internal/evmreader/foreclosure.go @@ -0,0 +1,220 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +// checkForForeclosure runs once per evmreader tick. For each app with a +// zero foreclose_block (the unset sentinel), it polls isForeclosed() +// (cheap, one CallContract). +// On a true result, it filters Foreclosure events over the window +// `[max(deploymentBlock, last_foreclose_check_block+1), mostRecentBlockNumber]` +// and persists (block, txHash) of the first match to the application row. +// +// If isForeclosed() is false, no event scan is needed and the cursor advances +// to mostRecentBlockNumber because the state query proves the app was not +// foreclosed up to that block. If isForeclosed() is true but no matching event +// is found, the scan cursor is left unchanged so the next tick retries the +// same window; advancing would permanently exclude the only block range where +// the event can be found. +// +// Once foreclose_block is non-zero, the app is skipped on subsequent ticks — +// the flag is one-way (the contract has no un-foreclose). +func (r *Service) checkForForeclosure( + ctx context.Context, + apps []appContracts, + mostRecentBlockNumber uint64, +) { + for _, app := range apps { + if app.application.ForecloseBlock != 0 { + continue + } + foreclosed, err := app.applicationContract.IsForeclosed( + &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber), + }, + ) + if err != nil { + if abortForeclosureLoop(r, err, "isForeclosed") { + return + } + r.Logger.Error("Failed to query isForeclosed", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err) + continue + } + if !foreclosed { + if app.application.LastForecloseCheckBlock < mostRecentBlockNumber { + r.advanceLastForecloseCheckBlock(ctx, app.application.ID, mostRecentBlockNumber) + app.application.LastForecloseCheckBlock = mostRecentBlockNumber + } + continue + } + + // On-chain says the app is foreclosed but we don't yet know which + // block emitted Foreclosure(). Determine the lower bound of the + // filter window. Once LastForecloseCheckBlock is non-zero we've + // scanned through deployment already, so we never need to read it + // again for the lifetime of this wait state. + startBlock := app.application.LastForecloseCheckBlock + 1 + if app.application.LastForecloseCheckBlock == 0 { + deploymentBlock, err := r.foreclosureSearchFloor(ctx, &app, mostRecentBlockNumber) + if err != nil { + if abortForeclosureLoop(r, err, "getDeploymentBlock") { + return + } + r.Logger.Error("Failed to compute Foreclosure search start block", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err) + continue + } + startBlock = deploymentBlock + } + if startBlock > mostRecentBlockNumber { + // Already scanned past the current head; nothing new since the + // previous tick. Re-check isForeclosed next tick. + continue + } + + events, err := app.applicationContract.RetrieveForeclosureEvents( + &bind.FilterOpts{ + Context: ctx, + Start: startBlock, + End: &mostRecentBlockNumber, + }, + ) + if err != nil { + if abortForeclosureLoop(r, err, "retrieveForeclosureEvents") { + return + } + r.Logger.Error("Failed to fetch Foreclosure events", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err) + continue + } + if len(events) == 0 { + r.Logger.Warn( + "isForeclosed() is true but no Foreclosure event found in search window — will retry same window next tick", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber) + continue + } + // `Foreclosure()` is one-way; multiple events on the same app are + // not possible. Use the first. + ev := events[0] + block := ev.Raw.BlockNumber + txHash := ev.Raw.TxHash + + err = r.repository.UpdateApplicationForeclosure( + ctx, app.application.ID, block, txHash, mostRecentBlockNumber, + ) + if errors.Is(err, repository.ErrNotFound) { + // Row was deleted between the ListApplications scan at the top + // of the tick and now. Skip without touching the in-memory + // marker — writing it would diverge from a row that no longer + // exists. + r.Logger.Warn( + "Foreclosure observed but application row is missing; skipping", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "foreclose_block", block, + "foreclose_transaction", txHash) + continue + } + if err != nil { + r.Logger.Error("Failed to record foreclosure", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "foreclose_block", block, + "foreclose_transaction", txHash, + "error", err) + continue + } + // Reflect the write in the in-memory app so other code paths in + // this tick see the marker. Safe both for "we wrote" and the + // idempotent "already foreclosed" case: the Foreclosure() event + // is one-way so all observers see the same (block, txHash). + app.application.ForecloseBlock = block + txHashCopy := txHash + app.application.ForecloseTransaction = &txHashCopy + app.application.LastForecloseCheckBlock = mostRecentBlockNumber + r.Logger.Info("Application foreclosure observed", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "foreclose_block", block, + "foreclose_transaction", txHash) + } +} + +// advanceLastForecloseCheckBlock persists the new value and logs (does not +// surface) any DB error. A failed write is non-fatal: the next tick will +// re-scan the same window, paying the cost but producing correct behavior. +func (r *Service) advanceLastForecloseCheckBlock(ctx context.Context, appID int64, head uint64) { + if err := r.repository.UpdateApplicationLastForecloseCheckBlock(ctx, appID, head); err != nil { + r.Logger.Warn("Failed to advance last_foreclose_check_block", + "application_id", appID, + "head", head, + "error", err) + } +} + +// abortForeclosureLoop reports whether the RPC error should abort the +// per-tick loop entirely. Context cancellation is graceful shutdown — +// silent return. A deadline-exceeded error means the tick's budget is +// gone; every remaining app's RPC call would fail with the same error, +// producing one ERROR log per app for no operational benefit. Log once +// at the site and abort. Other errors stay per-app so a transient RPC +// failure on one app does not block the rest. Mirrors the convention +// documented at memory/feedback_context_error_semantics.md. +func abortForeclosureLoop(r *Service, err error, where string) bool { + if errors.Is(err, context.Canceled) { + return true + } + if errors.Is(err, context.DeadlineExceeded) { + r.Logger.Error("Foreclosure scan deadline exceeded; aborting remaining apps", + "site", where, "error", err) + return true + } + return false +} + +// foreclosureSearchFloor reads the application's on-chain deployment block. +// Called only on the first tick of the wait state (LastForecloseCheckBlock +// == 0); once it advances past zero, the deployment block is irrelevant to +// subsequent ticks. +func (r *Service) foreclosureSearchFloor( + ctx context.Context, + app *appContracts, + mostRecentBlockNumber uint64, +) (uint64, error) { + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber), + } + deploymentBlock, err := app.applicationContract.GetDeploymentBlockNumber(callOpts) + if err != nil { + return 0, fmt.Errorf("get deployment block: %w", err) + } + // Zero is accepted: anvil / genesis-snapshot fixtures can legitimately + // place contract code at block 0, and a zero floor only widens the scan + // window (a performance hit bounded by last_foreclose_check_block on + // the next tick, not a correctness break). The original guard rejected + // zero defensively but produced a hard error on otherwise-valid fixtures. + // A negative value is impossible from a uint256 return. + return deploymentBlock.Uint64(), nil +} diff --git a/internal/evmreader/foreclosure_test.go b/internal/evmreader/foreclosure_test.go new file mode 100644 index 000000000..2364cdf0c --- /dev/null +++ b/internal/evmreader/foreclosure_test.go @@ -0,0 +1,524 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "log/slog" + "math/big" + "os" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/service" + "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/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newForeclosureServiceFixture builds the smallest Service surface that +// checkForForeclosure / foreclosureSearchStartBlock reach for, plus the +// mocks bound to it. This avoids the full EvmReaderSuite bootstrap which +// wires up websocket clients, adapter factories, and tick-loop plumbing +// none of which are exercised by these unit tests. +// +// newMockApplicationContract pre-registers .Maybe() stubs for IsForeclosed +// and RetrieveForeclosureEvents (FIFO match). For foreclosure-path tests +// those defaults must be cleared so the per-test .On(...) expectations +// match — see the doc comment on newMockApplicationContract. +func newForeclosureServiceFixture(t *testing.T) ( + *Service, *MockApplicationContract, *MockRepository, +) { + t.Helper() + repo := newMockRepository() + appContract := newMockApplicationContract() + appContract.Unset("IsForeclosed") + appContract.Unset("RetrieveForeclosureEvents") + s := &Service{ + repository: repo, + } + require.NoError(t, service.Create( + context.Background(), + &service.CreateInfo{Name: "evm-reader", Impl: s, Logger: slog.New(slog.NewTextHandler(os.Stdout, nil))}, + &s.Service, + )) + return s, appContract, repo +} + +// foreclosureAppContracts wraps an Application with the per-app contract +// adapter that checkForForeclosure consults. +func foreclosureAppContracts(app *Application, c *MockApplicationContract) appContracts { + return appContracts{ + application: app, + applicationContract: c, + } +} + +// makeForeclosureEvent constructs a Foreclosure event with the given block +// and tx hash on the Raw log. The Foreclosure event body itself carries no +// fields (see ABI); only Raw.BlockNumber / Raw.TxHash are read by the +// observer. +func makeForeclosureEvent(block uint64, txHash common.Hash) *iapplication.IApplicationForeclosure { + return &iapplication.IApplicationForeclosure{ + Raw: types.Log{BlockNumber: block, TxHash: txHash}, + } +} + +// foreclosureTestApp builds an Application whose ForecloseBlock is zero +// (the "not yet observed" state). Unique address and ID keep mock +// assertions specific. +func foreclosureTestApp(id int64) *Application { + return &Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(big.NewInt(id)), + State: ApplicationState_Enabled, + } +} + +// --------------------------------------------------------------------------- +// checkForForeclosure +// --------------------------------------------------------------------------- + +// TestCheckForForeclosure_SkipsWhenAlreadyRecorded verifies that the +// in-memory ForecloseBlock guard short-circuits the function: no on-chain +// reads, no DB write. This is the steady state for every foreclosed app +// after its first observation tick. +func TestCheckForForeclosure_SkipsWhenAlreadyRecorded(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + app.ForecloseBlock = 50 + + // No mock expectations — any IsForeclosed / RetrieveForeclosureEvents / + // UpdateApplicationForeclosure call would fail + // testify's assertion. + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) +} + +// TestCheckForForeclosure_SkipsWhenNotForeclosed verifies the common-case +// path: isForeclosed returns false, the function advances the cursor without +// filtering events. +func TestCheckForForeclosure_SkipsWhenNotForeclosed(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const head = uint64(100) + c.On("IsForeclosed", mock.Anything).Return(false, nil).Once() + repo.On("UpdateApplicationLastForecloseCheckBlock", + mock.Anything, app.ID, head).Return(nil).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head) + + assert.Zero(t, app.ForecloseBlock, "ForecloseBlock must remain zero") + assert.Equal(t, head, app.LastForecloseCheckBlock) +} + +// TestCheckForForeclosure_PersistsOnFirstObservation walks the happy path: +// isForeclosed=true, deployment block resolves, exactly one Foreclosure +// event is returned, the repository persists the (block, txHash) pair and +// cursor atomically, and the in-memory ForecloseBlock / ForecloseTransaction +// are populated so other code paths in this tick see the marker. +func TestCheckForForeclosure_PersistsOnFirstObservation(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const head = uint64(100) + const evBlock = uint64(80) + txHash := common.HexToHash("0xfeed") + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", + mock.Anything, + ).Return([]*iapplication.IApplicationForeclosure{ + makeForeclosureEvent(evBlock, txHash), + }, nil).Once() + repo.On("UpdateApplicationForeclosure", + mock.Anything, app.ID, evBlock, txHash, head, + ).Return(nil).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head) + + assert.Equal(t, evBlock, app.ForecloseBlock, + "in-memory ForecloseBlock must be set so this tick's downstream "+ + "code sees the marker without re-reading the DB") + if assert.NotNil(t, app.ForecloseTransaction) { + assert.Equal(t, txHash, *app.ForecloseTransaction) + } + assert.Equal(t, head, app.LastForecloseCheckBlock) +} + +// TestCheckForForeclosure_DoesNotAdvanceCursorWhenEventNotFound exercises an +// inconsistent RPC/log view where isForeclosed() is true but the matching +// Foreclosure log is absent from the search window. The function must leave +// both foreclose_block and last_foreclose_check_block unchanged so the next +// tick retries the same window. +func TestCheckForForeclosure_DoesNotAdvanceCursorWhenEventNotFound(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const head = uint64(100) + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{}, nil).Once() + // No atomic foreclosure marker write — the absence is the assertion. + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head) + + assert.Zero(t, app.ForecloseBlock, + "ForecloseBlock must remain zero so the next tick re-scans") + assert.Zero(t, app.LastForecloseCheckBlock, + "LastForecloseCheckBlock must remain unchanged so the same window is retried") +} + +// TestCheckForForeclosure_SkipsAppOnIsForeclosedError verifies the per-app +// failure isolation: a transient RPC failure on one app must not prevent +// other apps in the same tick from being checked. Tested here by ensuring +// IsForeclosed-error leaves ForecloseBlock unset, with no DB write. +func TestCheckForForeclosure_SkipsAppOnIsForeclosedError(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + c.On("IsForeclosed", mock.Anything).Return(false, errors.New("rpc dial")).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Zero(t, app.ForecloseBlock) +} + +// TestCheckForForeclosure_SkipsAppOnRetrieveError verifies the same +// isolation property for the event-filter call. +func TestCheckForForeclosure_SkipsAppOnRetrieveError(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{}, errors.New("eth_getLogs failed")).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Zero(t, app.ForecloseBlock) +} + +// TestCheckForForeclosure_SkipsAppOnPersistError verifies the DB-error +// branch. The in-memory marker must NOT be set when the persist failed — +// otherwise the next tick would read a zero DB column but a non-zero +// in-memory marker, racing with restarts that drop the in-memory state. +func TestCheckForForeclosure_SkipsAppOnPersistError(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const evBlock = uint64(80) + txHash := common.HexToHash("0xfeed") + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(evBlock, txHash)}, nil).Once() + repo.On("UpdateApplicationForeclosure", + mock.Anything, app.ID, evBlock, txHash, uint64(100), + ).Return(errors.New("db deadlock")).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Zero(t, app.ForecloseBlock, + "in-memory marker must not run ahead of the DB on persist failure") +} + +// TestCheckForForeclosure_StopsOnContextCanceled verifies the early-exit +// on shutdown. IsForeclosed and RetrieveForeclosureEvents both check for +// context.Canceled and return immediately to avoid log-spam during the +// orchestrator's coordinated stop. +func TestCheckForForeclosure_StopsOnContextCanceled(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + c.On("IsForeclosed", mock.Anything).Return(false, context.Canceled).Once() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.checkForForeclosure(ctx, []appContracts{foreclosureAppContracts(app, c)}, 100) +} + +// TestCheckForForeclosure_AbortsLoopOnDeadlineExceeded verifies the +// deadline-exceeded short-circuit. Once the tick's context is past +// deadline every subsequent IsForeclosed call would fail the same way; +// surfacing one ERROR per app is wasted noise. The fix logs once at the +// site and aborts the loop, leaving recovery to the next tick — distinct +// from context.Canceled (silent) and other RPC errors (per-app log + +// continue), per the project's context-error-semantics convention. +func TestCheckForForeclosure_AbortsLoopOnDeadlineExceeded(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app1 := foreclosureTestApp(1) + app2 := foreclosureTestApp(2) + + // Only app1's IsForeclosed call is expected. If the loop kept going, + // app2's call would fail testify's "unexpected call" assertion — + // that is the assertion this test relies on. + c.On("IsForeclosed", mock.Anything).Return(false, context.DeadlineExceeded).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app1, c), foreclosureAppContracts(app2, c)}, 100) +} + +// TestCheckForForeclosure_AbortsOnDeadlineExceededAtRetrieve mirrors the +// previous test for the second blocking RPC: even after IsForeclosed +// succeeds, a deadline during RetrieveForeclosureEvents on the first app +// must abort the loop rather than continuing to the second app. +func TestCheckForForeclosure_AbortsOnDeadlineExceededAtRetrieve(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app1 := foreclosureTestApp(1) + app2 := foreclosureTestApp(2) + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{}, context.DeadlineExceeded).Once() + // No expectations registered for app2 — an unexpected call fails the test. + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app1, c), foreclosureAppContracts(app2, c)}, 100) +} + +// TestCheckForForeclosure_SkipsInMemoryMarkerOnErrNotFound verifies the +// ErrNotFound branch on the atomic foreclosure write. The row was deleted +// between the tick's ListApplications scan and this write; the caller +// must NOT populate app.ForecloseBlock / app.ForecloseTransaction +// because doing so would diverge from a DB row that no longer exists. +func TestCheckForForeclosure_SkipsInMemoryMarkerOnErrNotFound(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const evBlock = uint64(80) + txHash := common.HexToHash("0xfeed") + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(evBlock, txHash)}, nil).Once() + repo.On("UpdateApplicationForeclosure", + mock.Anything, app.ID, evBlock, txHash, uint64(100), + ).Return(repository.ErrNotFound).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Zero(t, app.ForecloseBlock, + "ErrNotFound means the row is gone — in-memory marker must not be set") + assert.Nil(t, app.ForecloseTransaction) +} + +// TestCheckForForeclosure_SetsInMemoryMarkerOnIdempotentNil verifies the +// idempotent path: when the atomic foreclosure write returns nil for the +// "already foreclosed" case, the in-memory marker IS populated. The +// Foreclosure() event is one-way on chain so every observer derives the +// same (block, txHash); writing the marker is safe and lets other code +// paths in this tick see the foreclosure. +func TestCheckForForeclosure_SetsInMemoryMarkerOnIdempotentNil(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const evBlock = uint64(80) + txHash := common.HexToHash("0xfeed") + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(evBlock, txHash)}, nil).Once() + // nil for the idempotent "already foreclosed" path. The repository + // contract distinguishes this from ErrNotFound. + repo.On("UpdateApplicationForeclosure", + mock.Anything, app.ID, evBlock, txHash, uint64(100), + ).Return(nil).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Equal(t, evBlock, app.ForecloseBlock) + if assert.NotNil(t, app.ForecloseTransaction) { + assert.Equal(t, txHash, *app.ForecloseTransaction) + } +} + +// --------------------------------------------------------------------------- +// foreclosureSearchFloor +// --------------------------------------------------------------------------- + +// TestForeclosureSearchFloor_ReturnsDeploymentBlock verifies the happy +// path: a positive deployment block is returned for the lower bound of +// the very first scan window. +func TestForeclosureSearchFloor_ReturnsDeploymentBlock(t *testing.T) { + s, c, _ := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + + app := foreclosureTestApp(1) + ac := foreclosureAppContracts(app, c) + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(123), nil).Once() + + got, err := s.foreclosureSearchFloor(context.Background(), &ac, 200) + require.NoError(t, err) + assert.Equal(t, uint64(123), got) +} + +// TestForeclosureSearchFloor_AcceptsDeploymentBlockZero verifies that a +// zero deployment block is accepted: anvil / genesis-snapshot fixtures can +// legitimately place contract code at block 0, and the previous defensive +// reject tripped on otherwise-valid devnet runs. A zero floor only widens +// the scan window; last_foreclose_check_block bounds it on the next tick. +func TestForeclosureSearchFloor_AcceptsDeploymentBlockZero(t *testing.T) { + s, c, _ := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + + app := foreclosureTestApp(1) + ac := foreclosureAppContracts(app, c) + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(0), nil).Once() + + got, err := s.foreclosureSearchFloor(context.Background(), &ac, 200) + require.NoError(t, err) + assert.Equal(t, uint64(0), got) +} + +// TestForeclosureSearchFloor_PropagatesRPCError verifies that the +// underlying RPC failure surfaces verbatim so the caller can log it with +// the right context. +func TestForeclosureSearchFloor_PropagatesRPCError(t *testing.T) { + s, c, _ := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + + app := foreclosureTestApp(1) + ac := foreclosureAppContracts(app, c) + rpcErr := errors.New("eth_call timeout") + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int), rpcErr).Once() + + _, err := s.foreclosureSearchFloor(context.Background(), &ac, 200) + require.Error(t, err) + assert.ErrorIs(t, err, rpcErr) +} + +// --------------------------------------------------------------------------- +// LastForecloseCheckBlock advancement +// --------------------------------------------------------------------------- + +// TestCheckForForeclosure_RetriesSameWindowWhenEventMissing pins the +// correctness contract: if isForeclosed() is true but no Foreclosure log is +// found, the scan cursor must not advance. The second tick therefore scans +// from the original deployment floor again, extending only the upper bound. +func TestCheckForForeclosure_RetriesSameWindowWhenEventMissing(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const ( + head1 = uint64(100) + head2 = uint64(110) + ) + + // First tick: LastForecloseCheckBlock==0, so the deployment block is read. + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 10 && opts.End != nil && *opts.End == head1 + }), + ).Return([]*iapplication.IApplicationForeclosure{}, nil).Once() + + // Second tick: LastForecloseCheckBlock is still zero, so the deployment + // floor is read again and the scan retries [deployment, newHead]. + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 10 && opts.End != nil && *opts.End == head2 + }), + ).Return([]*iapplication.IApplicationForeclosure{}, nil).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head1) + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head2) + + assert.Zero(t, app.LastForecloseCheckBlock) +} + +// TestCheckForForeclosure_SkipsWhenAlreadyScannedPastHead verifies the +// short-circuit: if a previous tick already advanced +// LastForecloseCheckBlock past the current head (e.g. defaultBlock +// policy temporarily falls back), the function must not issue any +// RetrieveForeclosureEvents call and must not read the deployment block +// either (LastForecloseCheckBlock > 0 alone satisfies the lower-bound +// check). +func TestCheckForForeclosure_SkipsWhenAlreadyScannedPastHead(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + app.LastForecloseCheckBlock = 200 + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + // No GetDeploymentBlockNumber, no RetrieveForeclosureEvents, no + // last_foreclose_check_block update — the short-circuit is the + // assertion. + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 150) + + assert.Equal(t, uint64(200), app.LastForecloseCheckBlock, + "last_foreclose_check_block must not regress when head < last block") +} diff --git a/internal/evmreader/input.go b/internal/evmreader/input.go index bf3606c21..6f4c49a77 100644 --- a/internal/evmreader/input.go +++ b/internal/evmreader/input.go @@ -270,11 +270,22 @@ func (r *Service) readAndStoreInputs( // Retrieves last open epoch from DB currentEpoch, err := r.repository.GetEpoch(ctx, address.String(), calculateEpochIndex(epochLength, lastProcessedBlock)) if err != nil { - r.Logger.Error("Error retrieving existing current epoch", - "application", app.application.Name, - "address", address, - "error", err, - ) + // 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, + ) + } continue } @@ -359,11 +370,22 @@ func (r *Service) readAndStoreInputs( if len(appsToUpdate) > 0 { err := r.repository.UpdateEventLastCheckBlock(ctx, appsToUpdate, MonitoredEvent_InputAdded, mostRecentBlockNumber) if err != nil { - r.Logger.Error("Failed to update LastInputCheckBlock for applications without inputs", - "app_ids", appsToUpdate, - "block_number", mostRecentBlockNumber, - "error", err, - ) + // 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, + ) + } // We don't return an error here as we've already processed the inputs // and this is just an update to the last check block } else { diff --git a/internal/evmreader/mocks_test.go b/internal/evmreader/mocks_test.go index 77107df2e..e35cf8352 100644 --- a/internal/evmreader/mocks_test.go +++ b/internal/evmreader/mocks_test.go @@ -299,6 +299,11 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { MonitoredEvent_OutputExecuted, mock.Anything, ).Return(nil).Times(8) + m.On("UpdateApplicationLastForecloseCheckBlock", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(nil).Maybe() m.On("GetNumberOfInputs", mock.Anything, @@ -461,6 +466,51 @@ func (m *MockRepository) UpdateApplicationState( return args.Error(0) } +func (m *MockRepository) UpdateApplicationForeclosure( + ctx context.Context, appID int64, block uint64, txHash common.Hash, blockNumber uint64, +) error { + args := m.Called(ctx, appID, block, txHash, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) UpdateApplicationLastForecloseCheckBlock( + ctx context.Context, appID int64, blockNumber uint64, +) error { + args := m.Called(ctx, appID, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) UpdateAccountsDriveProved( + ctx context.Context, appID int64, block uint64, txHash common.Hash, root common.Hash, blockNumber uint64, +) error { + args := m.Called(ctx, appID, block, txHash, root, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) UpdateApplicationLastAccountsDriveProvedCheckBlock( + ctx context.Context, appID int64, blockNumber uint64, +) error { + args := m.Called(ctx, appID, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) StoreWithdrawalEvents( + ctx context.Context, appID int64, withdrawals []*Withdrawal, blockNumber uint64, +) error { + args := m.Called(ctx, appID, withdrawals, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) GetNumberOfWithdrawals(ctx context.Context, appID int64) (uint64, error) { + args := m.Called(ctx, appID) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *MockRepository) InsertWithdrawal(ctx context.Context, w *Withdrawal) error { + args := m.Called(ctx, w) + return args.Error(0) +} + func (m *MockRepository) UpdateEventLastCheckBlock( ctx context.Context, appIDs []int64, event MonitoredEvent, blockNumber uint64, @@ -485,7 +535,17 @@ type MockApplicationContract struct { } func newMockApplicationContract() *MockApplicationContract { - return &MockApplicationContract{} + m := &MockApplicationContract{} + // Foreclosure detection runs on every evmreader tick. Default to a + // not-foreclosed app so tests that don't care about this path don't + // need to wire it up. .Maybe() lets AssertExpectations pass even + // when these calls didn't happen (test never reached the foreclosure + // branch). Tests that exercise foreclosure call Unset("IsForeclosed") + // + re-mock with the desired behavior. + m.On("IsForeclosed", mock.Anything).Return(false, nil).Maybe() + m.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{}, nil).Maybe() + return m } func (m *MockApplicationContract) SetupDefaultBehavior() *MockApplicationContract { @@ -507,6 +567,13 @@ func (m *MockApplicationContract) RetrieveOutputExecutionEvents( return args.Get(0).([]*iapplication.IApplicationOutputExecuted), args.Error(1) } +func (m *MockApplicationContract) RetrieveForeclosureEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationForeclosure, error) { + args := m.Called(opts) + return args.Get(0).([]*iapplication.IApplicationForeclosure), args.Error(1) +} + func (m *MockApplicationContract) GetDeploymentBlockNumber( opts *bind.CallOpts, ) (*big.Int, error) { @@ -521,6 +588,38 @@ func (m *MockApplicationContract) GetNumberOfExecutedOutputs( return args.Get(0).(*big.Int), args.Error(1) } +func (m *MockApplicationContract) GetAccountsDriveMerkleRoot(opts *bind.CallOpts) (bool, common.Hash, error) { + args := m.Called(opts) + return args.Bool(0), args.Get(1).(common.Hash), args.Error(2) +} + +func (m *MockApplicationContract) IsForeclosed(opts *bind.CallOpts) (bool, error) { + args := m.Called(opts) + return args.Bool(0), args.Error(1) +} + +func (m *MockApplicationContract) GetNumberOfWithdrawals(opts *bind.CallOpts) (*big.Int, error) { + args := m.Called(opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*big.Int), args.Error(1) +} + +func (m *MockApplicationContract) RetrieveWithdrawalEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationWithdrawal, error) { + args := m.Called(opts) + return args.Get(0).([]*iapplication.IApplicationWithdrawal), args.Error(1) +} + +func (m *MockApplicationContract) RetrieveAccountsDriveProvedEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationAccountsDriveMerkleRootProved, error) { + args := m.Called(opts) + return args.Get(0).([]*iapplication.IApplicationAccountsDriveMerkleRootProved), args.Error(1) +} + // --------------------------------------------------------------------------- // MockDaveConsensus // --------------------------------------------------------------------------- diff --git a/internal/evmreader/output.go b/internal/evmreader/output.go index 545b349bd..baeaea1fb 100644 --- a/internal/evmreader/output.go +++ b/internal/evmreader/output.go @@ -144,11 +144,24 @@ func (r *Service) readAndUpdateOutputs( err := r.repository.UpdateEventLastCheckBlock( ctx, []int64{app.application.ID}, MonitoredEvent_OutputExecuted, mostRecentBlockNumber) if err != nil { - r.Logger.Error("Failed to update LastOutputCheckBlock for applications without inputs", - "application", app.application.Name, "address", app.application.IApplicationAddress, - "block_number", mostRecentBlockNumber, - "error", err, - ) + // Shutdown cancels the ctx mid-update; downgrade to Debug + // for the graceful-stop case so it does not show up as a + // spurious ERR line during shutdown. DeadlineExceeded would + // still flow through the Error branch and demand attention. + if errors.Is(err, context.Canceled) { + r.Logger.Debug( + "UpdateEventLastCheckBlock canceled during shutdown", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "block_number", mostRecentBlockNumber, + "error", err, + ) + } else { + r.Logger.Error("Failed to update LastOutputCheckBlock for applications without inputs", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "block_number", mostRecentBlockNumber, + "error", err, + ) + } // We don't return an error here as there is no output execution to process // and this is just an update to the last check block } else { diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go index 43fd597cc..65bec61de 100644 --- a/internal/evmreader/output_test.go +++ b/internal/evmreader/output_test.go @@ -520,6 +520,11 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { MonitoredEvent_OutputExecuted, mock.Anything, ).Return(nil).Times(5) + s.repository.On("UpdateApplicationLastForecloseCheckBlock", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(nil).Maybe() s.repository.On("GetNumberOfInputs", mock.Anything, diff --git a/internal/evmreader/post_foreclosure.go b/internal/evmreader/post_foreclosure.go new file mode 100644 index 000000000..67dd52334 --- /dev/null +++ b/internal/evmreader/post_foreclosure.go @@ -0,0 +1,61 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" +) + +// checkPostForeclosure dispatches per-tick observation work for apps that +// have already been foreclosed on chain. +// +// Apps with `foreclose_block == 0` are skipped — they are still in the +// pre-foreclosure scan surface. For each foreclosed app, dispatches to: +// - checkForDriveProved while `accounts_drive_proved_block == 0` +// (discover the proveAccountsDriveMerkleRoot tx by checking the one-way +// wasProved boolean, then filtering the event when it flips). +// - checkForPostForeclosureWithdrawals once the drive has been proved +// (discover Withdrawal events via FindTransitions on the on-chain +// getNumberOfWithdrawals counter, then FilterLogs on the 1-block +// window and persist each event). +// +// The two halves are mutually exclusive — once drive-prove lands, the +// withdrawal scan takes over for that app. They are mutually exclusive +// because the contract enforces drive-must-be-proved-before-withdraw, so +// the cursor relationship is one-way. +func (r *Service) checkPostForeclosure( + ctx context.Context, + apps []appContracts, + mostRecentBlockNumber uint64, +) { + for _, app := range apps { + if app.application.ForecloseBlock == 0 { + continue + } + if app.application.AccountsDriveProvedBlock == 0 { + r.checkForDriveProved(ctx, app, mostRecentBlockNumber) + } else { + r.checkForPostForeclosureWithdrawals(ctx, app, mostRecentBlockNumber) + } + } +} + +// abortPostForeclosureLoop mirrors abortForeclosureLoop's +// context-error convention: context.Canceled is graceful (silent return), +// context.DeadlineExceeded means the tick's budget is gone and every +// remaining per-app RPC would fail the same way — log once at the site +// and stop the loop. Other errors stay per-app so a transient RPC failure +// on one app does not block the rest. +func abortPostForeclosureLoop(r *Service, err error, where string) bool { + if errors.Is(err, context.Canceled) { + return true + } + if errors.Is(err, context.DeadlineExceeded) { + r.Logger.Error("Post-foreclosure scan deadline exceeded; aborting remaining apps", + "site", where, "error", err) + return true + } + return false +} diff --git a/internal/evmreader/post_foreclosure_withdrawal.go b/internal/evmreader/post_foreclosure_withdrawal.go new file mode 100644 index 000000000..9b722a3c7 --- /dev/null +++ b/internal/evmreader/post_foreclosure_withdrawal.go @@ -0,0 +1,180 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "fmt" + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +// checkForPostForeclosureWithdrawals runs once per evmreader tick for each +// foreclosed app whose accounts drive has been proved. It performs a +// FindTransitions search on the on-chain `getNumberOfWithdrawals()` counter +// (monotonic) over +// `[max(accounts_drive_proved_block, last_withdrawal_check_block+1), mostRecent]`. +// +// On each transition block N (the counter increased), filter Withdrawal +// events with a 1-block window `[N, N]`. +// +// After a successful scan, the observed events and the per-app +// last_withdrawal_check_block cursor are committed in one repository +// transaction. That keeps the DB withdrawal count aligned with the cursor, so +// the next tick can use the DB count as the previous counter. On any scan or +// persist failure, neither cursor nor in-memory mirror advances. +func (r *Service) checkForPostForeclosureWithdrawals( + ctx context.Context, + app appContracts, + mostRecentBlockNumber uint64, +) { + startBlock := app.application.LastWithdrawalCheckBlock + 1 + if floor := app.application.AccountsDriveProvedBlock; startBlock < floor { + startBlock = floor + } + if startBlock > mostRecentBlockNumber { + return + } + + query := func(ctx context.Context, block uint64) (*big.Int, error) { + return app.applicationContract.GetNumberOfWithdrawals(&bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(block), + }) + } + + var withdrawals []*Withdrawal + + onHit := func(block uint64) error { + blockWithdrawals, err := r.withdrawalsAtBlock(ctx, app, block) + if err != nil { + return err + } + withdrawals = append(withdrawals, blockWithdrawals...) + return nil + } + + prevValue, err := r.previousWithdrawalCount(ctx, app, startBlock) + if err != nil { + if abortPostForeclosureLoop(r, err, "getPreviousWithdrawalCount") { + return + } + r.Logger.Error("Failed to read previous withdrawal count", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "error", err) + return + } + + _, err = ethutil.FindTransitions( + ctx, + startBlock, + mostRecentBlockNumber, + prevValue, + query, + onHit, + ) + if err != nil { + if abortPostForeclosureLoop(r, err, "findTransitionsWithdrawals") { + return + } + r.Logger.Error("Failed to scan withdrawal transitions", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber, + "error", err) + return + } + + if err := r.repository.StoreWithdrawalEvents( + ctx, + app.application.ID, + withdrawals, + mostRecentBlockNumber, + ); err != nil { + r.Logger.Error("Failed to persist withdrawal scan", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "withdrawals", len(withdrawals), + "last_withdrawal_check_block", mostRecentBlockNumber, + "error", err) + return + } + + for _, w := range withdrawals { + r.Logger.Info("Withdrawal observed", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "account_index", w.AccountIndex, + "block", w.BlockNumber, + "transaction_hash", w.TransactionHash, + ) + } + app.application.LastWithdrawalCheckBlock = mostRecentBlockNumber +} + +func (r *Service) previousWithdrawalCount( + ctx context.Context, + app appContracts, + startBlock uint64, +) (*big.Int, error) { + // No withdrawal can happen before the accounts drive is proved: withdraw() + // validates against the proved root and reverts while it is missing. + if startBlock == app.application.AccountsDriveProvedBlock { + return big.NewInt(0), nil + } + + count, err := r.repository.GetNumberOfWithdrawals(ctx, app.application.ID) + if err != nil { + return nil, err + } + return new(big.Int).SetUint64(count), nil +} + +// withdrawalsAtBlock fetches all Withdrawal events emitted at the given block +// via the IApplication adapter. Multiple Withdrawal events can fire in the +// same block (different account indices); each gets its own row with a +// distinct (application_id, account_index) primary key when persisted. +func (r *Service) withdrawalsAtBlock( + ctx context.Context, + app appContracts, + block uint64, +) ([]*Withdrawal, error) { + events, err := app.applicationContract.RetrieveWithdrawalEvents(&bind.FilterOpts{ + Context: ctx, + Start: block, + End: &block, + }) + if err != nil { + return nil, err + } + if len(events) == 0 { + r.Logger.Warn( + "Withdrawal counter transition reported but no Withdrawal log in block", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "block", block, + ) + return nil, fmt.Errorf("withdrawal counter transition at block %d has no Withdrawal event", block) + } + + withdrawals := make([]*Withdrawal, 0, len(events)) + for _, ev := range events { + withdrawals = append(withdrawals, &Withdrawal{ + ApplicationID: app.application.ID, + AccountIndex: ev.AccountIndex, + Account: append([]byte{}, ev.Account...), + Output: append([]byte{}, ev.Output...), + BlockNumber: ev.Raw.BlockNumber, + TransactionHash: ev.Raw.TxHash, + LogIndex: ev.Raw.Index, + }) + } + return withdrawals, nil +} diff --git a/internal/evmreader/post_foreclosure_withdrawal_test.go b/internal/evmreader/post_foreclosure_withdrawal_test.go new file mode 100644 index 000000000..5279d5071 --- /dev/null +++ b/internal/evmreader/post_foreclosure_withdrawal_test.go @@ -0,0 +1,491 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "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/assert" + "github.com/stretchr/testify/mock" +) + +// postForeclosureWithdrawalApp builds an Application that has already been +// foreclosed AND had its accounts drive proved; this is the state where the +// withdrawal scan runs. +func postForeclosureWithdrawalApp(id int64, forecloseBlock, driveProvedBlock uint64) *Application { + return &Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(big.NewInt(id)), + State: ApplicationState_Enabled, + ForecloseBlock: forecloseBlock, + AccountsDriveProvedBlock: driveProvedBlock, + } +} + +// makeWithdrawalEvent builds a synthetic IApplicationWithdrawal event with +// the given block/log positions and account fields. Used by the +// withdrawal-scan tests to stub RetrieveWithdrawalEvents. +func makeWithdrawalEvent( + block uint64, logIndex uint, txHash common.Hash, + accountIndex uint64, account, output []byte, +) *iapplication.IApplicationWithdrawal { + return &iapplication.IApplicationWithdrawal{ + AccountIndex: accountIndex, + Account: account, + Output: output, + Raw: types.Log{ + BlockNumber: block, + TxHash: txHash, + Index: logIndex, + }, + } +} + +// --------------------------------------------------------------------------- +// checkForPostForeclosureWithdrawals +// --------------------------------------------------------------------------- + +// TestCheckForWithdrawals_NoWithdrawalsYet verifies the common steady-state +// path: the counter stays at zero across the whole scan window, no +// transitions fire, no withdrawals are persisted, and the cursor advances. +func TestCheckForWithdrawals_NoWithdrawalsYet(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.Anything). + Return(big.NewInt(0), nil) + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 0 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastWithdrawalCheckBlock) +} + +// TestCheckForWithdrawals_SingleWithdrawal walks the happy path: +// getNumberOfWithdrawals goes 0→1 at block 120, FilterWithdrawal is called +// with a 1-block window [120, 120], one event is returned, and the event plus +// cursor are persisted atomically. +func TestCheckForWithdrawals_SingleWithdrawal(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + txHash := common.HexToHash("0xcafe") + accountBytes := []byte{0xaa, 0xbb} + outputBytes := []byte{0xcc, 0xdd} + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + + c.On("RetrieveWithdrawalEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 120 && opts.End != nil && *opts.End == 120 + })).Return([]*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, txHash, 7, accountBytes, outputBytes), + }, nil).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 1 && + ws[0].ApplicationID == app.ID && + ws[0].AccountIndex == 7 && + string(ws[0].Account) == string(accountBytes) && + string(ws[0].Output) == string(outputBytes) && + ws[0].BlockNumber == 120 && + ws[0].TransactionHash == txHash && + ws[0].LogIndex == 0 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) +} + +// TestCheckForWithdrawals_WithdrawalAtDriveProvedFloor verifies that the +// scanner detects a transition at AccountsDriveProvedBlock itself. This +// requires seeding FindTransitions with the contract invariant that no +// withdrawal exists before the accounts drive is proved; otherwise a +// withdrawal in the first scanned block is invisible. +func TestCheckForWithdrawals_WithdrawalAtDriveProvedFloor(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 120) + const head = uint64(130) + txHash := common.HexToHash("0xcafe") + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + c.On("RetrieveWithdrawalEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 120 && opts.End != nil && *opts.End == 120 + })).Return([]*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, txHash, 7, []byte{0xaa}, []byte{0xbb}), + }, nil).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 1 && + ws[0].AccountIndex == 7 && + ws[0].BlockNumber == 120 && + ws[0].TransactionHash == txHash + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastWithdrawalCheckBlock) +} + +// TestCheckForWithdrawals_WithdrawalAtCursorNextBlock verifies that the +// scanner detects a withdrawal in the first newly scanned block after a +// previous successful tick. +func TestCheckForWithdrawals_WithdrawalAtCursorNextBlock(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 119 + const head = uint64(130) + txHash := common.HexToHash("0xbeef") + + repo.On("GetNumberOfWithdrawals", mock.Anything, app.ID).Return(uint64(0), nil).Once() + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + c.On("RetrieveWithdrawalEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 120 && opts.End != nil && *opts.End == 120 + })).Return([]*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, txHash, 9, []byte{0x01}, []byte{0x02}), + }, nil).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 1 && + ws[0].AccountIndex == 9 && + ws[0].BlockNumber == 120 && + ws[0].TransactionHash == txHash + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastWithdrawalCheckBlock) +} + +// TestCheckForWithdrawals_DoesNotAdvanceCursorWhenDBCountFails verifies that +// later scan windows rely on the DB withdrawal count as the previous counter. +// If that local read fails, no chain scan is attempted and the cursor remains +// unchanged. +func TestCheckForWithdrawals_DoesNotAdvanceCursorWhenDBCountFails(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 119 + const head = uint64(130) + + repo.On("GetNumberOfWithdrawals", mock.Anything, app.ID). + Return(uint64(0), errors.New("db unavailable")).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, uint64(119), app.LastWithdrawalCheckBlock) +} + +// TestCheckForWithdrawals_MultipleInOneBlock verifies the multi-event-per- +// block path: two Withdrawals fire in the same block (different account +// indices). Both must be persisted in the same cursor-advance transaction, +// preserving distinct log_index values. +func TestCheckForWithdrawals_MultipleInOneBlock(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + txHash := common.HexToHash("0xbeef") + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(2), nil) + + c.On("RetrieveWithdrawalEvents", mock.Anything).Return( + []*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, txHash, 3, []byte{0x01}, []byte{0x10}), + makeWithdrawalEvent(120, 1, txHash, 5, []byte{0x02}, []byte{0x20}), + }, nil, + ).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 2 && + ws[0].AccountIndex == 3 && + ws[0].LogIndex == 0 && + ws[1].AccountIndex == 5 && + ws[1].LogIndex == 1 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) +} + +// TestCheckForWithdrawals_CursorRespectsDriveProvedAsFloor pins the +// search-window lower bound. When LastWithdrawalCheckBlock is 0 and the +// drive was proved mid-range, the scan must start at +// AccountsDriveProvedBlock (not 1, not 0) — withdrawals cannot land +// before the drive-prove that gates them. +func TestCheckForWithdrawals_CursorRespectsDriveProvedAsFloor(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 500) + const head = uint64(600) + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 500 + })).Return(big.NewInt(0), nil) + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 0 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) +} + +// TestCheckForWithdrawals_SkipsWhenCursorPastHead verifies the +// short-circuit: a previous tick already advanced the cursor past head. +// No RPC, no DB write. Mirrors the same check on the drive-prove side. +func TestCheckForWithdrawals_SkipsWhenCursorPastHead(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 200 + const head = uint64(150) + + // No mock expectations. + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, uint64(200), app.LastWithdrawalCheckBlock, + "cursor must not regress when head < last cursor") +} + +// TestCheckForWithdrawals_PersistErrorDoesNotAdvanceCursor verifies the +// atomic persistence contract: if inserting the observed withdrawals or +// advancing the cursor fails, the in-memory cursor must not advance. +func TestCheckForWithdrawals_PersistErrorDoesNotAdvanceCursor(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(2), nil) + + c.On("RetrieveWithdrawalEvents", mock.Anything).Return( + []*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, common.HexToHash("0xaa"), 1, []byte{0x01}, []byte{0x10}), + makeWithdrawalEvent(120, 1, common.HexToHash("0xbb"), 2, []byte{0x02}, []byte{0x20}), + }, nil, + ).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 2 && + ws[0].AccountIndex == 1 && + ws[1].AccountIndex == 2 + }), head).Return(errors.New("constraint violation")).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastWithdrawalCheckBlock, + "insert failure keeps cursor unchanged for retry") +} + +// TestCheckForWithdrawals_DoesNotAdvanceCursorOnQueryError verifies that a +// RetrieveWithdrawalEvents error mid-scan leaves the cursor unchanged. The +// next tick must retry the same block range instead of permanently skipping +// the missing events. +func TestCheckForWithdrawals_DoesNotAdvanceCursorOnQueryError(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + + c.On("RetrieveWithdrawalEvents", mock.Anything). + Return([]*iapplication.IApplicationWithdrawal(nil), errors.New("eth_getLogs failed")).Once() + // No persistence expectation — the scan errored before completion. + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastWithdrawalCheckBlock, + "query failure keeps cursor unchanged for retry") +} + +// TestCheckForWithdrawals_DoesNotAdvanceCursorWhenTransitionHasNoEvent +// verifies the inconsistent RPC/log view path. A counter transition without a +// matching Withdrawal log is treated as retryable and must not advance the +// cursor. +func TestCheckForWithdrawals_DoesNotAdvanceCursorWhenTransitionHasNoEvent(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + c.On("RetrieveWithdrawalEvents", mock.Anything). + Return([]*iapplication.IApplicationWithdrawal{}, nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastWithdrawalCheckBlock, + "missing event after counter transition keeps cursor unchanged for retry") +} + +// TestCheckForWithdrawals_AbortsOnDeadlineExceeded mirrors the drive-prove +// abort path: a DeadlineExceeded mid-scan aborts the loop without +// advancing the cursor. +func TestCheckForWithdrawals_AbortsOnDeadlineExceeded(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.Anything). + Return((*big.Int)(nil), context.DeadlineExceeded).Maybe() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastWithdrawalCheckBlock, + "DeadlineExceeded aborts before cursor advance") +} + +// --------------------------------------------------------------------------- +// checkPostForeclosure dispatcher routing +// --------------------------------------------------------------------------- + +// TestCheckPostForeclosure_SkipsNonForeclosedApps verifies the top-level +// dispatcher's gate: apps whose ForecloseBlock is zero must not reach +// either of the two scan branches. The mock has no expectations for any +// adapter or repo method tied to the scans — any call trips the test. +func TestCheckPostForeclosure_SkipsNonForeclosedApps(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := &Application{ + ID: 99, + Name: "not-foreclosed", + IApplicationAddress: common.BigToAddress(big.NewInt(99)), + State: ApplicationState_Enabled, + // ForecloseBlock left zero — should be skipped. + } + + s.checkPostForeclosure(context.Background(), + []appContracts{{application: app, applicationContract: c}}, 100) +} + +// TestCheckPostForeclosure_RoutesToDriveProvedWhenZero verifies the dispatcher +// routes to the drive-prove scan when AccountsDriveProvedBlock == 0. +// GetAccountsDriveMerkleRoot must be called; GetNumberOfWithdrawals must NOT be. +func TestCheckPostForeclosure_RoutesToDriveProvedWhenZero(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(false, common.Hash{}, nil).Once() + repo.On("UpdateApplicationLastAccountsDriveProvedCheckBlock", + mock.Anything, app.ID, head).Return(nil).Once() + // No GetNumberOfWithdrawals — assertion by negation. + + s.checkPostForeclosure(context.Background(), + []appContracts{{application: app, applicationContract: c}}, head) +} + +// TestCheckPostForeclosure_RoutesToWithdrawalsWhenProved verifies the +// dispatcher routes to the withdrawal scan once +// AccountsDriveProvedBlock != 0. GetNumberOfWithdrawals must be called; +// RetrieveAccountsDriveProvedEvents must NOT be. +func TestCheckPostForeclosure_RoutesToWithdrawalsWhenProved(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.Anything).Return(big.NewInt(0), nil) + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 0 + }), head).Return(nil).Once() + + s.checkPostForeclosure(context.Background(), + []appContracts{{application: app, applicationContract: c}}, head) +} From 441949b1f66487b1a7c8aa80b474805a3a29b934 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:09:39 -0300 Subject: [PATCH 11/16] feat(prt): drain foreclosed apps to INOPERABLE --- internal/prt/handle_foreclosed_test.go | 238 +++++++++++++++++++++++++ internal/prt/prt.go | 34 +++- internal/prt/service.go | 75 +++++++- internal/prt/typed_errors_test.go | 108 +++++++++++ 4 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 internal/prt/handle_foreclosed_test.go create mode 100644 internal/prt/typed_errors_test.go diff --git a/internal/prt/handle_foreclosed_test.go b/internal/prt/handle_foreclosed_test.go new file mode 100644 index 000000000..509da41a1 --- /dev/null +++ b/internal/prt/handle_foreclosed_test.go @@ -0,0 +1,238 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "log/slog" + "os" + "testing" + "time" + + "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/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// prtRepositoryMock is a hand-written mock for the prtRepository interface, +// stubbing only the methods used by handleForeclosedApp. Unused methods +// keep zero-value Return signatures so the surface compiles; if a test +// accidentally invokes them, testify/mock reports an unexpected call. +type prtRepositoryMock struct { + mock.Mock +} + +func (m *prtRepositoryMock) HasUndrainedEpochsBeforeBlock( + ctx context.Context, appID int64, blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *prtRepositoryMock) UpdateApplicationState( + ctx context.Context, appID int64, state model.ApplicationState, reason *string, +) error { + args := m.Called(ctx, appID, state, reason) + return args.Error(0) +} + +// Unused-by-this-suite methods. We satisfy the interface but each panics +// loudly if invoked — handleForeclosedApp must not reach for them. +func (m *prtRepositoryMock) ListApplications( + context.Context, repository.ApplicationFilter, repository.Pagination, bool, +) ([]*model.Application, uint64, error) { + panic("unexpected ListApplications") +} +func (m *prtRepositoryMock) ListEpochs( + context.Context, string, repository.EpochFilter, repository.Pagination, bool, +) ([]*model.Epoch, uint64, error) { + panic("unexpected ListEpochs") +} +func (m *prtRepositoryMock) GetEpoch(context.Context, string, uint64) (*model.Epoch, error) { + panic("unexpected GetEpoch") +} +func (m *prtRepositoryMock) UpdateEpochStatus(context.Context, string, *model.Epoch) error { + panic("unexpected UpdateEpochStatus") +} +func (m *prtRepositoryMock) CreateTournament(context.Context, string, *model.Tournament) error { + panic("unexpected CreateTournament") +} +func (m *prtRepositoryMock) GetTournament(context.Context, string, string) (*model.Tournament, error) { + panic("unexpected GetTournament") +} +func (m *prtRepositoryMock) UpdateTournament(context.Context, string, *model.Tournament) error { + panic("unexpected UpdateTournament") +} +func (m *prtRepositoryMock) ListTournaments( + context.Context, string, repository.TournamentFilter, repository.Pagination, bool, +) ([]*model.Tournament, uint64, error) { + panic("unexpected ListTournaments") +} +func (m *prtRepositoryMock) StoreTournamentEvents( + context.Context, int64, []*model.Commitment, []*model.Match, + []*model.MatchAdvanced, []*model.Match, uint64, +) error { + panic("unexpected StoreTournamentEvents") +} +func (m *prtRepositoryMock) GetCommitment(context.Context, string, uint64, string, string) (*model.Commitment, error) { + panic("unexpected GetCommitment") +} +func (m *prtRepositoryMock) SaveNodeConfigRaw(context.Context, string, []byte) error { + panic("unexpected SaveNodeConfigRaw") +} +func (m *prtRepositoryMock) LoadNodeConfigRaw(context.Context, string) ([]byte, time.Time, time.Time, error) { + panic("unexpected LoadNodeConfigRaw") +} + +// newPRTServiceMock builds a minimal Service wired to a prtRepositoryMock. +// Only the fields handleForeclosedApp reaches for are populated. +func newPRTServiceMock() (*Service, *prtRepositoryMock) { + repo := &prtRepositoryMock{} + s := &Service{ + Service: service.Service{ + Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})), + }, + repository: repo, + } + return s, repo +} + +func prtForeclosedApp(id int64, block uint64) *model.Application { + txHash := common.HexToHash("0xcafe") + return &model.Application{ + ID: id, + Name: "prt-app", + IApplicationAddress: common.BigToAddress(common.Big1), + ConsensusType: model.Consensus_PRT, + State: model.ApplicationState_Enabled, + ForecloseBlock: block, + ForecloseTransaction: &txHash, + // LastEpochCheckBlock defaults to the foreclose block so callers + // who don't care about the bootstrap guard skip past it. Tests + // that exercise the guard override this field explicitly. + LastEpochCheckBlock: block, + } +} + +// 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 +// drive any work. +func TestHandleForeclosedApp_NoOpWhenForecloseBlockZero(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := &model.Application{ID: 1, ConsensusType: model.Consensus_PRT} + require.NoError(t, s.handleForeclosedApp(context.Background(), app)) +} + +// TestHandleForeclosedApp_DefersWhenUndrained verifies the +// pre-foreclosure-work guard. While the advancer/validator have epochs to +// process before the foreclose block, the PRT app must remain in ENABLED. +// Marking it INOPERABLE early would lose the last machine state needed +// to settle any in-flight tournament. +func TestHandleForeclosedApp_DefersWhenUndrained(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + // No UpdateApplicationState expectation — see TestProcessForeclosedApps_DefersWhenUndrained + // in the claimer suite for the equivalent reasoning. + + require.NoError(t, s.handleForeclosedApp(context.Background(), app)) +} + +// TestHandleForeclosedApp_NoTransitionWhenDrained verifies that once the +// narrow drain gate clears, handleForeclosedApp is a no-op. No +// UpdateApplicationState call fires — the PRT app stays ENABLED with +// foreclose_block set. evmreader picks up the post-foreclosure observation +// work from here. +// +// The mock has no UpdateApplicationState expectation registered; +// testify/mock fails the test on an unexpected call, so any regression that +// re-introduces a terminal-state transition trips this test loudly. +func TestHandleForeclosedApp_NoTransitionWhenDrained(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + + // No UpdateApplicationState expectation — the assertion is by negation. + + require.NoError(t, s.handleForeclosedApp(context.Background(), app)) +} + +// TestHandleForeclosedApp_SurfacesDrainCheckError verifies the surrounding +// behavior on transient repository failures: the error must propagate so +// the Tick's err slice marks the app as in trouble; the app stays in +// ENABLED for retry on the next tick. +func TestHandleForeclosedApp_SurfacesDrainCheckError(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + dbErr := errors.New("connection refused") + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, dbErr).Once() + + err := s.handleForeclosedApp(context.Background(), app) + require.Error(t, err) + assert.ErrorIs(t, err, dbErr) +} + +// TestHandleForeclosedApp_DefersWhenStillBackfilling verifies the +// bootstrap-readiness guard. When a freshly registered PRT app encounters +// an already-foreclosed contract, evmreader sets ForecloseBlock before +// checkForEpochsAndInputs has ingested any historical sealed epochs. The +// drain gate would then see an empty input table and incorrectly return +// false, making the app look drained before any pre-foreclosure epoch is +// observed locally. The guard must defer the drain check until +// LastEpochCheckBlock >= ForecloseBlock. +// +// The mock has no HasUndrainedEpochsBeforeBlock or UpdateApplicationState +// 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() + + // No UpdateApplicationState expectation — the gate has cleared but the + // function does not transition the app. + + require.NoError(t, s.handleForeclosedApp(context.Background(), app)) +} diff --git a/internal/prt/prt.go b/internal/prt/prt.go index 5cda631af..d442a2c41 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -28,6 +28,7 @@ type prtRepository interface { ListApplications(ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool) ([]*Application, uint64, error) UpdateApplicationState(ctx context.Context, appID int64, state ApplicationState, reason *string) error + HasUndrainedEpochsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool) ([]*Epoch, uint64, error) @@ -448,7 +449,7 @@ func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentB "application", app.Name, "epoch", epoch.Index, "event_block_number", event.Raw.BlockNumber, - "claim_hash", fmt.Sprintf("%x", event.OutputsMerkleRoot), + "outputs_merkle_root", fmt.Sprintf("%x", event.OutputsMerkleRoot), "tx", epoch.ClaimTransactionHash, ) @@ -712,6 +713,22 @@ func (s *Service) trySettle(ctx context.Context, app *Application, mostRecentBlo "epoch_index", result.EpochNumber.Uint64()) return nil } + // 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. + if ethutil.IsNonceTooLowError(err) { + s.Logger.Info( + "Settle broadcast rejected with 'nonce too low'; "+ + "deferring to the next tick's IsEpochSettled reconciliation", + "application", app.Name, + "epoch_index", result.EpochNumber.Uint64()) + return nil + } s.Logger.Error("failed to send Settle transaction", "application", app.Name, "epoch_index", result.EpochNumber.Uint64(), "error", err) return err @@ -853,6 +870,21 @@ func (s *Service) reactToTournament(ctx context.Context, app *Application, mostR "tournament", epoch.TournamentAddress.Hex(), "commitment", epoch.Commitment.Hex()) return nil } + // Transient broadcast race: a tx with this EOA's nonce is already + // mined. The next tick's IsCommitmentJoined check will reconcile + // against the propagated chain state and short-circuit if our prior + // JoinTournament landed; otherwise a new broadcast goes out with a + // fresh nonce. + if ethutil.IsNonceTooLowError(err) { + s.Logger.Info( + "JoinTournament broadcast rejected with 'nonce too low'; "+ + "deferring to the next tick's IsCommitmentJoined reconciliation", + "application", app.Name, + "epoch_index", currentEpochIndex, + "tournament", epoch.TournamentAddress.Hex(), + "commitment", epoch.Commitment.Hex()) + return nil + } s.Logger.Error("failed to send join tournament transaction", "application", app.Name, "epoch_index", currentEpochIndex, "error", err) return err diff --git a/internal/prt/service.go b/internal/prt/service.go index b60c4971b..e53ddb63f 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -149,12 +149,26 @@ func (s *Service) Tick() []error { if s.Context.Err() != nil { return errs } - if err := s.validateApplication(s.Context, apps[idx]); err != nil { + app := apps[idx] + // Foreclosed apps: chain has rejected the consensus pipeline. Skip + // PRT tournament work and run the drain visibility path instead. + // The evmreader is the sole writer of ForecloseBlock; once drained, + // the app remains ENABLED with foreclose_block set. + if app.ForecloseBlock != 0 { + if ferr := s.handleForeclosedApp(s.Context, app); ferr != nil { + if s.IsStopping() && errors.Is(ferr, context.Canceled) { + continue + } + errs = append(errs, ferr) + } + continue + } + if err := s.validateApplication(s.Context, app); err != nil { // During shutdown, in-flight L1 requests see context cancellation. // Suppress these to avoid spurious ERR log entries. if s.IsStopping() && errors.Is(err, context.Canceled) { s.Logger.Warn("Tick interrupted by shutdown", - "application", apps[idx].IApplicationAddress, "error", err) + "application", app.IApplicationAddress, "error", err) continue } errs = append(errs, err) @@ -163,6 +177,63 @@ func (s *Service) Tick() []error { return errs } +// handleForeclosedApp observes foreclosed DaveConsensus applications once per +// tick, logging visibility into the bootstrap-readiness guard and the narrow +// drain gate. Foreclosure no longer transitions the app to INOPERABLE — the +// app stays ENABLED with `foreclose_block != 0` as the terminal state. +// INOPERABLE is reserved for genuine corruption. +// +// The function still runs because: +// - The Info logs give operators visibility while pre-foreclosure inputs +// are still being ingested or drained. +// - The claim-broadcast guards in PRT's Settle/Join paths already +// short-circuit gas-burning work for foreclosed apps. +// +// Once both gates clear, the per-app branch is a no-op: there is no terminal +// action. evmreader picks up the post-foreclosure observation work from here. +func (s *Service) handleForeclosedApp(ctx context.Context, app *Application) error { + if app.ForecloseBlock == 0 { + return nil + } + // 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). + if app.LastEpochCheckBlock < app.ForecloseBlock { + s.Logger.Info( + "Foreclosed PRT application still ingesting pre-foreclosure sealed epochs", + "application", app.Name, + "address", app.IApplicationAddress, + "last_epoch_check_block", app.LastEpochCheckBlock, + "foreclose_block", app.ForecloseBlock, + ) + return nil + } + undrained, err := s.repository.HasUndrainedEpochsBeforeBlock(ctx, app.ID, app.ForecloseBlock) + if err != nil { + return fmt.Errorf("foreclosed app drain check (%s): %w", + app.IApplicationAddress, err) + } + if undrained { + s.Logger.Info( + "Foreclosed PRT application still draining pre-foreclosure inputs", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + ) + return nil + } + // Both gates clear: no terminal action. evmreader picks up the + // post-foreclosure observation work from here. + return nil +} + func (s *Service) Stop(_ bool) []error { s.SetStopping() return nil diff --git a/internal/prt/typed_errors_test.go b/internal/prt/typed_errors_test.go new file mode 100644 index 000000000..c00dee4ff --- /dev/null +++ b/internal/prt/typed_errors_test.go @@ -0,0 +1,108 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "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" +) + +// TestTournamentFailedNoWinnerSelector locks the hardcoded selector against +// the live ABI. A binding regen that renames the error or changes its inputs +// will trip this; an ABI rename that breaks the rest of the system but keeps +// the selector stable will not (intentional — the selector is what matters +// on the wire). +func TestTournamentFailedNoWinnerSelector(t *testing.T) { + abi, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + abiErr, ok := abi.Errors["TournamentFailedNoWinner"] + require.True(t, ok, "TournamentFailedNoWinner missing from ITournament ABI") + got := fmt.Sprintf("0x%x", abiErr.ID[:4]) + assert.Equal(t, TournamentFailedNoWinner, got, + "hardcoded TournamentFailedNoWinner selector drifted from ABI") +} + +// 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 (selector match in Result)", + abi: map[string]struct{ present bool }{"itournament": {true}}}, + + // prt.go: isIncorrectEpochNumberError uses IDaveConsensus.IsCustomError. + {name: "IncorrectEpochNumber", where: "prt.go (Settle revert classifier)", + 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 fac3c6d396b9abaa455ab680336e76a144d910a5 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:10:10 -0300 Subject: [PATCH 12/16] feat(claimer): v3 staging lifecycle and foreclosure drain --- internal/claimer/blockchain.go | 265 +- internal/claimer/claimer.go | 1967 ++++++++++++-- internal/claimer/claimer_test.go | 3001 +++++++++++++++++++--- internal/claimer/foreclosed_apps_test.go | 163 ++ internal/claimer/prior_counter_test.go | 181 ++ internal/claimer/service.go | 335 ++- internal/config/generate/Config.toml | 13 + internal/config/generated.go | 49 + 8 files changed, 5297 insertions(+), 677 deletions(-) create mode 100644 internal/claimer/foreclosed_apps_test.go create mode 100644 internal/claimer/prior_counter_test.go diff --git a/internal/claimer/blockchain.go b/internal/claimer/blockchain.go index 65e2a5d6e..cd5bcdb15 100644 --- a/internal/claimer/blockchain.go +++ b/internal/claimer/blockchain.go @@ -13,6 +13,7 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum" @@ -32,8 +33,20 @@ type iclaimerBlockchain interface { toBlock uint64, ) ( *iconsensus.IConsensus, - *iconsensus.IConsensusClaimSubmitted, - *iconsensus.IConsensusClaimSubmitted, + []*iconsensus.IConsensusClaimSubmitted, + error, + ) + + findClaimStagedEventAndSucc( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, + ) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimStaged, + *iconsensus.IConsensusClaimStaged, error, ) @@ -43,6 +56,18 @@ type iclaimerBlockchain interface { epoch *model.Epoch, ) (common.Hash, error) + acceptClaimOnBlockchain( + application *model.Application, + epoch *model.Epoch, + ) (common.Hash, error) + + getClaimStatus( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + blockNumber *big.Int, + ) (iconsensus.IConsensusClaim, error) + pollTransaction( ctx context.Context, txHash common.Hash, @@ -67,7 +92,10 @@ type iclaimerBlockchain interface { getConsensusAddress( ctx context.Context, app *model.Application, + blockNumber *big.Int, ) (common.Address, error) + + claimSubmitterAddress() (common.Address, bool) } type claimerBlockchain struct { @@ -77,6 +105,13 @@ type claimerBlockchain struct { defaultBlock config.DefaultBlock } +func (cb *claimerBlockchain) claimSubmitterAddress() (common.Address, bool) { + if cb.txOpts == nil { + return common.Address{}, false + } + return cb.txOpts.From, true +} + func (cb *claimerBlockchain) submitClaimToBlockchain( ic *iconsensus.IConsensus, application *model.Application, @@ -86,9 +121,29 @@ func (cb *claimerBlockchain) submitClaimToBlockchain( if cb.txOpts == nil { return txHash, fmt.Errorf("txOpts is required for claim submission") } + if epoch.OutputsMerkleRoot == nil { + return txHash, fmt.Errorf( + "epoch %d (%d) has no outputs_merkle_root; refusing to submit claim", + epoch.Index, epoch.VirtualIndex) + } + // Defensive nil check: the schema's epoch trigger only fires on status + // updates — it rejects a status→CLAIM_COMPUTED transition when + // outputs_merkle_proof is NULL, but does NOT prevent a later UPDATE that + // clears the proof while status is already CLAIM_COMPUTED. Submitting + // without a proof would revert on-chain (InvalidOutputsMerkleRootProofSize); + // fail loudly here instead. + if epoch.OutputsMerkleProof == nil { + return txHash, fmt.Errorf( + "epoch %d (%d) has no outputs_merkle_proof; refusing to submit claim", + epoch.Index, epoch.VirtualIndex) + } + proof := make([][32]byte, len(epoch.OutputsMerkleProof)) + for i, h := range epoch.OutputsMerkleProof { + proof[i] = h + } lastBlockNumber := new(big.Int).SetUint64(epoch.LastBlock) tx, err := ic.SubmitClaim(cb.txOpts, application.IApplicationAddress, - lastBlockNumber, *epoch.OutputsMerkleRoot) + lastBlockNumber, *epoch.OutputsMerkleRoot, proof) if err != nil { cb.logger.Warn("submitClaimToBlockchain:failed", "appContractAddress", application.IApplicationAddress, @@ -112,15 +167,46 @@ type eventIterator interface { Error() error } +// newOracle wraps a per-app counter getter (GetNumberOfSubmittedClaims / +// GetNumberOfAcceptedClaims, both of which take an appContract address in v3) +// as a (ctx, block) → uint256 closure suitable for ethutil.FindTransitions. func newOracle( - nr func(*bind.CallOpts) (*big.Int, error), + addr common.Address, + nr func(*bind.CallOpts, common.Address) (*big.Int, error), ) func(ctx context.Context, block uint64) (*big.Int, error) { return func(ctx context.Context, block uint64) (*big.Int, error) { return nr(&bind.CallOpts{ Context: ctx, BlockNumber: new(big.Int).SetUint64(block), - }) + }, addr) + } +} + +// priorCounter returns the value of an oracle at the block immediately +// preceding fromBlock, suitable for use as the prevValue argument of +// [ethutil.FindTransitions]. When fromBlock is zero there is no prior +// block to query and the function returns nil, which FindTransitions +// treats as "no prior reference; do not assert monotonicity at the +// boundary". +// +// Using oracle(fromBlock-1) is what makes the monotonic assertion inside +// FindTransitions sensible: prevValue must be the counter value just +// before the scan starts, not the counter at some unrelated block. An +// earlier version of the call sites here queried oracle(epoch.LastBlock), +// which silently worked for single-epoch scans (where fromBlock = +// epoch.LastBlock + 1) but tripped the monotonic check whenever a prior +// epoch already existed and fromBlock was prevEpoch.LastBlock + 1 — +// because the current epoch's LastBlock was AFTER all the post-fromBlock +// transitions and the counter there was already higher than at fromBlock. +func priorCounter( + ctx context.Context, + oracle func(ctx context.Context, block uint64) (*big.Int, error), + fromBlock uint64, +) (*big.Int, error) { + if fromBlock == 0 { + return nil, nil } + return oracle(ctx, fromBlock-1) } func newOnHit[IT eventIterator]( @@ -147,8 +233,9 @@ func newOnHit[IT eventIterator]( } } -// scan the event stream for a claimSubmitted event that matches claim. -// return this event and its successor +// scan the event stream for a ClaimSubmitted event that belongs to this epoch. +// Returns the stream starting at the first matching event. The caller filters +// by epoch and classifies full-tuple matches/divergences. func (cb *claimerBlockchain) findClaimSubmittedEventAndSucc( ctx context.Context, application *model.Application, @@ -157,34 +244,88 @@ func (cb *claimerBlockchain) findClaimSubmittedEventAndSucc( toBlock uint64, ) ( *iconsensus.IConsensus, - *iconsensus.IConsensusClaimSubmitted, - *iconsensus.IConsensusClaimSubmitted, + []*iconsensus.IConsensusClaimSubmitted, error, ) { ic, err := iconsensus.NewIConsensus(application.IConsensusAddress, cb.client) if err != nil { - return nil, nil, nil, fmt.Errorf("creating IConsensus binding for submitted events of application: %v, epoch: %v (%v): %w", + return nil, nil, fmt.Errorf("creating IConsensus binding for submitted events of application: %v, epoch: %v (%v): %w", application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) } - oracle := newOracle(ic.GetNumberOfSubmittedClaims) + oracle := newOracle(application.IApplicationAddress, ic.GetNumberOfSubmittedClaims) events := []*iconsensus.IConsensusClaimSubmitted{} onHit := newOnHit(ctx, application.IApplicationAddress, ic.FilterClaimSubmitted, func(it *iconsensus.IConsensusClaimSubmittedIterator) { event := it.Event - if (len(events) > 0) || claimSubmittedEventMatches(application, epoch, event) { + if (len(events) > 0) || claimSubmittedEventMatchesEpoch(application, epoch, event) { events = append(events, event) } }, ) - numSubmittedClaims, err := oracle(ctx, epoch.LastBlock) + prevValue, err := priorCounter(ctx, oracle, fromBlock) if err != nil { - return nil, nil, nil, fmt.Errorf("querying number of submitted claims for epoch %v (%v) at block %d: %w", - epoch.Index, epoch.VirtualIndex, epoch.LastBlock, err) + return nil, nil, fmt.Errorf("querying number of submitted claims for epoch %v (%v) before block %d: %w", + epoch.Index, epoch.VirtualIndex, fromBlock, err) } - _, err = ethutil.FindTransitions(ctx, fromBlock, toBlock, numSubmittedClaims, oracle, onHit) + _, err = ethutil.FindTransitions(ctx, fromBlock, toBlock, prevValue, oracle, onHit) if err != nil { - return nil, nil, nil, fmt.Errorf("walking ClaimSubmitted transitions for application: %v, epoch %v (%v): %w", + return nil, nil, fmt.Errorf("walking ClaimSubmitted transitions for application: %v, epoch %v (%v): %w", + application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) + } + + return ic, events, nil +} + +// scan the event stream for a ClaimStaged event that belongs to this epoch. +// Returns the first matching event and its on-chain successor (if any). +// The in-callback comparison uses claimStagedEventMatchesEpoch (only app + +// lastProcessedBlockNumber), NOT the merkleRoot, so a divergent staged +// event for our epoch can still be surfaced and handled by the caller — +// where the divergence taxonomy logic at the state-machine level decides +// the response. +func (cb *claimerBlockchain) findClaimStagedEventAndSucc( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimStaged, + *iconsensus.IConsensusClaimStaged, + error, +) { + ic, err := iconsensus.NewIConsensus(application.IConsensusAddress, cb.client) + if err != nil { + return nil, nil, nil, fmt.Errorf("creating IConsensus binding for staged events: %w", err) + } + + oracle := newOracle(application.IApplicationAddress, ic.GetNumberOfStagedClaims) + events := []*iconsensus.IConsensusClaimStaged{} + filter := func( + opts *bind.FilterOpts, + _ []common.Address, + appContract []common.Address, + ) (*iconsensus.IConsensusClaimStagedIterator, error) { + return ic.FilterClaimStaged(opts, appContract) + } + onHit := newOnHit(ctx, application.IApplicationAddress, filter, + func(it *iconsensus.IConsensusClaimStagedIterator) { + event := it.Event + if (len(events) > 0) || claimStagedEventMatchesEpoch(application, epoch, event) { + events = append(events, event) + } + }, + ) + + prevValue, err := priorCounter(ctx, oracle, fromBlock) + if err != nil { + return nil, nil, nil, fmt.Errorf("querying number of staged claims before block %d: %w", fromBlock, err) + } + _, err = ethutil.FindTransitions(ctx, fromBlock, toBlock, prevValue, oracle, onHit) + if err != nil { + return nil, nil, nil, fmt.Errorf("walking ClaimStaged transitions for application: %v, epoch %v (%v): %w", application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) } @@ -216,7 +357,7 @@ func (cb *claimerBlockchain) findClaimAcceptedEventAndSucc( return nil, nil, nil, fmt.Errorf("creating IConsensus binding for accepted events: %w", err) } - oracle := newOracle(ic.GetNumberOfAcceptedClaims) + oracle := newOracle(application.IApplicationAddress, ic.GetNumberOfAcceptedClaims) events := []*iconsensus.IConsensusClaimAccepted{} filter := func( opts *bind.FilterOpts, @@ -240,11 +381,11 @@ func (cb *claimerBlockchain) findClaimAcceptedEventAndSucc( }, ) - numAcceptedClaims, err := oracle(ctx, epoch.LastBlock) + prevValue, err := priorCounter(ctx, oracle, fromBlock) if err != nil { - return nil, nil, nil, fmt.Errorf("querying number of accepted claims at block %d: %w", epoch.LastBlock, err) + return nil, nil, nil, fmt.Errorf("querying number of accepted claims before block %d: %w", fromBlock, err) } - _, err = ethutil.FindTransitions(ctx, fromBlock, toBlock, numAcceptedClaims, oracle, onHit) + _, err = ethutil.FindTransitions(ctx, fromBlock, toBlock, prevValue, oracle, onHit) if err != nil { return nil, nil, nil, fmt.Errorf("walking ClaimAccepted transitions for application: %v, epoch %v (%v): %w", application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) @@ -262,15 +403,85 @@ func (cb *claimerBlockchain) findClaimAcceptedEventAndSucc( func (cb *claimerBlockchain) getConsensusAddress( ctx context.Context, app *model.Application, + blockNumber *big.Int, ) (common.Address, error) { - return ethutil.GetConsensus(ctx, cb.client, app.IApplicationAddress) + return ethutil.GetConsensusAt(ctx, cb.client, app.IApplicationAddress, blockNumber) +} + +// acceptClaimOnBlockchain calls IConsensus.acceptClaim for an epoch whose +// claim is already STAGED on chain and whose staging period has elapsed. +// The contract validates the period server-side and reverts with +// ClaimStagingPeriodNotOverYet if the math is off; the caller handles that +// revert via handleAcceptClaimRevert. +func (cb *claimerBlockchain) acceptClaimOnBlockchain( + application *model.Application, + epoch *model.Epoch, +) (common.Hash, error) { + txHash := common.Hash{} + if cb.txOpts == nil { + return txHash, fmt.Errorf("txOpts is required for claim acceptance") + } + if epoch.MachineHash == nil { + return txHash, fmt.Errorf( + "epoch %d (%d) has no machine_hash; refusing to accept claim", + epoch.Index, epoch.VirtualIndex) + } + ic, err := iconsensus.NewIConsensus(application.IConsensusAddress, cb.client) + if err != nil { + return txHash, fmt.Errorf("creating IConsensus binding for acceptClaim: %w", err) + } + lastBlockNumber := new(big.Int).SetUint64(epoch.LastBlock) + tx, err := ic.AcceptClaim(cb.txOpts, application.IApplicationAddress, + lastBlockNumber, *epoch.MachineHash) + if err != nil { + cb.logger.Warn("acceptClaimOnBlockchain:failed", + "appContractAddress", application.IApplicationAddress, + "machineHash", *epoch.MachineHash, + "last_block", epoch.LastBlock, + "error", err) + } else { + txHash = tx.Hash() + cb.logger.Debug("acceptClaimOnBlockchain:success", + "appContractAddress", application.IApplicationAddress, + "machineHash", *epoch.MachineHash, + "last_block", epoch.LastBlock, + "TxHash", txHash) + } + return txHash, err +} + +// getClaimStatus reads the on-chain Claim record for our (app, lpbn, +// machineMerkleRoot) tuple. Pinned to a specific block height (the tick's +// finalized block) so all chain reads within a single tick observe a +// consistent state. +func (cb *claimerBlockchain) getClaimStatus( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + blockNumber *big.Int, +) (iconsensus.IConsensusClaim, error) { + var zero iconsensus.IConsensusClaim + if epoch.MachineHash == nil { + return zero, fmt.Errorf( + "epoch %d (%d) has no machine_hash; cannot query getClaim", + epoch.Index, epoch.VirtualIndex) + } + ic, err := iconsensus.NewIConsensusCaller(application.IConsensusAddress, cb.client) + if err != nil { + return zero, fmt.Errorf("creating IConsensus caller for getClaim: %w", err) + } + opts := &bind.CallOpts{Context: ctx, BlockNumber: blockNumber} + return ic.GetClaim(opts, application.IApplicationAddress, + new(big.Int).SetUint64(epoch.LastBlock), *epoch.MachineHash) } -// isNotFirstClaimError checks whether an error from submitClaim is -// a NotFirstClaim revert, indicating the claim was already submitted -// on-chain (e.g., before a node restart). -func isNotFirstClaimError(err error) bool { - return ethutil.IsCustomError(err, iconsensus.IConsensusMetaData, "NotFirstClaim") +// isCustomConsensusError matches a typed Solidity error against an RPC revert. +// Checks IConsensus first; falls back to IQuorum so Quorum-only errors such +// as CallerIsNotValidator are also recognised. Selectors are name+type-based, +// so an error declared in both interfaces has the same selector either way. +func isCustomConsensusError(err error, name string) bool { + return ethutil.IsCustomError(err, iconsensus.IConsensusMetaData, name) || + ethutil.IsCustomError(err, iquorum.IQuorumMetaData, name) } // poll a transaction for its receipt diff --git a/internal/claimer/claimer.go b/internal/claimer/claimer.go index 57eaecec7..6e9963209 100644 --- a/internal/claimer/claimer.go +++ b/internal/claimer/claimer.go @@ -1,60 +1,109 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) -// Algorithm for the state transition of computed claims. Possible actions are: -// - update epoch in the database -// - submit claim to blockchain -// - transition application to an invalid state +// Package claimer drives the on-chain claim lifecycle for Authority and +// Quorum consensus apps. Each Tick interleaves three DB snapshots with five +// stages — submit, stage, accept-broadcast, accept-confirm, accept-by-event — +// so each stage's view of the database reflects the prior stage's mutations. // -// 1. On startup of a clean blockchain there are no previous claims nor events. +// The in-DB lifecycle mirrors the v3 IConsensus contract: // -// - This configuration must submit a new computed claim. +// CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_STAGED → CLAIM_ACCEPTED // -// 2. Some time after the submission, the computed claim shows up as a claimSubmitted -// event in the blockchain. The claim and event must match. +// with two shortcut transitions for restart recovery and deep reader-mode +// catch-up (CLAIM_COMPUTED → CLAIM_STAGED, CLAIM_COMPUTED → CLAIM_ACCEPTED), +// and Quorum-only rejection transitions from CLAIM_COMPUTED or CLAIM_SUBMITTED +// when Quorum stages or accepts a different claim for the epoch before our +// claim is staged. // -// - This configuration must update the epoch in the database: computed -> submitted +// PRT (DaveConsensus) is carved out: PRT epochs go directly +// CLAIM_COMPUTED → CLAIM_ACCEPTED via tournament resolution and never reach +// CLAIM_STAGED. The DB trigger enforces this carve-out; the claimer's selects +// also exclude PRT apps. // -// 3. After the first epoch, additional checks must be done. Same as (1) otherwise. -// 3.1. No epoch was skipped: -// - previous_claim.last_block < current_claim.first_block -// -// 4. After the first epoch, additional checks must be done. Same as (2) otherwise. -// 4.1. epochs are in order: -// - previous_claim.last_block < current_claim.first_block -// -// 4.2. There are no events between the epochs -// - next(previous_event) == current_event -// -// Other cases are errors. -// -// | n | prev | curr | action | -// | | claim | event | claim | event | | -// |---+-------+-------+-------+-------+--------+ -// | 1 | . | . | cc | . | submit | -// | 2 | . | . | cc | ce | update | -// | 3 | pc | pe | cc | . | submit | -// | 4 | pc | pe | cc | ce | update | +// Divergence between our local state and a chain event surfaces at three +// lifecycle stages — submit, stage, accept — each with its own per-consensus +// INOPERABLE reason bucket. package claimer import ( + "bytes" "context" + "errors" "fmt" "math/big" + "reflect" "time" "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ) -var ( - ErrClaimMismatch = fmt.Errorf("constraints failed for epoch claim and its successor.") - ErrEventMismatch = fmt.Errorf("epoch claim does not match its corresponding event.") - ErrMissingEvent = fmt.Errorf("epoch claim does not have a corresponding event.") +// submitClaimRevertOutcome describes how a typed revert from submitClaim was +// resolved by handleSubmitClaimRevert. The caller uses it to decide whether +// to surface, retry, or drop the in-flight epoch. +type submitClaimRevertOutcome int + +const ( + // submitClaimUnknown — the error is not a known typed IConsensus revert. + // Caller should surface it as an unexpected failure (normal error path). + submitClaimUnknown submitClaimRevertOutcome = iota + + // submitClaimAlreadyOnChain — the claim was already recorded on chain + // (Authority "NotFirstClaim" after restart). Side effect: log info. + // Caller should skip silently and wait for the normal event-sync path + // to update the DB. + submitClaimAlreadyOnChain + + // submitClaimAppHalted — the app state has been transitioned to a + // terminal/recoverable state (INOPERABLE or FAILED). Caller should drop + // the epoch from its work map and surface the returned state-update + // error if any. + submitClaimAppHalted + + // submitClaimRetryLater — the revert is expected to clear or be reconciled + // on a later tick (e.g., Quorum "NotFirstClaim" waiting for the prior vote's + // event, or "ApplicationForeclosed" waiting for the EVM reader marker). + // Caller should keep the epoch in its work map and try again next tick. + submitClaimRetryLater +) + +// Solidity ClaimStatus values from IConsensus.sol — used by getClaim() and +// returned in the ClaimNotStaged typed error's payload. +const ( + claimStatusUnstaged uint8 = 0 + claimStatusStaged uint8 = 1 + claimStatusAccepted uint8 = 2 +) + +// acceptClaimRevertOutcome is the parallel of submitClaimRevertOutcome but +// for acceptClaim reverts. Reachable causes are disjoint enough that a +// separate enum keeps the call-site switch tight. +type acceptClaimRevertOutcome int + +const ( + // acceptClaimUnknown — caller should surface the error. + acceptClaimUnknown acceptClaimRevertOutcome = iota + + // acceptClaimReconciledAccepted — a front-runner accepted our claim + // first (ClaimNotStaged revert with claimStatus=ACCEPTED). Caller + // should record the acceptance and proceed. + acceptClaimReconciledAccepted + + // acceptClaimAppHalted — the app state was transitioned (INOPERABLE + // or FAILED). Caller surfaces stateErr and drops the work. + acceptClaimAppHalted + + // acceptClaimRetryLater — transient: ClaimStagingPeriodNotOverYet + // (our arithmetic was off), or ApplicationForeclosed (the EVM reader will + // record the foreclosure marker and claim work will be partitioned out). + acceptClaimRetryLater ) type iclaimerRepository interface { @@ -74,6 +123,14 @@ type iclaimerRepository interface { error, ) + // key is model.Application.ID — accepted (newest) per app, staged (oldest) per app. + SelectStagedClaimPairsPerApp(ctx context.Context) ( + map[int64]*model.Epoch, + map[int64]*model.Epoch, + map[int64]*model.Application, + error, + ) + UpdateEpochWithSubmittedClaim( ctx context.Context, applicationID int64, @@ -81,10 +138,40 @@ type iclaimerRepository interface { transactionHash common.Hash, ) error + UpdateEpochToStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, + ) error + + UpdateEpochThroughStaging( + ctx context.Context, + applicationID int64, + index uint64, + transactionHash common.Hash, + stagedAtBlock uint64, + ) error + + UpdateEpochReconciledStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, + ) error + UpdateEpochWithAcceptedClaim( ctx context.Context, applicationID int64, index uint64, + txHash *common.Hash, + ) error + + RejectEpochAndSetApplicationInoperable( + ctx context.Context, + applicationID int64, + index uint64, + reason string, ) error UpdateApplicationState( @@ -94,6 +181,19 @@ type iclaimerRepository interface { reason *string, ) error + HasUnreconciledClaimsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + + // ListApplications is used to find ENABLED, foreclosed, non-PRT apps that + // no longer appear in any pending claim work map (e.g. their last claim + // was already accepted before the foreclosure) so drain/reconciliation + // visibility still runs for them. + ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + p repository.Pagination, + descending bool, + ) ([]*model.Application, 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) } @@ -115,18 +215,27 @@ func (s *Service) checkClaimsInFlight( endBlock *big.Int, ) (int, error) { confirmed := 0 - // check claims in flight. NOTE: map mutation + iteration is safe in Go - for key, txHash := range s.claimsInFlight { + for key, tx := range s.claimsInFlight { + txHash := tx.txHash ready, receipt, err := s.blockchain.pollTransaction(s.Context, txHash, endBlock) if err != nil { - s.Logger.Warn("Claim submission failed, retrying.", + s.Logger.Warn("Claim submission receipt lookup failed; keeping tx in flight.", "txHash", txHash, "err", err, ) - delete(s.claimsInFlight, key) - continue + return confirmed, fmt.Errorf("polling claim submission transaction %v: %w", txHash, err) } if !ready { + age := tx.ageAt(endBlock) + if receipt == nil && age >= maxInFlightReceiptNotFoundBlocks { + s.Logger.Warn("Claim submission receipt not found after timeout; retrying claim lifecycle.", + "app_id", key, + "txHash", txHash, + "age_blocks", age, + "timeout_blocks", maxInFlightReceiptNotFoundBlocks, + ) + delete(s.claimsInFlight, key) + } continue } if receipt.Status == 0 { @@ -138,42 +247,101 @@ func (s *Service) checkClaimsInFlight( continue } if computedEpoch, ok := computedEpochs[key]; ok { - err = s.repository.UpdateEpochWithSubmittedClaim( - s.Context, - computedEpoch.ApplicationID, - computedEpoch.Index, - receipt.TxHash, - ) - - // NOTE: there is no point in trying the other applications on a database error - // so we just return and try again later (next tick) - if err != nil { - return confirmed, fmt.Errorf("updating epoch %d (%d) with submitted claim: %w", computedEpoch.Index, computedEpoch.VirtualIndex, err) - } - confirmed++ - app := apps[key] appAddress := common.Address{} if app != nil { appAddress = app.IApplicationAddress } - s.Logger.Info("Claim submitted", - "app", appAddress, - "receipt_block_number", receipt.BlockNumber, - "claim_hash", hashToHex(computedEpoch.OutputsMerkleRoot), - "last_block", computedEpoch.LastBlock, - "tx", txHash) - - // Authority emits ClaimAccepted in the same tx as ClaimSubmitted. - // Parse the receipt to transition directly to accepted, saving a - // full tick round-trip. Quorum waits for a separate acceptance scan. - if app != nil && app.ConsensusType == model.Consensus_Authority { - if accepted := s.tryAcceptFromReceipt(receipt, app, computedEpoch); accepted { - confirmed++ + + // v3 fast-path: if the receipt also contains ClaimStaged for our + // (app, lpbn, outputs, machine), record COMPUTED → SUBMITTED → + // STAGED atomically in a single DB transaction. This is the + // common case for Authority (always) and Quorum deciding-vote. + // + // Four outcomes are possible: + // - stageReceiptStaged: count 2 transitions, log success. + // - stageReceiptDivergent: app set INOPERABLE; surface the + // handler error (if any) and DO NOT fall through. + // - stageReceiptDBPending: receipt matched but the atomic + // DB write failed; defer to the next tick — falling back + // to UpdateEpochWithSubmittedClaim would hide the STAGED + // signal. + // - stageReceiptNoMatch: Quorum non-deciding submit; fall back + // to the plain COMPUTED → SUBMITTED update. + outcome := stageReceiptNoMatch + var divErr error + if app != nil { + outcome, divErr = s.tryStageFromReceipt(receipt, app, computedEpoch) + } + switch outcome { + case stageReceiptStaged: + confirmed += 2 + s.Logger.Info("Claim submitted (and staged in same tx)", + "app", appAddress, + "receipt_block_number", receipt.BlockNumber, + "outputs_merkle_root", hashToHex(computedEpoch.OutputsMerkleRoot), + "last_block", computedEpoch.LastBlock, + "tx", txHash) + case stageReceiptDivergent: + s.Logger.Warn("Submit tx revealed divergent staging; app set INOPERABLE", + "app", appAddress, + "epoch_index", computedEpoch.Index, + "last_block", computedEpoch.LastBlock, + "tx", txHash) + delete(computedEpochs, key) + delete(s.claimsInFlight, key) + if divErr != nil { + return confirmed, fmt.Errorf("handling staging divergence for epoch %d (%d): %w", + computedEpoch.Index, computedEpoch.VirtualIndex, divErr) + } + continue + case stageReceiptPrecondFailure: + s.Logger.Warn("Submit tx receipt matched our epoch but local row is missing fields; app set FAILED", + "app", appAddress, + "epoch_index", computedEpoch.Index, + "last_block", computedEpoch.LastBlock, + "tx", txHash) + delete(computedEpochs, key) + delete(s.claimsInFlight, key) + if divErr != nil { + return confirmed, fmt.Errorf("marking app FAILED on matcher pre-cond failure for epoch %d (%d): %w", + computedEpoch.Index, computedEpoch.VirtualIndex, divErr) } + continue + case stageReceiptDBPending: + // Receipt was a valid match but the atomic write failed. + // Leave the in-flight key in place so the next tick polls + // the same receipt and retries; leave computedEpochs[key] + // alone so cleanupOrphanedInFlight doesn't drop the + // in-flight tracking. Surface the error so the tick's err + // slice flags this as a problem. + s.Logger.Warn("staging fast-path: atomic DB write failed; deferring to next tick", + "app", appAddress, + "epoch_index", computedEpoch.Index, + "last_block", computedEpoch.LastBlock, + "tx", txHash, + "error", divErr) + return confirmed, divErr + case stageReceiptNoMatch: + err = s.repository.UpdateEpochWithSubmittedClaim( + s.Context, + computedEpoch.ApplicationID, + computedEpoch.Index, + receipt.TxHash, + ) + if err != nil { + return confirmed, fmt.Errorf("updating epoch %d (%d) with submitted claim: %w", computedEpoch.Index, computedEpoch.VirtualIndex, err) + } + confirmed++ + s.Logger.Info("Claim submitted", + "app", appAddress, + "receipt_block_number", receipt.BlockNumber, + "outputs_merkle_root", hashToHex(computedEpoch.OutputsMerkleRoot), + "last_block", computedEpoch.LastBlock, + "tx", txHash) } - // epoch is no longer "computed" and is now "submitted" (or accepted). + // epoch is no longer "computed". delete(computedEpochs, key) } else { s.Logger.Warn("unexpected, claim in flight is not a computed epoch.", @@ -185,55 +353,281 @@ func (s *Service) checkClaimsInFlight( return confirmed, nil } -// tryAcceptFromReceipt parses a transaction receipt for a ClaimAccepted event -// matching the given epoch. If found and valid, it transitions the epoch -// directly to accepted in the database, returning true. This is an optimization -// for Authority consensus, which emits both ClaimSubmitted and ClaimAccepted -// atomically in the same transaction. +// stageReceiptOutcome describes how tryStageFromReceipt interpreted a submit +// transaction receipt. The caller uses it to decide whether to log success, +// log divergence, or fall through to the plain SUBMITTED update. +type stageReceiptOutcome int + +const ( + // stageReceiptNoMatch — the receipt contained no ClaimStaged event for + // our epoch. Normal for Quorum non-deciding submits; caller should fall + // back to the plain COMPUTED → SUBMITTED update. + stageReceiptNoMatch stageReceiptOutcome = iota + + // stageReceiptStaged — happy path. The receipt had a matching ClaimStaged + // event for our (app, lpbn, outputs, machine); the DB was advanced + // COMPUTED → SUBMITTED → STAGED atomically. + stageReceiptStaged + + // stageReceiptDivergent — the receipt had a ClaimStaged event for our + // (app, lpbn) but with a divergent (outputs|machine) tuple. The app has + // been set INOPERABLE with the appropriate lifecycle-stage reason. + stageReceiptDivergent + + // stageReceiptPrecondFailure — the matcher couldn't run because the + // local epoch row is missing outputs_merkle_root or machine_hash. The + // app has been set FAILED (recoverable: operator inspects the row). + stageReceiptPrecondFailure + + // stageReceiptDBPending — the receipt was a valid match but the + // atomic COMPUTED → SUBMITTED → STAGED write failed (transient DB + // error). The caller must NOT fall back to UpdateEpochWithSubmittedClaim: + // a partial transition would hide the STAGED event from this tick's + // pipeline and from the next tick's staging scan would have to + // re-discover it from chain — a surface signal that goes silent under + // correlated DB outages. Instead the caller logs WARN, leaves the + // in-flight tracking intact, and retries on the next tick. + stageReceiptDBPending +) + +// tryStageFromReceipt parses a transaction receipt for a ClaimStaged event +// matching the given epoch. In v3: +// - Authority's submitClaim ALWAYS emits ClaimSubmitted + ClaimStaged in +// the same transaction (Authority.sol:35-66). +// - Quorum's submitClaim emits ClaimStaged in the same tx ONLY when the +// submission is the deciding vote (Quorum.sol:116-123). // -// Errors are logged but not propagated — the normal acceptance scan on the -// next tick will handle the transition if this fast path fails. -func (s *Service) tryAcceptFromReceipt( +// In both cases this function records the transition COMPUTED → SUBMITTED → +// STAGED atomically via UpdateEpochThroughStaging, so a crash between the +// status flips cannot leave the DB inconsistent with the chain. +// +// Note: in v3 the contract NEVER emits ClaimAccepted in the same tx as +// ClaimSubmitted, regardless of claimStagingPeriod. The acceptClaim path is +// always a separate transaction. The previous tryAcceptFromReceipt +// implementation was based on an incorrect mental model that no longer +// applies. +func (s *Service) tryStageFromReceipt( receipt *types.Receipt, app *model.Application, epoch *model.Epoch, -) bool { +) (stageReceiptOutcome, error) { ic, err := iconsensus.NewIConsensus(app.IConsensusAddress, nil) if err != nil { - s.Logger.Warn("Authority fast-accept: failed to create ABI binding", + s.Logger.Warn("staging fast-path: failed to create ABI binding", "app", app.IApplicationAddress, "error", err) - return false + return stageReceiptNoMatch, nil } for _, log := range receipt.Logs { - event, err := ic.ParseClaimAccepted(*log) + // Only consider logs emitted by the consensus contract. A future + // contract upgrade (or any tx whose call graph includes callbacks + // into attacker-controlled code) could emit a log with the same + // topic hash from a different address; the ABI binding's parser + // matches on topic[0] alone, so without this filter a forged event + // could be mis-attributed. + if log.Address != app.IConsensusAddress { + continue + } + event, err := ic.ParseClaimStaged(*log) if err != nil { - continue // not a ClaimAccepted event + continue // not a ClaimStaged event } - if !claimAcceptedEventMatches(app, epoch, event) { + if !claimStagedEventMatchesEpoch(app, epoch, event) { continue } - err = s.repository.UpdateEpochWithAcceptedClaim( - s.Context, epoch.ApplicationID, epoch.Index) + matches, ok := claimStagedEventMatches(app, epoch, event) + if !ok { + pErr := s.markMatcherPrecondFailure(app, epoch, "tryStageFromReceipt") + return stageReceiptPrecondFailure, pErr + } + if !matches { + // Divergence: same (app, lpbn) but different outputs or machine + // root. Authority case: owner produced a different state. Quorum + // case: this submit-tx is from a different submitter (not us); + // we should not be seeing it in our own receipt. Either way it's + // a fault. Mark INOPERABLE and surface the outcome so the + // caller does NOT log success and does NOT fall through. + divErr := s.markStagingDivergence(app, epoch, event, "tryStageFromReceipt") + return stageReceiptDivergent, divErr + } + err = s.repository.UpdateEpochThroughStaging( + s.Context, epoch.ApplicationID, epoch.Index, + receipt.TxHash, log.BlockNumber) if err != nil { - s.Logger.Warn("Authority fast-accept: DB update failed, "+ - "will retry via normal acceptance scan", - "app", app.IApplicationAddress, - "epoch", epoch.Index, "error", err) - return false + return stageReceiptDBPending, fmt.Errorf( + "UpdateEpochThroughStaging (app=%s, epoch=%d): %w", + app.IApplicationAddress, epoch.Index, err) } - s.Logger.Info("Claim accepted (Authority fast path)", + s.Logger.Info("Claim staged (fast path)", "app", app.IApplicationAddress, "epoch_index", epoch.Index, - "claim_hash", hashToHex(epoch.OutputsMerkleRoot), + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), "last_block", epoch.LastBlock, + "staged_at_block", log.BlockNumber, "tx", receipt.TxHash) - return true + return stageReceiptStaged, nil } - // No matching ClaimAccepted event found. This is unexpected for Authority - // but not fatal — the normal acceptance scan will handle it. - s.Logger.Warn("Authority fast-accept: ClaimAccepted event not found in receipt", - "app", app.IApplicationAddress, "tx", receipt.TxHash) - return false + // No matching ClaimStaged event in this receipt. Normal for Quorum + // non-deciding submissions; the next-tick staging scan will catch the + // staging event when it arrives. + return stageReceiptNoMatch, nil +} + +// divergenceStage names the lifecycle point where a divergent on-chain +// observation was detected. Used to construct the per-stage reason-key +// ("authority_divergence_at_submission", "quorum_divergence_at_staging", …) +// surfaced to operators and the bug taxonomy. +type divergenceStage int + +const ( + divergenceStageSubmit divergenceStage = iota + divergenceStageStaging + divergenceStageAcceptance +) + +func (d divergenceStage) String() string { + switch d { + case divergenceStageSubmit: + return "submission" + case divergenceStageStaging: + return "staging" + case divergenceStageAcceptance: + return "acceptance" + default: + return fmt.Sprintf("unknown(%d)", int(d)) + } +} + +// divergenceBucket returns the `_divergence_at_` reason-key +// surfaced as the prefix of every divergence reason string. Centralizing +// construction here means adding a new stage or consensus type is one switch +// arm, not six sprintf sites scattered across three handlers. +func divergenceBucket(c model.Consensus, stage divergenceStage) string { + consensus := "quorum" + if c == model.Consensus_Authority { + consensus = "authority" + } + return fmt.Sprintf("%s_divergence_at_%s", consensus, stage) +} + +// markDivergence is the single entry point that decides whether a divergent +// observation is a per-epoch reject (Quorum, before our local CLAIM_STAGED) +// or an app-level INOPERABLE transition (everything else). +// +// The per-epoch reject path applies only to Quorum from the staging and +// acceptance stages while our local epoch is still pre-STAGED. In that +// window a different validator's accepted claim can still be reconciled as +// "we lost the vote" without contaminating our local state. From STAGED +// onward, any divergent observation is app-level (our staged fact is +// inconsistent with the new on-chain reality). For Authority, divergence is +// always app-level — no competing-vote semantics exist. +func (s *Service) markDivergence( + app *model.Application, + epoch *model.Epoch, + stage divergenceStage, + reasonText string, +) error { + rejectable := app.ConsensusType == model.Consensus_Quorum && + stage != divergenceStageSubmit && + epoch.Status != model.EpochStatus_ClaimStaged + if rejectable { + return s.rejectEpochAndSetApplicationInoperable(app, epoch, reasonText) + } + return s.setApplicationInoperable(s.Context, app, "%s", reasonText) +} + +// markStagingDivergence sets the application INOPERABLE for a divergent +// ClaimStaged event observed for our epoch. Routes through markDivergence +// for the Quorum-vs-Authority and pre/post-STAGED branching. +func (s *Service) markStagingDivergence( + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimStaged, + site string, +) error { + ourMMR := common.Hash{} + if epoch.MachineHash != nil { + ourMMR = *epoch.MachineHash + } + reason := fmt.Sprintf( + "%s: divergent ClaimStaged observed at %s. "+ + "on-chain machineMerkleRoot=%s, our machineMerkleRoot=%s, "+ + "epoch %d (lastBlock %d). "+ + "Guardian SHOULD call foreclose() on the application contract "+ + "before staged_at_block + claim_staging_period elapses, "+ + "after which outputs from this divergent claim become executable.", + divergenceBucket(app.ConsensusType, divergenceStageStaging), site, + common.BytesToHash(event.MachineMerkleRoot[:]).Hex(), + ourMMR.Hex(), + epoch.Index, epoch.LastBlock, + ) + return s.markDivergence(app, epoch, divergenceStageStaging, reason) +} + +// markSubmittedDivergence sets the application INOPERABLE for a divergent +// ClaimSubmitted observation. Submit-stage divergence is always app-level +// (markDivergence's rejectable check excludes the submit stage), reflecting +// that even Quorum cannot recover from a submit-time mismatch — once Quorum +// staged a divergent claim, our submit was wrong even if we hadn't broadcast +// yet. +func (s *Service) markSubmittedDivergence( + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimSubmitted, + site string, +) error { + ourOutputs := common.Hash{} + if epoch.OutputsMerkleRoot != nil { + ourOutputs = *epoch.OutputsMerkleRoot + } + ourMMR := common.Hash{} + if epoch.MachineHash != nil { + ourMMR = *epoch.MachineHash + } + reason := fmt.Sprintf( + "%s: divergent ClaimSubmitted observed at %s. "+ + "on-chain outputsMerkleRoot=%s machineMerkleRoot=%s, "+ + "our outputsMerkleRoot=%s machineMerkleRoot=%s, "+ + "epoch %d (lastBlock %d).", + divergenceBucket(app.ConsensusType, divergenceStageSubmit), site, + common.BytesToHash(event.OutputsMerkleRoot[:]).Hex(), + common.BytesToHash(event.MachineMerkleRoot[:]).Hex(), + ourOutputs.Hex(), ourMMR.Hex(), + epoch.Index, epoch.LastBlock, + ) + return s.markDivergence(app, epoch, divergenceStageSubmit, reason) +} + +// markAcceptedDivergence sets the application INOPERABLE for a divergent +// ClaimAccepted observation. Routes through markDivergence for the +// per-consensus / pre-vs-post-STAGED branching. +func (s *Service) markAcceptedDivergence( + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimAccepted, + site string, +) error { + ourOutputs := common.Hash{} + if epoch.OutputsMerkleRoot != nil { + ourOutputs = *epoch.OutputsMerkleRoot + } + ourMMR := common.Hash{} + if epoch.MachineHash != nil { + ourMMR = *epoch.MachineHash + } + reason := fmt.Sprintf( + "%s: divergent ClaimAccepted observed at %s. "+ + "on-chain outputsMerkleRoot=%s machineMerkleRoot=%s, "+ + "our outputsMerkleRoot=%s machineMerkleRoot=%s, "+ + "epoch %d (lastBlock %d). "+ + "Outputs from this divergent claim are now executable on-chain; "+ + "manual remediation required.", + divergenceBucket(app.ConsensusType, divergenceStageAcceptance), site, + common.BytesToHash(event.OutputsMerkleRoot[:]).Hex(), + common.BytesToHash(event.MachineMerkleRoot[:]).Hex(), + ourOutputs.Hex(), ourMMR.Hex(), + epoch.Index, epoch.LastBlock, + ) + return s.markDivergence(app, epoch, divergenceStageAcceptance, reason) } func (s *Service) findClaimSubmittedEventAndSucc( @@ -245,8 +639,7 @@ func (s *Service) findClaimSubmittedEventAndSucc( toBlock uint64, ) ( *iconsensus.IConsensus, - *iconsensus.IConsensusClaimSubmitted, - *iconsensus.IConsensusClaimSubmitted, + []*iconsensus.IConsensusClaimSubmitted, error, ) { err := checkEpochSequenceConstraint(prevEpoch, currEpoch) @@ -259,15 +652,22 @@ func (s *Service) findClaimSubmittedEventAndSucc( prevEpoch.Index, prevEpoch.VirtualIndex, ) - return nil, nil, nil, err + return nil, nil, err } - ic, prevClaimSubmissionEvent, currClaimSubmissionEvent, err := + ic, events, err := s.blockchain.findClaimSubmittedEventAndSucc(ctx, app, prevEpoch, fromBlock, toBlock) if err != nil { - return nil, nil, nil, fmt.Errorf("finding claim submitted event for epoch %d (%d): %w", prevEpoch.Index, prevEpoch.VirtualIndex, err) + return nil, nil, fmt.Errorf("finding claim submitted event for epoch %d (%d): %w", prevEpoch.Index, prevEpoch.VirtualIndex, err) } + var prevClaimSubmissionEvent *iconsensus.IConsensusClaimSubmitted + for _, event := range events { + if claimSubmittedEventMatchesEpoch(app, prevEpoch, event) { + prevClaimSubmissionEvent = event + break + } + } if prevClaimSubmissionEvent == nil { err = s.setApplicationInoperable( s.Context, @@ -276,10 +676,15 @@ func (s *Service) findClaimSubmittedEventAndSucc( prevEpoch.Index, prevEpoch.VirtualIndex, ) - return nil, nil, nil, err + return nil, nil, err } - if !claimSubmittedEventMatches(app, prevEpoch, prevClaimSubmissionEvent) { + matches, ok := claimSubmittedEventMatches(app, prevEpoch, prevClaimSubmissionEvent) + if !ok { + err = s.markMatcherPrecondFailure(app, prevEpoch, "findClaimSubmittedEventAndSucc(prev)") + return nil, nil, err + } + if !matches { err = s.setApplicationInoperable( s.Context, app, @@ -288,9 +693,85 @@ func (s *Service) findClaimSubmittedEventAndSucc( prevEpoch.VirtualIndex, prevClaimSubmissionEvent.Raw.TxHash, ) - return nil, nil, nil, err + return nil, nil, err + } + return ic, events, nil +} + +func (s *Service) classifyClaimSubmittedEvents( + app *model.Application, + epoch *model.Epoch, + events []*iconsensus.IConsensusClaimSubmitted, + site string, +) ( + *iconsensus.IConsensusClaimSubmitted, + bool, + error, +) { + for _, event := range events { + if !claimSubmittedEventMatchesEpoch(app, epoch, event) { + continue + } + s.Logger.Debug("Found ClaimSubmitted Event", + "app", event.AppContract, + "outputs_merkle_root", fmt.Sprintf("%x", event.OutputsMerkleRoot), + "last_block", event.LastProcessedBlockNumber.Uint64(), + ) + matches, ok := claimSubmittedEventMatches(app, epoch, event) + if !ok { + return nil, true, s.markMatcherPrecondFailure(app, epoch, site) + } + if matches { + if !s.shouldRecordMatchingClaimSubmitted(app, epoch, event) { + continue + } + return event, false, nil + } + + // Authority: any (outputs, machine) mismatch is terminal. + // Quorum: outputs mismatch alone is an honest-different-vote + // (row 5c, NOT terminal); only the adversarial-proof case + // (outputs == ours, machine != ours) is terminal here. + ourOutputs := common.Hash{} + if epoch.OutputsMerkleRoot != nil { + ourOutputs = *epoch.OutputsMerkleRoot + } + outputsMatch := common.Hash(event.OutputsMerkleRoot) == ourOutputs + if app.ConsensusType == model.Consensus_Quorum && !outputsMatch { + s.Logger.Info("Quorum: observed ClaimSubmitted with different outputs "+ + "(another validator's honest vote); continuing local submission path", + "app", app.IApplicationAddress, + "event_outputs", fmt.Sprintf("%x", event.OutputsMerkleRoot), + "our_outputs", ourOutputs.Hex(), + "last_block", epoch.LastBlock, + ) + continue + } + return nil, true, s.markSubmittedDivergence(app, epoch, event, site) + } + return nil, false, nil +} + +func (s *Service) shouldRecordMatchingClaimSubmitted( + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimSubmitted, +) bool { + if app.ConsensusType != model.Consensus_Quorum || !s.submissionEnabled { + return true + } + submitter, ok := s.blockchain.claimSubmitterAddress() + if !ok || event.Submitter == (common.Address{}) || event.Submitter == submitter { + return true } - return ic, prevClaimSubmissionEvent, currClaimSubmissionEvent, nil + s.Logger.Info("Quorum: observed matching ClaimSubmitted from another validator; submitting local vote", + "app", app.IApplicationAddress, + "event_submitter", event.Submitter, + "our_submitter", submitter, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return false } // transition epoch claims from computed to submitted. @@ -308,10 +789,9 @@ func (s *Service) submitClaimsAndUpdateDatabase( transitions := confirmed errs := []error{} - // check computed epochs. NOTE: map mutation + iteration is safe in Go for key, currEpoch := range computedEpochs { var ic *iconsensus.IConsensus - var currEvent *iconsensus.IConsensusClaimSubmitted + var submittedEvents []*iconsensus.IConsensusClaimSubmitted if _, isClaimInFlight := s.claimsInFlight[key]; isClaimInFlight { continue @@ -321,17 +801,77 @@ func (s *Service) submitClaimsAndUpdateDatabase( prevEpoch, prevEpochExists := acceptedOrSubmittedEpochs[key] // check address for changes - if err := s.checkConsensusForAddressChange(app); err != nil { + if err := s.checkConsensusForAddressChange(app, defaultBlockNumber); err != nil { delete(computedEpochs, key) errs = append(errs, err) continue } + + // Scan for ClaimAccepted events at this epoch's lpbn. Two cases: + // - matching MMR → deep reader-mode catch-up: reconcile to ACCEPTED. + // - divergent MMR → a foreign claim was accepted; ours is unreachable. + // Set INOPERABLE with the per-consensus *_divergence_at_acceptance + // reason. Only Quorum can legitimately reach this from CLAIM_COMPUTED + // (honest-different-vote that we didn't follow); Authority typically + // goes INOPERABLE at the submission stage first, but the path is + // handled uniformly. + acceptScanFrom := currEpoch.LastBlock + 1 + if prevEpochExists { + acceptScanFrom = prevEpoch.LastBlock + 1 + } + _, foreignAccepted, _, err := s.blockchain.findClaimAcceptedEventAndSucc( + s.Context, app, currEpoch, acceptScanFrom, defaultBlockNumber.Uint64(), + ) + if err != nil { + delete(computedEpochs, key) + errs = append(errs, fmt.Errorf( + "scanning ClaimAccepted for computed epoch %d (%d): %w", + currEpoch.Index, currEpoch.VirtualIndex, err)) + continue + } + if foreignAccepted != nil { + matches, ok := claimAcceptedEventMatches(app, currEpoch, foreignAccepted) + if !ok { + if perr := s.markMatcherPrecondFailure(app, currEpoch, "submitClaimsAndUpdateDatabase(ClaimAccepted)"); perr != nil { + errs = append(errs, perr) + } + delete(computedEpochs, key) + continue + } + if matches { + acceptedTxHash := foreignAccepted.Raw.TxHash + if uerr := s.repository.UpdateEpochWithAcceptedClaim( + s.Context, currEpoch.ApplicationID, currEpoch.Index, &acceptedTxHash); uerr != nil { + delete(computedEpochs, key) + errs = append(errs, fmt.Errorf( + "reconciling COMPUTED→ACCEPTED for epoch %d (%d): %w", + currEpoch.Index, currEpoch.VirtualIndex, uerr)) + continue + } + delete(computedEpochs, key) + transitions++ + s.Logger.Info("ClaimAccepted observed for computed epoch (deep catch-up; reconciled)", + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "tx", foreignAccepted.Raw.TxHash, + "last_block", currEpoch.LastBlock, + ) + continue + } + derr := s.markAcceptedDivergence(app, currEpoch, foreignAccepted, "submitClaimsAndUpdateDatabase") + delete(computedEpochs, key) + if derr != nil { + errs = append(errs, derr) + } + continue + } + if prevEpochExists { - ic, _, currEvent, err = s.findClaimSubmittedEventAndSucc( + ic, submittedEvents, err = s.findClaimSubmittedEventAndSucc( s.Context, app, prevEpoch, currEpoch, prevEpoch.LastBlock+1, defaultBlockNumber.Uint64(), ) } else { - ic, currEvent, _, err = s.blockchain.findClaimSubmittedEventAndSucc( + ic, submittedEvents, err = s.blockchain.findClaimSubmittedEventAndSucc( s.Context, app, currEpoch, currEpoch.LastBlock+1, defaultBlockNumber.Uint64(), ) } @@ -341,26 +881,19 @@ func (s *Service) submitClaimsAndUpdateDatabase( continue } + currEvent, shouldDrop, err := s.classifyClaimSubmittedEvents( + app, currEpoch, submittedEvents, "submitClaimsAndUpdateDatabase(ClaimSubmitted)") + if err != nil { + errs = append(errs, err) + } + if shouldDrop { + delete(computedEpochs, key) + continue + } if currEvent != nil { - s.Logger.Debug("Found ClaimSubmitted Event", - "app", currEvent.AppContract, - "claim_hash", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), - "last_block", currEvent.LastProcessedBlockNumber.Uint64(), - ) - if !claimSubmittedEventMatches(app, currEpoch, currEvent) { - err = s.setApplicationInoperable( - s.Context, - app, - "computed claim does not match event. computed_claim=%v, current_event=%v", - currEpoch, currEvent, - ) - delete(computedEpochs, key) - errs = append(errs, err) - continue - } s.Logger.Debug("Updating claim status to submitted", "app", app.IApplicationAddress, - "claim_hash", hashToHex(currEpoch.OutputsMerkleRoot), + "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), "last_block", currEpoch.LastBlock, ) txHash := currEvent.Raw.TxHash @@ -380,114 +913,177 @@ func (s *Service) submitClaimsAndUpdateDatabase( s.Logger.Info("Claim previously submitted", "app", app.IApplicationAddress, "event_block_number", currEvent.Raw.BlockNumber, - "claim_hash", hashToHex(currEpoch.OutputsMerkleRoot), + "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), "last_block", currEpoch.LastBlock, ) - } else { - if s.submissionEnabled { - if prevEpoch != nil && prevEpoch.Status != model.EpochStatus_ClaimAccepted { - s.Logger.Debug("Waiting previous claim to be accepted before submitting new one. Previous:", - "app", app.IApplicationAddress, - "claim_hash", hashToHex(prevEpoch.OutputsMerkleRoot), - "last_block", prevEpoch.LastBlock, - ) - continue - } - s.Logger.Debug("Submitting claim to blockchain", + continue + } + + if s.submissionEnabled { + if prevEpoch != nil && prevEpoch.Status != model.EpochStatus_ClaimAccepted { + s.Logger.Debug("Waiting previous claim to be accepted before submitting new one. Previous:", "app", app.IApplicationAddress, - "claim_hash", hashToHex(currEpoch.OutputsMerkleRoot), - "last_block", currEpoch.LastBlock, + "outputs_merkle_root", hashToHex(prevEpoch.OutputsMerkleRoot), + "last_block", prevEpoch.LastBlock, ) - txHash, err := s.blockchain.submitClaimToBlockchain(ic, app, currEpoch) - if err != nil { - // NotFirstClaim handling after restart. - // - // Gas estimation (eth_estimateGas) simulates - // the call before broadcasting, so the revert - // is caught without spending gas. This relies - // on txOpts.GasLimit == 0 (the default); if - // GasLimit were pre-set, the tx would skip - // estimation and revert on-chain. - // - // Authority: submitClaim checks a per-epoch - // bitmap. Any duplicate (same epoch, regardless - // of merkle root) reverts with NotFirstClaim. - // After restart this is benign — the node - // recomputed the same claim that was already - // on-chain. Both ClaimSubmitted and - // ClaimAccepted events were already emitted - // (Authority emits both atomically). - // - // Quorum: submitClaim first checks if this - // validator already voted for the SAME claim - // (same app + lastBlock + merkleRoot). If so, - // it silently returns — no revert, no event. - // It only reverts with NotFirstClaim when the - // validator voted for a DIFFERENT merkleRoot - // in the same epoch (checked via allVotes - // bitmap). After restart, this means the node - // recomputed a different claim hash than what - // it submitted pre-restart — a determinism - // violation. ClaimSubmitted was emitted for - // the original vote; ClaimAccepted is emitted - // only once a majority of validators agree. - if isNotFirstClaimError(err) { - if app.ConsensusType == model.Consensus_Quorum { - // Quorum only reverts with NotFirstClaim - // when the merkle root differs. This is - // unrecoverable: computation is expected - // to be deterministic, so recomputing - // will produce the same divergent hash. - err = s.setApplicationInoperable( - s.Context, - app, - "NotFirstClaim from Quorum consensus: "+ - "computed claim hash %s differs from "+ - "previously submitted claim for "+ - "epoch with last_block %d. "+ - "Possible determinism violation or "+ - "machine state corruption.", - hashToHex(currEpoch.OutputsMerkleRoot), - currEpoch.LastBlock, - ) - delete(computedEpochs, key) - errs = append(errs, err) - continue - } - s.Logger.Info( - "Claim already on-chain, "+ - "waiting for event sync", - "app", app.IApplicationAddress, - "claim_hash", - hashToHex(currEpoch.OutputsMerkleRoot), - "last_block", currEpoch.LastBlock, - ) - continue + continue + } + + // Pre-submit getClaim reconciliation. The chain may already + // be at STAGED or ACCEPTED for our (app, lpbn, machineMerkleRoot) + // tuple — e.g., across a restart that lost claimsInFlight, or + // when a bot accepted our staged claim while we were offline. + // Avoid re-broadcasting. This read-only reconciliation runs + // even on foreclosed apps so pre-foreclosure on-chain-accepted + // epochs are mirrored into the local DB as CLAIM_ACCEPTED; + // the claim-tx broadcast below is the only step that needs + // to be skipped for foreclosed apps. + if reconciled, errs2 := s.reconcileBeforeSubmit(app, currEpoch, defaultBlockNumber); reconciled { + delete(computedEpochs, key) + if len(errs2) > 0 { + errs = append(errs, errs2...) + } else { + transitions++ + } + continue + } else if len(errs2) > 0 { + delete(computedEpochs, key) + errs = append(errs, errs2...) + continue + } + + // Foreclosed apps: chain rejects submitClaim with + // ApplicationForeclosed. Skip the broadcast so we don't burn gas; + // read-only reconciliation remains responsible for mirroring any + // pre-foreclosure on-chain acceptance into the local DB. + if app.ForecloseBlock != 0 { + continue + } + + s.Logger.Debug("Submitting claim to blockchain", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), + "last_block", currEpoch.LastBlock, + ) + txHash, err := s.blockchain.submitClaimToBlockchain(ic, app, currEpoch) + if err != nil { + switch outcome, stateErr := s.handleSubmitClaimRevert(err, app, currEpoch); outcome { + case submitClaimAlreadyOnChain: + continue + case submitClaimRetryLater: + // Leave currEpoch in computedEpochs so the next tick retries. + continue + case submitClaimAppHalted: + delete(computedEpochs, key) + if stateErr != nil { + errs = append(errs, stateErr) } + continue + case submitClaimUnknown: delete(computedEpochs, key) errs = append(errs, err) continue + default: + // A new submitClaimRevertOutcome was added without a + // matching case. Surface the original error and the + // unhandled outcome so the next tick can re-try (the + // epoch stays in computedEpochs by design). + s.Logger.Error("unhandled submitClaimRevertOutcome; treating as retry-later", + "outcome", outcome, + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "error", err) + errs = append(errs, fmt.Errorf("unhandled submitClaimRevertOutcome %d: %w", outcome, err)) + continue } - s.claimsInFlight[key] = txHash - transitions++ } + s.claimsInFlight[key] = inFlightTx{ + txHash: txHash, + firstSeenBlock: defaultBlockNumber.Uint64(), + } + transitions++ } } return transitions, errs } -func (s *Service) findClaimAcceptedEventAndSucc( - ctx context.Context, +// reconcileBeforeSubmit performs the pre-submit getClaim() read and, if the +// chain shows the epoch is already STAGED or ACCEPTED, advances the local +// DB to match without re-broadcasting. Returns (reconciled, errs): +// - (true, nil): DB was advanced (STAGED or ACCEPTED). +// - (false, nil): chain is UNSTAGED; caller should proceed with broadcast. +// - (_, errs): one or more errors occurred; caller should drop epoch. +// +// The eth_call is pinned to the tick's finalized block, so all chain reads +// in one tick observe a consistent state. +func (s *Service) reconcileBeforeSubmit( app *model.Application, - prevEpoch *model.Epoch, currEpoch *model.Epoch, - fromBlock uint64, - toBlock uint64, -) ( - *iconsensus.IConsensus, - *iconsensus.IConsensusClaimAccepted, - *iconsensus.IConsensusClaimAccepted, - error, + defaultBlockNumber *big.Int, +) (bool, []error) { + claim, err := s.blockchain.getClaimStatus(s.Context, app, currEpoch, defaultBlockNumber) + if err != nil { + return false, []error{fmt.Errorf("pre-submit getClaim (app=%v, epoch=%d): %w", + app.IApplicationAddress, currEpoch.Index, err)} + } + switch claim.Status { + case claimStatusAccepted: + if vErr := s.verifyClaimOutputsMatch(app, currEpoch, claim, "reconcileBeforeSubmit"); vErr != nil { + return true, []error{vErr} + } + // txHash unknown here: getClaim is a view call that returns claim + // metadata but no event log. The column stays NULL; the relaxed + // invariant (checkEpochConstraint) permits this. + if err := s.repository.UpdateEpochWithAcceptedClaim( + s.Context, currEpoch.ApplicationID, currEpoch.Index, nil); err != nil { + return false, []error{fmt.Errorf("reconciling epoch %d (%d) to ACCEPTED: %w", + currEpoch.Index, currEpoch.VirtualIndex, err)} + } + s.Logger.Info("Claim already accepted on chain (reconciled pre-submit)", + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), + "last_block", currEpoch.LastBlock, + ) + return true, nil + case claimStatusStaged: + if vErr := s.verifyClaimOutputsMatch(app, currEpoch, claim, "reconcileBeforeSubmit"); vErr != nil { + return true, []error{vErr} + } + stagingBlock := claim.StagingBlockNumber.Uint64() + if err := s.repository.UpdateEpochReconciledStaged( + s.Context, currEpoch.ApplicationID, currEpoch.Index, stagingBlock); err != nil { + return false, []error{fmt.Errorf("reconciling epoch %d (%d) to STAGED: %w", + currEpoch.Index, currEpoch.VirtualIndex, err)} + } + s.Logger.Info("Claim already staged on chain (reconciled pre-submit)", + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), + "last_block", currEpoch.LastBlock, + "staged_at_block", stagingBlock, + ) + return true, nil + case claimStatusUnstaged: + return false, nil + default: + return false, []error{fmt.Errorf("unexpected ClaimStatus %d from getClaim for app=%v epoch=%d", + claim.Status, app.IApplicationAddress, currEpoch.Index)} + } +} + +func (s *Service) findClaimAcceptedEventAndSucc( + ctx context.Context, + app *model.Application, + prevEpoch *model.Epoch, + currEpoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimAccepted, + *iconsensus.IConsensusClaimAccepted, + error, ) { err := checkEpochSequenceConstraint(prevEpoch, currEpoch) if err != nil { @@ -518,7 +1114,12 @@ func (s *Service) findClaimAcceptedEventAndSucc( ) return nil, nil, nil, err } - if !claimAcceptedEventMatches(app, prevEpoch, prevClaimAcceptanceEvent) { + matches, ok := claimAcceptedEventMatches(app, prevEpoch, prevClaimAcceptanceEvent) + if !ok { + err = s.markMatcherPrecondFailure(app, prevEpoch, "findClaimAcceptedEventAndSucc(prev)") + return nil, nil, nil, err + } + if !matches { err = s.setApplicationInoperable( ctx, app, @@ -532,11 +1133,15 @@ func (s *Service) findClaimAcceptedEventAndSucc( return ic, prevClaimAcceptanceEvent, currClaimAcceptanceEvent, nil } -// transition claims from submitted to accepted. -// Returns the number of successful transitions and any errors. +// acceptClaimsAndUpdateDatabase transitions epochs from CLAIM_STAGED to +// CLAIM_ACCEPTED by observing ClaimAccepted events on chain. In v3 this is +// the normal terminal transition; in legacy / fast-paths the same method +// also handles CLAIM_SUBMITTED→CLAIM_ACCEPTED via the same +// UpdateEpochWithAcceptedClaim repository call (whose WHERE clause accepts +// either source). Returns the number of successful transitions and errors. func (s *Service) acceptClaimsAndUpdateDatabase( acceptedEpochs map[int64]*model.Epoch, - submittedEpochs map[int64]*model.Epoch, + stagedEpochs map[int64]*model.Epoch, apps map[int64]*model.Application, defaultBlockNumber *big.Int, ) (int, []error) { @@ -544,15 +1149,13 @@ func (s *Service) acceptClaimsAndUpdateDatabase( errs := []error{} var err error - // check submitted epochs. NOTE: map mutation + iteration is safe in Go - for key, currEpoch := range submittedEpochs { + for key, currEpoch := range stagedEpochs { var currEvent *iconsensus.IConsensusClaimAccepted app := apps[key] prevEpoch, prevEpochExists := acceptedEpochs[key] - // check address for changes - if err := s.checkConsensusForAddressChange(app); err != nil { - delete(submittedEpochs, key) + if err := s.checkConsensusForAddressChange(app, defaultBlockNumber); err != nil { + delete(stagedEpochs, key) errs = append(errs, err) continue } @@ -567,7 +1170,7 @@ func (s *Service) acceptClaimsAndUpdateDatabase( ) } if err != nil { - delete(submittedEpochs, key) + delete(stagedEpochs, key) errs = append(errs, err) continue } @@ -575,43 +1178,42 @@ func (s *Service) acceptClaimsAndUpdateDatabase( if currEvent != nil { s.Logger.Debug("Found ClaimAccepted Event", "app", currEvent.AppContract, - "claim_hash", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), + "outputs_merkle_root", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), "last_block", currEvent.LastProcessedBlockNumber.Uint64(), ) - if !claimAcceptedEventMatches(app, currEpoch, currEvent) { - s.Logger.Error("event mismatch", - "claim", currEpoch, - "event", currEvent, - "err", ErrEventMismatch, - ) - err := s.setApplicationInoperable( - s.Context, - app, - "event mismatch for epoch %v, event tx_hash: %v", - currEpoch.Index, - currEvent.Raw.TxHash, - ) - delete(submittedEpochs, key) + matches, ok := claimAcceptedEventMatches(app, currEpoch, currEvent) + if !ok { + if perr := s.markMatcherPrecondFailure(app, currEpoch, "acceptClaimsAndUpdateDatabase"); perr != nil { + errs = append(errs, perr) + } + delete(stagedEpochs, key) + continue + } + if !matches { + err := s.markAcceptedDivergence(app, currEpoch, currEvent, "acceptClaimsAndUpdateDatabase") + delete(stagedEpochs, key) errs = append(errs, err) continue } s.Logger.Debug("Updating claim status to accepted", "app", app.IApplicationAddress, - "claim_hash", hashToHex(currEpoch.OutputsMerkleRoot), + "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), "last_block", currEpoch.LastBlock, ) txHash := currEvent.Raw.TxHash - err = s.repository.UpdateEpochWithAcceptedClaim(s.Context, currEpoch.ApplicationID, currEpoch.Index) + err = s.repository.UpdateEpochWithAcceptedClaim(s.Context, currEpoch.ApplicationID, currEpoch.Index, &txHash) if err != nil { - delete(submittedEpochs, key) + delete(stagedEpochs, key) errs = append(errs, err) continue } transitions++ + delete(s.acceptsInFlight, key) + delete(s.acceptAttempts, acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index}) s.Logger.Info("Claim accepted", "app", currEvent.AppContract, "event_block_number", currEvent.Raw.BlockNumber, - "claim_hash", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), + "outputs_merkle_root", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), "last_block", currEvent.LastProcessedBlockNumber.Uint64(), "tx", txHash, ) @@ -620,6 +1222,443 @@ func (s *Service) acceptClaimsAndUpdateDatabase( return transitions, errs } +// stageClaimsAndUpdateDatabase transitions epochs from CLAIM_SUBMITTED to +// CLAIM_STAGED by observing ClaimStaged events on chain. Divergent staged +// events (machineMerkleRoot != ours) are handled via markStagingDivergence, +// which transitions the app to INOPERABLE with a reason that guides the +// guardian to call foreclose() before the staging period elapses. +func (s *Service) stageClaimsAndUpdateDatabase( + acceptedEpochs map[int64]*model.Epoch, + submittedEpochs map[int64]*model.Epoch, + apps map[int64]*model.Application, + defaultBlockNumber *big.Int, +) (int, []error) { + transitions := 0 + errs := []error{} + var err error + + for key, currEpoch := range submittedEpochs { + var currEvent *iconsensus.IConsensusClaimStaged + + app := apps[key] + prevEpoch, prevEpochExists := acceptedEpochs[key] + if err := s.checkConsensusForAddressChange(app, defaultBlockNumber); err != nil { + delete(submittedEpochs, key) + errs = append(errs, err) + continue + } + + var fromBlock uint64 + if prevEpochExists { + fromBlock = prevEpoch.LastBlock + 1 + } else { + fromBlock = currEpoch.LastBlock + 1 + } + + _, currEvent, _, err = s.blockchain.findClaimStagedEventAndSucc( + s.Context, app, currEpoch, fromBlock, defaultBlockNumber.Uint64(), + ) + if err != nil { + delete(submittedEpochs, key) + errs = append(errs, err) + continue + } + + if currEvent != nil { + s.Logger.Debug("Found ClaimStaged Event", + "app", currEvent.AppContract, + "outputs_merkle_root", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), + "machine_hash", fmt.Sprintf("%x", currEvent.MachineMerkleRoot), + "last_block", currEvent.LastProcessedBlockNumber.Uint64(), + ) + matches, ok := claimStagedEventMatches(app, currEpoch, currEvent) + if !ok { + if perr := s.markMatcherPrecondFailure(app, currEpoch, "stageClaimsAndUpdateDatabase"); perr != nil { + errs = append(errs, perr) + } + delete(submittedEpochs, key) + continue + } + if !matches { + err := s.markStagingDivergence(app, currEpoch, currEvent, "stageClaimsAndUpdateDatabase") + delete(submittedEpochs, key) + errs = append(errs, err) + continue + } + err = s.repository.UpdateEpochToStaged( + s.Context, currEpoch.ApplicationID, currEpoch.Index, + currEvent.Raw.BlockNumber) + if err != nil { + delete(submittedEpochs, key) + errs = append(errs, err) + continue + } + transitions++ + s.Logger.Info("Claim staged", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), + "last_block", currEpoch.LastBlock, + "staged_at_block", currEvent.Raw.BlockNumber, + ) + } + } + return transitions, errs +} + +// acceptStagedClaimsAndIssueAcceptTx scans CLAIM_STAGED epochs whose staging +// period has elapsed and, in submit mode, issues acceptClaim transactions +// for them. Pre-flight getClaim() check catches the case where someone else +// (front-runner) has already accepted, in which case the epoch transitions +// directly to CLAIM_ACCEPTED without issuing our own tx. +// +// In reader mode (submissionEnabled=false) this is a no-op — we wait to +// observe a ClaimAccepted event from another party. +func (s *Service) acceptStagedClaimsAndIssueAcceptTx( + stagedEpochs map[int64]*model.Epoch, + apps map[int64]*model.Application, + defaultBlockNumber *big.Int, +) (int, []error) { + transitions := 0 + errs := []error{} + + for key, currEpoch := range stagedEpochs { + // Already-issued accept tx waiting for confirmation: skip. + if _, inFlight := s.acceptsInFlight[key]; inFlight { + continue + } + + app := apps[key] + if err := s.checkConsensusForAddressChange(app, defaultBlockNumber); err != nil { + errs = append(errs, err) + continue + } + + if currEpoch.StagedAtBlock == nil { + // Invariant violation; the staged_requires_block CHECK should + // make this unreachable. + err := s.setApplicationInoperable(s.Context, app, + "epoch %d (%d) is CLAIM_STAGED but staged_at_block is nil", + currEpoch.Index, currEpoch.VirtualIndex) + errs = append(errs, err) + continue + } + + // staging period not yet elapsed: nothing to do this tick. + currentBlock := defaultBlockNumber.Uint64() + if currentBlock < *currEpoch.StagedAtBlock { + continue + } + if currentBlock-*currEpoch.StagedAtBlock < app.ClaimStagingPeriod { + continue + } + + if !s.submissionEnabled { + // Reader mode: do not issue acceptClaim; wait for someone else + // to do so, which we will observe via the ClaimAccepted scan. + continue + } + + // Pre-accept on-demand getClaim() pinned to the tick's block. + claim, err := s.blockchain.getClaimStatus(s.Context, app, currEpoch, defaultBlockNumber) + if err != nil { + errs = append(errs, fmt.Errorf("getClaim before acceptClaim (app=%v, epoch=%d): %w", + app.IApplicationAddress, currEpoch.Index, err)) + continue + } + switch claim.Status { + case claimStatusAccepted: // front-runner won; reconcile directly. + if vErr := s.verifyClaimOutputsMatch(app, currEpoch, claim, "acceptStagedClaimsAndIssueAcceptTx"); vErr != nil { + errs = append(errs, vErr) + continue + } + // txHash unknown: getClaim is a view call returning state, not + // the event log. Leaving column NULL is acceptable under the + // relaxed invariant. + err = s.repository.UpdateEpochWithAcceptedClaim( + s.Context, currEpoch.ApplicationID, currEpoch.Index, nil) + if err != nil { + errs = append(errs, err) + continue + } + transitions++ + delete(s.acceptAttempts, acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index}) + s.Logger.Info("Claim accepted (front-run; observed via getClaim)", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), + "last_block", currEpoch.LastBlock, + ) + continue + case claimStatusUnstaged: + // At the finalized block tag, UNSTAGED here is impossible under + // correct operation — the row is CLAIM_STAGED, so the chain must + // have observed our staging. Indicates misconfiguration (RPC + // endpoint mismatch, default block stale, node_config stale). + // Mark FAILED so the operator notices instead of an endless + // warn-log loop. Recoverable on operator fix + re-enable. + if ferr := appstatus.SetFailedf(s.Context, s.Logger, s.repository, app, + "getClaim returned UNSTAGED for epoch %d (%d) recorded as CLAIM_STAGED at block %d; "+ + "current block %d. Likely a misconfigured default block or stale node_config — "+ + "verify CARTESI_BLOCKCHAIN_DEFAULT_BLOCK is 'finalized' or 'safe' and that the "+ + "node_config row matches before re-enabling.", + currEpoch.Index, currEpoch.VirtualIndex, + *currEpoch.StagedAtBlock, currentBlock); ferr != nil { + errs = append(errs, fmt.Errorf( + "marking app FAILED on UNSTAGED pre-accept getClaim: %w", ferr)) + } + continue + case claimStatusStaged: + // Defense-in-depth: the chain reports STAGED for our (app, lpbn, + // machineMerkleRoot), so the stored outputs must match ours. + // A mismatch indicates determinism breakage at the submitter side. + if vErr := s.verifyClaimOutputsMatch(app, currEpoch, claim, "acceptStagedClaimsAndIssueAcceptTx"); vErr != nil { + errs = append(errs, vErr) + continue + } + // Fall through to broadcast. + default: + s.Logger.Warn("getClaim returned unexpected ClaimStatus; skipping this tick", + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "status", claim.Status, + ) + continue + } + + // Foreclosed apps: chain rejects acceptClaim with ApplicationForeclosed. + // Skip the broadcast so we don't burn gas; the pre-accept getClaim + // above has already reconciled any chain-side acceptance that + // happened before foreclosure. + if app.ForecloseBlock != 0 { + continue + } + + // Cap consecutive acceptClaim attempts so a persistently-reverting + // chain (misconfigured gas, nonce gap, key not authorized, etc.) + // can't burn gas forever. The cap is per (app, epoch); a fresh + // epoch reaching STAGED gets its own budget. Counter is cleared + // when the epoch transitions to ACCEPTED. + attemptKey := acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index} + s.acceptAttempts[attemptKey]++ + if uint64(s.acceptAttempts[attemptKey]) > s.maxAcceptAttempts { + if ferr := appstatus.SetFailedf(s.Context, s.Logger, s.repository, app, + "acceptClaim has failed %d consecutive times for epoch %d (%d); "+ + "inspect logs and the chain state, then re-enable. "+ + "Common causes: gas estimation issues, signer not authorised, "+ + "nonce gaps, or a fork inconsistent with the configured RPC.", + s.acceptAttempts[attemptKey], currEpoch.Index, currEpoch.VirtualIndex); ferr != nil { + errs = append(errs, fmt.Errorf("marking app FAILED after %d accept attempts: %w", + s.acceptAttempts[attemptKey], ferr)) + } + delete(s.acceptAttempts, attemptKey) + continue + } + + txHash, err := s.blockchain.acceptClaimOnBlockchain(app, currEpoch) + if err != nil { + outcome, stateErr := s.handleAcceptClaimRevert(err, app, currEpoch) + switch outcome { + case acceptClaimRetryLater: + continue + case acceptClaimAppHalted: + delete(s.acceptAttempts, attemptKey) + if stateErr != nil { + errs = append(errs, stateErr) + } + continue + case acceptClaimReconciledAccepted: + claim, gerr := s.blockchain.getClaimStatus(s.Context, app, currEpoch, defaultBlockNumber) + if gerr != nil { + errs = append(errs, fmt.Errorf("getClaim after acceptClaim front-run revert (app=%v, epoch=%d): %w", + app.IApplicationAddress, currEpoch.Index, gerr)) + continue + } + if claim.Status != claimStatusAccepted { + s.Logger.Warn("acceptClaim reverted as accepted, but pinned getClaim has not caught up", + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "claim_status", claim.Status, + ) + delete(s.acceptAttempts, attemptKey) + continue + } + if vErr := s.verifyClaimOutputsMatch(app, currEpoch, claim, "acceptClaimReconciledAccepted"); vErr != nil { + errs = append(errs, vErr) + continue + } + // txHash unknown: our acceptClaim reverted, so we have no + // successful tx of our own; the front-runner's tx hash is + // not surfaced by handleAcceptClaimRevert. Leave column NULL. + err = s.repository.UpdateEpochWithAcceptedClaim( + s.Context, currEpoch.ApplicationID, currEpoch.Index, nil) + if err != nil { + errs = append(errs, err) + continue + } + delete(s.acceptAttempts, attemptKey) + transitions++ + continue + case acceptClaimUnknown: + errs = append(errs, err) + continue + default: + // A new acceptClaimRevertOutcome was added without a + // matching case. Surface both the original error and + // the unhandled outcome; the next tick may retry via + // the existing acceptAttempts cap path. + s.Logger.Error("unhandled acceptClaimRevertOutcome; surfacing as error", + "outcome", outcome, + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "error", err) + errs = append(errs, fmt.Errorf("unhandled acceptClaimRevertOutcome %d: %w", outcome, err)) + continue + } + } + s.acceptsInFlight[key] = inFlightTx{ + txHash: txHash, + firstSeenBlock: defaultBlockNumber.Uint64(), + } + } + return transitions, errs +} + +// checkAcceptsInFlight polls in-flight acceptClaim transactions and, on +// confirmation, transitions the epoch to CLAIM_ACCEPTED. +func (s *Service) checkAcceptsInFlight( + stagedEpochs map[int64]*model.Epoch, + apps map[int64]*model.Application, + endBlock *big.Int, +) (int, error) { + confirmed := 0 + var pollErrs []error + for key, tx := range s.acceptsInFlight { + txHash := tx.txHash + ready, receipt, err := s.blockchain.pollTransaction(s.Context, txHash, endBlock) + if err != nil { + s.Logger.Warn("Accept submission receipt lookup failed; keeping tx in flight.", + "txHash", txHash, "err", err) + pollErrs = append(pollErrs, + fmt.Errorf("polling accept transaction %v: %w", txHash, err)) + continue + } + if !ready { + age := tx.ageAt(endBlock) + if receipt == nil && age >= maxInFlightReceiptNotFoundBlocks { + s.Logger.Warn("Accept submission receipt not found after timeout; retrying accept lifecycle.", + "app_id", key, + "txHash", txHash, + "age_blocks", age, + "timeout_blocks", maxInFlightReceiptNotFoundBlocks, + ) + delete(s.acceptsInFlight, key) + } + continue + } + + stagedEpoch, ok := stagedEpochs[key] + if !ok { + s.Logger.Warn("unexpected: accept-in-flight is not a staged epoch.", + "id", key, "tx", receipt.TxHash) + delete(s.acceptsInFlight, key) + continue + } + app := apps[key] + appAddress := common.Address{} + if app != nil { + appAddress = app.IApplicationAddress + } + + // Mined-revert classification. Our accept tx made it on chain but + // reverted. Re-read the contract state synchronously to decide what + // actually happened, rather than waiting a tick for the event scan. + if receipt.Status == 0 { + delete(s.acceptsInFlight, key) + if app == nil { + s.Logger.Warn("Accept tx reverted but app record missing; cannot classify.", + "id", key, "tx", txHash) + continue + } + claim, gerr := s.blockchain.getClaimStatus(s.Context, app, stagedEpoch, endBlock) + if gerr != nil { + s.Logger.Warn("Accept tx reverted; classifying getClaim failed, will retry next tick", + "app", appAddress, "tx", txHash, "err", gerr) + continue + } + switch claim.Status { + case claimStatusAccepted: + if vErr := s.verifyClaimOutputsMatch(app, stagedEpoch, claim, "checkAcceptsInFlight"); vErr != nil { + return confirmed, fmt.Errorf("accepted-outputs mismatch on accept-revert classification: %w", vErr) + } + // Front-runner accepted our staged claim first. Reconcile + // directly; no need to wait for the ClaimAccepted event scan. + // txHash unknown: getClaim is a view call returning state, + // not the front-runner's tx log. Leaving column NULL is + // acceptable under the relaxed invariant. + if uerr := s.repository.UpdateEpochWithAcceptedClaim( + s.Context, stagedEpoch.ApplicationID, stagedEpoch.Index, nil); uerr != nil { + return confirmed, fmt.Errorf("reconciling accept-revert front-run for epoch %d (%d): %w", + stagedEpoch.Index, stagedEpoch.VirtualIndex, uerr) + } + confirmed++ + delete(s.acceptAttempts, acceptAttemptKey{stagedEpoch.ApplicationID, stagedEpoch.Index}) + s.Logger.Info("Claim accepted by front-runner (own accept tx reverted; reconciled via getClaim)", + "app", appAddress, "tx", txHash, + "outputs_merkle_root", hashToHex(stagedEpoch.OutputsMerkleRoot), + "last_block", stagedEpoch.LastBlock) + delete(stagedEpochs, key) + case claimStatusStaged: + // Our claim is still staged. The revert was for a + // non-status-changing reason (e.g., period not elapsed if + // our clock drifted, or transient gas issue). The next tick's + // acceptStagedClaimsAndIssueAcceptTx will reissue. + if vErr := s.verifyClaimOutputsMatch(app, stagedEpoch, claim, "checkAcceptsInFlight"); vErr != nil { + return confirmed, fmt.Errorf("staged-outputs mismatch on accept-revert classification: %w", vErr) + } + s.Logger.Warn("Accept tx reverted but claim still STAGED on chain; will retry next tick", + "app", appAddress, "tx", txHash, + "last_block", stagedEpoch.LastBlock) + case claimStatusUnstaged: + // Contract disagrees with our STAGED record. At the + // finalized block tag this should be impossible; treat as + // recoverable misconfiguration and mark FAILED so the + // operator can investigate (default block, node_config + // staleness, RPC endpoint mismatch). + if ferr := appstatus.SetFailedf(s.Context, s.Logger, s.repository, app, + "accept tx %v reverted and getClaim reports UNSTAGED for our "+ + "(app, lpbn, machine) tuple — DB inconsistent with chain; check "+ + "default block and node_config, then re-enable", + txHash); ferr != nil { + return confirmed, fmt.Errorf("marking app FAILED after UNSTAGED accept revert: %w", ferr) + } + s.Logger.Warn("Accept tx reverted with UNSTAGED chain state — app set FAILED", + "app", appAddress, "tx", txHash) + } + continue + } + + // Normal flow: epoch's claim_transaction_hash was already set + // during the CLAIM_SUBMITTED transition. Pass nil to preserve it. + err = s.repository.UpdateEpochWithAcceptedClaim( + s.Context, stagedEpoch.ApplicationID, stagedEpoch.Index, nil) + if err != nil { + return confirmed, fmt.Errorf("updating epoch %d (%d) with accepted claim: %w", + stagedEpoch.Index, stagedEpoch.VirtualIndex, err) + } + confirmed++ + delete(s.acceptAttempts, acceptAttemptKey{stagedEpoch.ApplicationID, stagedEpoch.Index}) + + s.Logger.Info("Claim accepted (own tx)", + "app", appAddress, + "receipt_block_number", receipt.BlockNumber, + "outputs_merkle_root", hashToHex(stagedEpoch.OutputsMerkleRoot), + "last_block", stagedEpoch.LastBlock, + "tx", txHash) + delete(stagedEpochs, key) + delete(s.acceptsInFlight, key) + } + return confirmed, errors.Join(pollErrs...) +} + func (s *Service) setApplicationInoperable( ctx context.Context, app *model.Application, @@ -629,10 +1668,364 @@ func (s *Service) setApplicationInoperable( return appstatus.SetInoperablef(ctx, s.Logger, s.repository, app, reasonFmt, args...) } +func (s *Service) rejectEpochAndSetApplicationInoperable( + app *model.Application, + epoch *model.Epoch, + reason string, +) error { + s.Logger.Error("marking application as inoperable (irrecoverable)", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "reason", reason) + + err := s.repository.RejectEpochAndSetApplicationInoperable( + s.Context, app.ID, epoch.Index, reason) + reasonErr := errors.New(reason) + if err != nil { + s.Logger.Error("failed to reject epoch and update application state", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "epoch_index", epoch.Index, + "error", err) + return errors.Join(reasonErr, err) + } + + app.State = model.ApplicationState_Inoperable + app.Reason = &reason + epoch.Status = model.EpochStatus_ClaimRejected + return reasonErr +} + +// markMatcherPrecondFailure marks an application INOPERABLE when a full-tuple +// matcher reported ok=false — i.e., the local epoch row is missing fields +// (outputs_merkle_root or machine_hash) that the trigger guarantees on the +// transition INTO CLAIM_COMPUTED, so a nil value here indicates that a +// later setter cleared them. This is deterministic local state corruption: +// the app cannot safely continue because the node can no longer compare its +// claim facts to the on-chain event stream. +func (s *Service) markMatcherPrecondFailure(app *model.Application, epoch *model.Epoch, site string) error { + return s.setApplicationInoperable(s.Context, app, + "%s: cannot compare epoch %d (%d) against chain event — local row is missing "+ + "outputs_merkle_root or machine_hash. Inspect the epoch row before re-enabling.", + site, epoch.Index, epoch.VirtualIndex) +} + +// verifyClaimOutputsMatch checks that, when getClaim reports STAGED or +// ACCEPTED for our (app, lpbn, machineMerkleRoot) tuple, the contract's +// stagedOutputsMerkleRoot matches our local OutputsMerkleRoot. Since the +// query is keyed by our MMR and the contract record's outputs are taken from +// the submitter, a mismatch indicates determinism breakage between us and +// whoever last submitted our (app, lpbn, MMR) tuple, or an attempted spoof. +// Returns nil on match or when pre-conditions prevent the check; returns a +// non-nil error when the app has been transitioned to INOPERABLE. +func (s *Service) verifyClaimOutputsMatch( + app *model.Application, + epoch *model.Epoch, + claim iconsensus.IConsensusClaim, + site string, +) error { + if epoch.OutputsMerkleRoot == nil { + // markMatcherPrecondFailure handles this path elsewhere; here it's + // not actionable on its own. + return nil + } + chainStagedOutputs := common.BytesToHash(claim.StagedOutputsMerkleRoot[:]) + if chainStagedOutputs == *epoch.OutputsMerkleRoot { + return nil + } + status := fmt.Sprintf("status %d", claim.Status) + switch claim.Status { + case claimStatusStaged: + status = "STAGED" + case claimStatusAccepted: + status = "ACCEPTED" + } + return s.setApplicationInoperable(s.Context, app, + "chain_claim_outputs_mismatch: %s — getClaim returned %s for our "+ + "(app, lpbn, machineMerkleRoot) tuple but with stagedOutputsMerkleRoot=%s "+ + "while our local outputs_merkle_root is %s. Epoch %d (lastBlock %d). "+ + "Indicates determinism breakage between this node and the submitter of our "+ + "MMR; manual remediation required.", + site, status, + chainStagedOutputs.Hex(), + epoch.OutputsMerkleRoot.Hex(), + epoch.Index, epoch.LastBlock) +} + +// handleSubmitClaimRevert inspects a submitClaim error and, when it matches +// a known v3 IConsensus typed-error selector, performs the appropriate side +// effect (state transition or log) and reports an outcome describing what +// the caller should do next. +// +// Coverage of v3 typed errors that can be raised by submitClaim: +// +// - NotFirstClaim: Authority already has an epoch claim; Quorum already has +// this validator's vote for some claim in the epoch. Wait for event-sync; +// event matching, not the selector alone, decides divergence. +// - ApplicationForeclosed: retry later; evmreader records the foreclosure +// marker and the app remains ENABLED with foreclose_block set. +// - InvalidOutputsMerkleRootProofSize: INOPERABLE; local data corruption. +// - CallerIsNotValidator: FAILED; operator configuration error +// (recoverable once the right key is installed). +// +// `ClaimNotStaged` and `ClaimStagingPeriodNotOverYet` are only reachable via +// acceptClaim, not submitClaim — they are handled by the accept-path +// revert classifier. +func (s *Service) handleSubmitClaimRevert( + err error, + app *model.Application, + epoch *model.Epoch, +) (submitClaimRevertOutcome, error) { + switch { + case ethutil.IsNonceTooLowError(err): + // Transient broadcast race: the chain has already mined a tx + // with this EOA's nonce, so this attempt is rejected on + // submission. The most common trigger is a node restart that + // straddles an in-flight tx — the pre-restart broadcast landed, + // but the post-restart Tick re-derived the same nonce from + // PendingNonceAt before the node's pending view caught up. + // reconcileBeforeSubmit on the next tick reads the chain via + // getClaim and advances the local DB if our prior submission + // landed (STAGED/ACCEPTED), otherwise the next broadcast picks + // up a fresh nonce. Either path converges; defer to it. + s.Logger.Info( + "submitClaim broadcast rejected with 'nonce too low'; "+ + "deferring to the next tick's getClaim reconciliation", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return submitClaimRetryLater, nil + + case isCustomConsensusError(err, "NotFirstClaim"): + // Gas estimation (eth_estimateGas) simulates the call before + // broadcasting, so the revert is caught without spending gas. + // This relies on txOpts.GasLimit == 0 (the default); if GasLimit + // were pre-set, the tx would skip estimation and revert on-chain. + // + // Authority: submitClaim checks a per-epoch bitmap. Any duplicate + // (same epoch, regardless of merkle root) reverts with + // NotFirstClaim. After restart this is benign — the node + // recomputed the same claim that was already on-chain; both + // ClaimSubmitted and ClaimAccepted events were already emitted + // (Authority emits both atomically). + // + // Quorum v3 checks whether this validator already voted for any claim + // in the epoch. Unlike v2, a duplicate vote for the same machine root + // also reverts. Treat the selector as "prior vote exists" and let the + // ClaimSubmitted/ClaimStaged event reconciliation decide whether that + // prior vote matches our current computation or is divergent. + if app.ConsensusType == model.Consensus_Quorum { + s.Logger.Warn( + "submitClaim reverted with NotFirstClaim on Quorum; waiting for event reconciliation", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return submitClaimRetryLater, nil + } + s.Logger.Info("Claim already on-chain, waiting for event sync", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return submitClaimAlreadyOnChain, nil + + case isCustomConsensusError(err, "ApplicationForeclosed"): + // The EVM reader will record foreclose_block shortly. Keep the epoch; + // the next tick will skip broadcasts while read-only reconciliation + // advances any pre-cutoff work that was already accepted on-chain. + s.Logger.Warn("submitClaim reverted with ApplicationForeclosed; "+ + "awaiting Foreclosure observer to record the foreclosure marker", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return submitClaimRetryLater, nil + + case isCustomConsensusError(err, "InvalidOutputsMerkleRootProofSize"): + stateErr := s.setApplicationInoperable( + s.Context, app, + "submitClaim reverted with InvalidOutputsMerkleRootProofSize for "+ + "epoch %d (%d), last_block %d — outputs_merkle_proof in DB is "+ + "the wrong length for the machine memory tree.", + epoch.Index, epoch.VirtualIndex, epoch.LastBlock, + ) + return submitClaimAppHalted, stateErr + + case isCustomConsensusError(err, "CallerIsNotValidator"): + // Operator configuration error: the signing key is not a + // validator in this Quorum. Recoverable once the key is fixed, + // so use FAILED rather than INOPERABLE. + stateErr := appstatus.SetFailedf(s.Context, s.Logger, s.repository, app, + "submitClaim reverted with CallerIsNotValidator: the configured "+ + "signing key is not a member of the Quorum for app %s. "+ + "Check the validator key configuration.", + app.IApplicationAddress, + ) + return submitClaimAppHalted, stateErr + } + return submitClaimUnknown, nil +} + +// handleAcceptClaimRevert inspects an acceptClaim error and dispatches to +// the appropriate outcome: +// +// - ClaimNotStaged with claimStatus=ACCEPTED: front-runner won; reconcile +// to CLAIM_ACCEPTED. Cost: one wasted gas, no INOPERABLE. +// - ClaimNotStaged with claimStatus=UNSTAGED: should be impossible at the +// finalized block tag; loud warn and retry-later (operator must fix +// CARTESI_BLOCKCHAIN_DEFAULT_BLOCK). +// - ClaimStagingPeriodNotOverYet: our block-arithmetic was off; retry. +// - ApplicationForeclosed: evmreader records foreclosure; retry until the +// app is observed as foreclosed and future broadcasts are skipped. +func (s *Service) handleAcceptClaimRevert( + err error, + app *model.Application, + epoch *model.Epoch, +) (acceptClaimRevertOutcome, error) { + switch { + case ethutil.IsNonceTooLowError(err): + // Same shape as the submitClaim path: a tx with this EOA's + // nonce has already mined. The pre-broadcast getClaim() at the + // next tick reconciles via the front-runner branch if our + // prior acceptClaim actually landed (claimStatusAccepted), or + // re-broadcasts with a fresh nonce otherwise. The per-epoch + // acceptAttempts counter still increments here, capping + // runaway loops at s.maxAcceptAttempts (default 5, configurable). + s.Logger.Info( + "acceptClaim broadcast rejected with 'nonce too low'; "+ + "deferring to the next tick's getClaim reconciliation", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return acceptClaimRetryLater, nil + + case isCustomConsensusError(err, "ClaimNotStaged"): + status, ok := decodeClaimNotStagedStatus(err) + if !ok { + s.Logger.Warn("acceptClaim reverted with ClaimNotStaged but the status could not be decoded; "+ + "will retry next tick", + "app", app.IApplicationAddress, + "epoch_index", epoch.Index, + ) + return acceptClaimRetryLater, nil + } + switch status { + case claimStatusAccepted: + s.Logger.Info("Claim was accepted by a front-runner; reconciling", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return acceptClaimReconciledAccepted, nil + case claimStatusUnstaged: + s.Logger.Warn("acceptClaim reverted with ClaimNotStaged(UNSTAGED); "+ + "the on-chain status disagrees with our local view. "+ + "This can happen under reorgs when reading non-final blocks; "+ + "retry on the next tick.", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return acceptClaimRetryLater, nil + default: + s.Logger.Warn("acceptClaim reverted with ClaimNotStaged of unexpected status", + "app", app.IApplicationAddress, + "claimStatus", status, + "epoch_index", epoch.Index, + ) + return acceptClaimRetryLater, nil + } + + case isCustomConsensusError(err, "ClaimStagingPeriodNotOverYet"): + s.Logger.Warn("acceptClaim reverted with ClaimStagingPeriodNotOverYet; "+ + "local arithmetic disagrees with chain. Will retry next tick.", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return acceptClaimRetryLater, nil + + case isCustomConsensusError(err, "ApplicationForeclosed"): + s.Logger.Warn("acceptClaim reverted with ApplicationForeclosed; "+ + "awaiting Foreclosure observer to record the foreclosure marker", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(epoch.OutputsMerkleRoot), + "last_block", epoch.LastBlock, + ) + return acceptClaimRetryLater, nil + } + return acceptClaimUnknown, nil +} + +// decodeClaimNotStagedStatus extracts the uint8 claimStatus from a +// ClaimNotStaged revert's data field. Returns (status, true) on success, +// (0, false) on any decode failure — the caller should treat the latter as +// "unknown status, retry later" rather than mis-classifying. +// +// The ClaimNotStaged ABI (IConsensus.sol): +// +// error ClaimNotStaged( +// address appContract, +// uint256 lastProcessedBlockNumber, +// bytes32 machineMerkleRoot, +// enum ClaimStatus claimStatus); +// +// Unpacks via the generated ABI metadata so the layout is authoritative +// rather than positional. Defends against future ABI extensions and against +// RPCs that wrap or pad the revert payload. +func decodeClaimNotStagedStatus(err error) (uint8, bool) { + info, ok := ethutil.ExtractJSONErrorInfo(err) + if !ok || !info.HasData { + return 0, false + } + var raw []byte + switch d := info.Data.(type) { + case string: + raw = common.FromHex(d) + case []byte: + raw = d + default: + return 0, false + } + if len(raw) < 4 { + return 0, false + } + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + return 0, false + } + abiErr, ok := parsed.Errors["ClaimNotStaged"] + if !ok { + return 0, false + } + if !bytes.Equal(raw[:4], abiErr.ID[:4]) { + return 0, false + } + values, err := abiErr.Inputs.Unpack(raw[4:]) + if err != nil || len(values) < 4 { + return 0, false + } + // Read the kind via reflection rather than a direct `.(uint8)` cast. + // Today abigen renders the enum as a bare uint8, but a future abigen + // version may render `enum ClaimStatus` as a named type (e.g. + // `type ClaimStatus uint8`) — a direct uint8 cast would then fail and + // the caller would route to retry-later forever. Kind-based reading + // works for both shapes. + v := reflect.ValueOf(values[3]) + if !v.IsValid() || v.Kind() != reflect.Uint8 { + return 0, false + } + return uint8(v.Uint()), true +} + func (s *Service) checkConsensusForAddressChange( app *model.Application, + defaultBlockNumber *big.Int, ) error { - newConsensusAddress, err := s.blockchain.getConsensusAddress(s.Context, app) + newConsensusAddress, err := s.blockchain.getConsensusAddress(s.Context, app, defaultBlockNumber) if err != nil { return fmt.Errorf("getting consensus address for app %v: %w", app.IApplicationAddress, err) } @@ -663,9 +2056,15 @@ func checkEpochConstraint(epoch *model.Epoch) error { } } - mustHaveClaimTransactionHash := epoch.Status == model.EpochStatus_ClaimSubmitted || - epoch.Status == model.EpochStatus_ClaimAccepted - if mustHaveClaimTransactionHash { + // claim_transaction_hash is required for CLAIM_SUBMITTED — the node + // always sets it via UpdateEpochWithSubmittedClaim before transitioning. + // CLAIM_ACCEPTED is more permissive: catch-up reconciliations driven by + // a getClaim view call (front-runner won; accept-revert reclassified) + // do not have access to an on-chain event log, so the column can + // legitimately stay NULL. Catch-up paths that observed a ClaimAccepted + // event do set it via the (now optional) txHash argument; the field is + // thus informational, not load-bearing. + if epoch.Status == model.EpochStatus_ClaimSubmitted { if epoch.ClaimTransactionHash == nil { return fmt.Errorf("unexpected epoch state. missing claim_transaction_hash.") } @@ -697,24 +2096,36 @@ func checkEpochSequenceConstraint(prevEpoch *model.Epoch, currEpoch *model.Epoch return nil } -func claimSubmittedEventMatches(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimSubmitted) bool { +// The full-tuple matchers return (matches, ok). ok=false means a +// pre-condition could not be evaluated (nil epoch fields that should never +// be nil at this lifecycle stage). Callers MUST treat ok=false separately +// from matches=false — the former is local DB corruption, not on-chain +// divergence, and should route the app to FAILED rather than INOPERABLE. + +func claimSubmittedEventMatches(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimSubmitted) (matches bool, ok bool) { if application == nil || epoch == nil || event == nil { - return false + return false, false + } + if epoch.OutputsMerkleRoot == nil || epoch.MachineHash == nil { + return false, false } return application.IApplicationAddress == event.AppContract && - epoch.OutputsMerkleRoot != nil && *epoch.OutputsMerkleRoot == event.OutputsMerkleRoot && - epoch.LastBlock == event.LastProcessedBlockNumber.Uint64() + *epoch.MachineHash == event.MachineMerkleRoot && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64(), true } -func claimAcceptedEventMatches(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimAccepted) bool { +func claimAcceptedEventMatches(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimAccepted) (matches bool, ok bool) { if application == nil || epoch == nil || event == nil { - return false + return false, false + } + if epoch.OutputsMerkleRoot == nil || epoch.MachineHash == nil { + return false, false } return application.IApplicationAddress == event.AppContract && - epoch.OutputsMerkleRoot != nil && *epoch.OutputsMerkleRoot == event.OutputsMerkleRoot && - epoch.LastBlock == event.LastProcessedBlockNumber.Uint64() + *epoch.MachineHash == event.MachineMerkleRoot && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64(), true } // claimAcceptedEventMatchesEpoch checks if a ClaimAccepted event belongs to @@ -729,6 +2140,46 @@ func claimAcceptedEventMatchesEpoch(application *model.Application, epoch *model epoch.LastBlock == event.LastProcessedBlockNumber.Uint64() } +// claimSubmittedEventMatchesEpoch matches a ClaimSubmitted event by +// (app, lastBlock) only. Used at the filter layer so the event scan is +// independent of fields (machine_hash, outputs_merkle_root) whose mismatch +// is decided one layer up: the wrapper applies the full-tuple matcher to +// distinguish "ours" from "divergent". +func claimSubmittedEventMatchesEpoch(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimSubmitted) bool { + if application == nil || epoch == nil || event == nil { + return false + } + return application.IApplicationAddress == event.AppContract && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64() +} + +// claimStagedEventMatches checks the full (app, lpbn, outputs, machine) +// tuple — for the happy path where our claim was staged. +func claimStagedEventMatches(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimStaged) (matches bool, ok bool) { + if application == nil || epoch == nil || event == nil { + return false, false + } + if epoch.OutputsMerkleRoot == nil || epoch.MachineHash == nil { + return false, false + } + return application.IApplicationAddress == event.AppContract && + *epoch.OutputsMerkleRoot == event.OutputsMerkleRoot && + *epoch.MachineHash == event.MachineMerkleRoot && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64(), true +} + +// claimStagedEventMatchesEpoch matches only on (app, lastBlock). Used by +// the staged-event scan to surface ANY staging event for our epoch — the +// in-callback comparison against outputs/machine roots then decides whether +// it's our claim or a divergent one (which triggers F3-style INOPERABLE). +func claimStagedEventMatchesEpoch(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimStaged) bool { + if application == nil || epoch == nil || event == nil { + return false + } + return application.IApplicationAddress == event.AppContract && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64() +} + func (s *Service) String() string { return s.Name } diff --git a/internal/claimer/claimer_test.go b/internal/claimer/claimer_test.go index aece7f9c1..7d0675175 100644 --- a/internal/claimer/claimer_test.go +++ b/internal/claimer/claimer_test.go @@ -5,30 +5,63 @@ package claimer import ( "context" + "encoding/json" "fmt" "log/slog" "math/big" "os" + "strings" "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/internal/repository/repotest" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" "github.com/cartesi/rollups-node/pkg/service" "github.com/lmittmann/tint" "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/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) type claimerRepositoryMock struct { mock.Mock } +type claimerCreateRepositoryMock struct { + repository.Repository + mock.Mock +} + +func (m *claimerCreateRepositoryMock) SaveNodeConfigRaw( + ctx context.Context, + key string, + rawJSON []byte, +) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *claimerCreateRepositoryMock) LoadNodeConfigRaw(ctx context.Context, key string) ( + rawJSON []byte, + createdAt, updatedAt time.Time, + err error, +) { + args := m.Called(ctx, key) + raw, _ := args.Get(0).([]byte) + return raw, args.Get(1).(time.Time), args.Get(2).(time.Time), args.Error(3) +} + func (m *claimerRepositoryMock) SelectSubmittedClaimPairsPerApp(ctx context.Context) ( map[int64]*model.Epoch, map[int64]*model.Epoch, @@ -74,12 +107,90 @@ func (m *claimerRepositoryMock) UpdateApplicationState( return args.Error(0) } +func (m *claimerRepositoryMock) RejectEpochAndSetApplicationInoperable( + ctx context.Context, + appID int64, + index uint64, + reason string, +) error { + args := m.Called(ctx, appID, index, reason) + return args.Error(0) +} + +func (m *claimerRepositoryMock) HasUnreconciledClaimsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *claimerRepositoryMock) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + p repository.Pagination, + descending bool, +) ([]*model.Application, uint64, error) { + args := m.Called(ctx, f, p, descending) + var apps []*model.Application + if a := args.Get(0); a != nil { + apps = a.([]*model.Application) + } + return apps, uint64(args.Int(1)), args.Error(2) +} + func (m *claimerRepositoryMock) UpdateEpochWithAcceptedClaim( ctx context.Context, appid int64, index uint64, + txHash *common.Hash, +) error { + args := m.Called(ctx, appid, index, txHash) + return args.Error(0) +} + +func (m *claimerRepositoryMock) SelectStagedClaimPairsPerApp(ctx context.Context) ( + map[int64]*model.Epoch, + map[int64]*model.Epoch, + map[int64]*model.Application, + error, +) { + args := m.Called(ctx) + return args.Get(0).(map[int64]*model.Epoch), + args.Get(1).(map[int64]*model.Epoch), + args.Get(2).(map[int64]*model.Application), + args.Error(3) +} + +func (m *claimerRepositoryMock) UpdateEpochToStaged( + ctx context.Context, + appid int64, + index uint64, + stagedAtBlock uint64, +) error { + args := m.Called(ctx, appid, index, stagedAtBlock) + return args.Error(0) +} + +func (m *claimerRepositoryMock) UpdateEpochThroughStaging( + ctx context.Context, + appid int64, + index uint64, + txHash common.Hash, + stagedAtBlock uint64, +) error { + args := m.Called(ctx, appid, index, txHash, stagedAtBlock) + return args.Error(0) +} + +func (m *claimerRepositoryMock) UpdateEpochReconciledStaged( + ctx context.Context, + appid int64, + index uint64, + stagedAtBlock uint64, ) error { - args := m.Called(ctx, appid, index) + args := m.Called(ctx, appid, index, stagedAtBlock) return args.Error(0) } @@ -103,6 +214,12 @@ func (m *claimerRepositoryMock) LoadNodeConfigRaw(ctx context.Context, key strin type claimerBlockchainMock struct { mock.Mock + submitterAddress common.Address + hasSubmitter bool +} + +func (m *claimerBlockchainMock) claimSubmitterAddress() (common.Address, bool) { + return m.submitterAddress, m.hasSubmitter } func (m *claimerBlockchainMock) findClaimSubmittedEventAndSucc( @@ -113,15 +230,40 @@ func (m *claimerBlockchainMock) findClaimSubmittedEventAndSucc( toBlock uint64, ) ( *iconsensus.IConsensus, - *iconsensus.IConsensusClaimSubmitted, - *iconsensus.IConsensusClaimSubmitted, + []*iconsensus.IConsensusClaimSubmitted, error, ) { args := m.Called(ctx, app, epoch, fromBlock, toBlock) + if len(args) == 4 { + return args.Get(0).(*iconsensus.IConsensus), + compactSubmittedEvents(args.Get(1), args.Get(2)), + args.Error(3) + } return args.Get(0).(*iconsensus.IConsensus), - args.Get(1).(*iconsensus.IConsensusClaimSubmitted), - args.Get(2).(*iconsensus.IConsensusClaimSubmitted), - args.Error(3) + submittedEventSliceArg(args.Get(1)), + args.Error(2) +} + +func compactSubmittedEvents(values ...any) []*iconsensus.IConsensusClaimSubmitted { + events := []*iconsensus.IConsensusClaimSubmitted{} + for _, value := range values { + event, ok := value.(*iconsensus.IConsensusClaimSubmitted) + if ok && event != nil { + events = append(events, event) + } + } + return events +} + +func submittedEventSliceArg(value any) []*iconsensus.IConsensusClaimSubmitted { + if value == nil { + return nil + } + events, ok := value.([]*iconsensus.IConsensusClaimSubmitted) + if ok { + return events + } + return compactSubmittedEvents(value) } func (m *claimerBlockchainMock) findClaimAcceptedEventAndSucc( @@ -151,6 +293,43 @@ func (m *claimerBlockchainMock) submitClaimToBlockchain( args := m.Called(instance, app, epoch) return args.Get(0).(common.Hash), args.Error(1) } + +func (m *claimerBlockchainMock) acceptClaimOnBlockchain( + app *model.Application, + epoch *model.Epoch, +) (common.Hash, error) { + args := m.Called(app, epoch) + return args.Get(0).(common.Hash), args.Error(1) +} + +func (m *claimerBlockchainMock) findClaimStagedEventAndSucc( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimStaged, + *iconsensus.IConsensusClaimStaged, + error, +) { + args := m.Called(ctx, app, epoch, fromBlock, toBlock) + return args.Get(0).(*iconsensus.IConsensus), + args.Get(1).(*iconsensus.IConsensusClaimStaged), + args.Get(2).(*iconsensus.IConsensusClaimStaged), + args.Error(3) +} + +func (m *claimerBlockchainMock) getClaimStatus( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + blockNumber *big.Int, +) (iconsensus.IConsensusClaim, error) { + args := m.Called(ctx, app, epoch, blockNumber) + return args.Get(0).(iconsensus.IConsensusClaim), args.Error(1) +} func (m *claimerBlockchainMock) pollTransaction( ctx context.Context, txHash common.Hash, @@ -170,12 +349,70 @@ func (m *claimerBlockchainMock) getDefaultBlockNumber(ctx context.Context) (*big func (m *claimerBlockchainMock) getConsensusAddress( ctx context.Context, app *model.Application, + blockNumber *big.Int, ) (common.Address, error) { - args := m.Called(ctx, app) + args := m.Called(ctx, app, blockNumber) return args.Get(0).(common.Address), args.Error(1) } +// expectNoForeignClaimAccepted registers the ClaimAccepted scan expectation +// for a CLAIM_COMPUTED epoch where no foreign claim has been accepted. +// fromBlock matches prevEpoch.LastBlock+1 (if a prev exists) or +// epoch.LastBlock+1 (otherwise) — same logic as submitClaimsAndUpdateDatabase. +func expectNoForeignClaimAccepted(b *claimerBlockchainMock, app *model.Application, epoch *model.Epoch, fromBlock, toBlock uint64) { + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, epoch, fromBlock, toBlock). + Return( + &iconsensus.IConsensus{}, + (*iconsensus.IConsensusClaimAccepted)(nil), + (*iconsensus.IConsensusClaimAccepted)(nil), + nil, + ).Once() +} + +// expectGetClaimStatusUnstaged registers the pre-submit getClaim reconciliation +// expectation for the common case where the chain has not yet seen our claim, +// so the caller proceeds to broadcast. +func expectGetClaimStatusUnstaged(b *claimerBlockchainMock, app *model.Application, epoch *model.Epoch, endBlock *big.Int) { + b.On("getClaimStatus", mock.Anything, app, epoch, endBlock). + Return(iconsensus.IConsensusClaim{Status: 0}, nil).Once() +} + +func makeClaimStatus(status uint8, epoch *model.Epoch, stagedAtBlock uint64) iconsensus.IConsensusClaim { + claim := iconsensus.IConsensusClaim{Status: status} + if epoch.OutputsMerkleRoot != nil { + claim.StagedOutputsMerkleRoot = *epoch.OutputsMerkleRoot + } + if stagedAtBlock != 0 { + claim.StagingBlockNumber = new(big.Int).SetUint64(stagedAtBlock) + } + return claim +} + +type chainIDRPC struct { + chainID uint64 +} + +func (s *chainIDRPC) ChainId(_ context.Context) (*hexutil.Big, error) { + chainID := hexutil.Big(*new(big.Int).SetUint64(s.chainID)) + return &chainID, nil +} + +func newTestEthClient(t *testing.T, chainID uint64) *ethclient.Client { + server := rpc.NewServer() + t.Cleanup(server.Stop) + + err := server.RegisterName("eth", &chainIDRPC{chainID: chainID}) + require.NoError(t, err) + + rpcClient := rpc.DialInProc(server) + t.Cleanup(rpcClient.Close) + + client := ethclient.NewClient(rpcClient) + t.Cleanup(client.Close) + return client +} + func newServiceMock() (*Service, *claimerRepositoryMock, *claimerBlockchainMock) { opts := &tint.Options{ Level: slog.LevelDebug, @@ -185,14 +422,20 @@ func newServiceMock() (*Service, *claimerRepositoryMock, *claimerBlockchainMock) } handler := tint.NewHandler(os.Stdout, opts) repository := &claimerRepositoryMock{} - blockchain := &claimerBlockchainMock{} + blockchain := &claimerBlockchainMock{ + submitterAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + hasSubmitter: true, + } claimer := &Service{ Service: service.Service{ Logger: slog.New(handler), }, submissionEnabled: true, - claimsInFlight: map[int64]common.Hash{}, + claimsInFlight: map[int64]inFlightTx{}, + acceptsInFlight: map[int64]inFlightTx{}, + acceptAttempts: map[acceptAttemptKey]int{}, + maxAcceptAttempts: defaultMaxAcceptAttempts, repository: repository, blockchain: blockchain, } @@ -207,14 +450,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 - return repotest.NewEpochBuilder(id). + e := repotest.NewEpochBuilder(id). WithIndex(i). WithBlocks(i*10, i*10+9). WithStatus(status). WithClaimTransactionHash(txHash). WithOutputsMerkleRoot(outputsMerkleRoot). + WithMachineHash(machineHash). Build() + if status == model.EpochStatus_ClaimStaged { + // CHECK constraint: staged_iff_block. + b := uint64(i*10 + 1) + e.StagedAtBlock = &b + } + return e } func makeAcceptedEpoch(app *model.Application, i uint64) *model.Epoch { @@ -243,37 +494,71 @@ func makeApplicationMap(apps ...*model.Application) map[int64]*model.Application return result } +// testMachineHash returns a stable [32]byte derived from the epoch — good +// enough for fixtures that don't need a real on-chain match. Tests that +// exercise the machineMerkleRoot cross-check should construct their own +// machine hash and use the field-named struct literal. +func testMachineHash(epoch *model.Epoch) [32]byte { + if epoch.MachineHash != nil { + return *epoch.MachineHash + } + return [32]byte{} +} + func makeSubmittedEvent(app *model.Application, epoch *model.Epoch) *iconsensus.IConsensusClaimSubmitted { + return makeSubmittedEventWithTxHash(app, epoch, *epoch.ClaimTransactionHash) +} + +func makeSubmittedEventWithTxHash( + app *model.Application, + epoch *model.Epoch, + txHash common.Hash, +) *iconsensus.IConsensusClaimSubmitted { return &iconsensus.IConsensusClaimSubmitted{ LastProcessedBlockNumber: new(big.Int).SetUint64(epoch.LastBlock), AppContract: app.IApplicationAddress, OutputsMerkleRoot: *epoch.OutputsMerkleRoot, + MachineMerkleRoot: testMachineHash(epoch), Raw: types.Log{ - TxHash: common.HexToHash(epoch.ClaimTransactionHash.Hex()), + TxHash: txHash, BlockNumber: epoch.LastBlock + 5, }, } } -// makeClaimAcceptedLog creates a types.Log that ParseClaimAccepted can decode. -// Used to build receipt logs for the Authority fast-accept path in tests. -func makeClaimAcceptedLog(app *model.Application, epoch *model.Epoch) types.Log { +func makeSubmittedEventWithRoots( + app *model.Application, + epoch *model.Epoch, + outputs common.Hash, + machine common.Hash, +) *iconsensus.IConsensusClaimSubmitted { + event := makeSubmittedEvent(app, epoch) + event.OutputsMerkleRoot = outputs + event.MachineMerkleRoot = machine + return event +} + +// makeClaimStagedLog creates a types.Log that ParseClaimStaged can decode. +// Used to build receipt logs for the staging fast-path in tests. +func makeClaimStagedLog(app *model.Application, epoch *model.Epoch) types.Log { parsed, err := iconsensus.IConsensusMetaData.GetAbi() if err != nil { panic(fmt.Sprintf("failed to get IConsensus ABI: %v", err)) } - event, ok := parsed.Events["ClaimAccepted"] + event, ok := parsed.Events["ClaimStaged"] if !ok { - panic("IConsensus ABI does not define ClaimAccepted event") + panic("IConsensus ABI does not define ClaimStaged event") } data, err := event.Inputs.NonIndexed().Pack( new(big.Int).SetUint64(epoch.LastBlock), *epoch.OutputsMerkleRoot, + testMachineHash(epoch), ) if err != nil { - panic(fmt.Sprintf("failed to pack ClaimAccepted event data: %v", err)) + panic(fmt.Sprintf("failed to pack ClaimStaged event data: %v", err)) } return types.Log{ + Address: app.IConsensusAddress, Topics: []common.Hash{ event.ID, common.BytesToHash(app.IApplicationAddress.Bytes()), @@ -282,11 +567,25 @@ func makeClaimAcceptedLog(app *model.Application, epoch *model.Epoch) types.Log } } +// makeStagedEvent constructs an IConsensusClaimStaged matching the epoch. +func makeStagedEvent(app *model.Application, epoch *model.Epoch) *iconsensus.IConsensusClaimStaged { + return &iconsensus.IConsensusClaimStaged{ + LastProcessedBlockNumber: new(big.Int).SetUint64(epoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *epoch.OutputsMerkleRoot, + MachineMerkleRoot: testMachineHash(epoch), + Raw: types.Log{ + BlockNumber: epoch.LastBlock + 5, + }, + } +} + func makeAcceptedEvent(app *model.Application, epoch *model.Epoch) *iconsensus.IConsensusClaimAccepted { return &iconsensus.IConsensusClaimAccepted{ LastProcessedBlockNumber: new(big.Int).SetUint64(epoch.LastBlock), AppContract: app.IApplicationAddress, OutputsMerkleRoot: *epoch.OutputsMerkleRoot, + MachineMerkleRoot: testMachineHash(epoch), Raw: types.Log{ TxHash: common.HexToHash(epoch.ClaimTransactionHash.Hex()), BlockNumber: epoch.LastBlock + 5, @@ -321,9 +620,166 @@ 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). +func consensusRevertError(errorName string) error { + consensusABI, _ := iconsensus.IConsensusMetaData.GetAbi() + quorumABI, _ := iquorum.IQuorumMetaData.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 { + panic(fmt.Sprintf("unknown typed error: %s", errorName)) + } + return &rpcDataError{ + code: 3, + msg: "execution reverted", + data: fmt.Sprintf("0x%x", id[:4]), + } +} + +// claimNotStagedError creates a typed ClaimNotStaged revert carrying the +// given on-chain claim status, ABI-encoded as the contract would emit it. +func claimNotStagedError(status uint8) error { + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + panic(err) + } + abiErr := parsed.Errors["ClaimNotStaged"] + packed, err := abiErr.Inputs.Pack( + common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), + big.NewInt(42), + [32]byte(common.HexToHash("0xabcd")), + status, + ) + if err != nil { + panic(err) + } + payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) + return &rpcDataError{ + code: 3, + msg: "execution reverted", + data: fmt.Sprintf("0x%x", payload), + } +} + +// TestDecodeClaimNotStagedStatus pins the ABI-decode path used by +// handleAcceptClaimRevert. The status byte must come from the contract's +// own ABI Unpack, not from a positional read on the raw payload. +func TestDecodeClaimNotStagedStatus(t *testing.T) { + t.Run("ValidStatuses", func(t *testing.T) { + for _, s := range []uint8{0, 1, 2, 3} { + err := claimNotStagedError(s) + got, ok := decodeClaimNotStagedStatus(err) + assert.True(t, ok, "status=%d should decode", s) + assert.Equal(t, s, got, "status=%d should round-trip", s) + } + }) + + t.Run("NilError", func(t *testing.T) { + _, ok := decodeClaimNotStagedStatus(nil) + assert.False(t, ok) + }) + + t.Run("PlainErrorNoData", func(t *testing.T) { + _, ok := decodeClaimNotStagedStatus(fmt.Errorf("nope")) + assert.False(t, ok) + }) + + t.Run("EmptyPayload", func(t *testing.T) { + e := &rpcDataError{code: 3, msg: "execution reverted", 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"} + _, ok := decodeClaimNotStagedStatus(e) + assert.False(t, ok) + }) + + t.Run("WrongSelector", func(t *testing.T) { + e := &rpcDataError{ + code: 3, + msg: "execution reverted", + // Valid 132-byte payload, but selector is for a different error. + data: "0xdeadbeef" + strings.Repeat("00", 128), + } + _, ok := decodeClaimNotStagedStatus(e) + assert.False(t, ok) + }) + + t.Run("RightSelectorTruncatedBody", func(t *testing.T) { + parsed, _ := iconsensus.IConsensusMetaData.GetAbi() + abiErr := parsed.Errors["ClaimNotStaged"] + // Selector + only 1 slot — Unpack must fail rather than silently + // returning a stale byte. + payload := append(append([]byte{}, abiErr.ID[:4]...), make([]byte, 32)...) + e := &rpcDataError{ + code: 3, + msg: "execution reverted", + data: fmt.Sprintf("0x%x", payload), + } + _, ok := decodeClaimNotStagedStatus(e) + assert.False(t, ok) + }) +} + // ////////////////////////////////////////////////////////////////////////////// // Success // ////////////////////////////////////////////////////////////////////////////// + +func TestCreateUsesPersistedDefaultBlock(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + persistedConfig := PersistentConfig{ + DefaultBlock: model.DefaultBlock_Latest, + ClaimSubmissionEnabled: false, + ChainID: 42, + } + rawConfig, err := json.Marshal(persistedConfig) + require.NoError(t, err) + + repo := &claimerCreateRepositoryMock{} + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey). + Return(rawConfig, time.Now(), time.Now(), nil).Once() + + s, err := Create(ctx, &CreateInfo{ + CreateInfo: service.CreateInfo{ + Context: ctx, + PollInterval: time.Hour, + }, + Config: config.ClaimerConfig{ + BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainId: 42, + FeatureClaimSubmissionEnabled: true, + }, + EthConn: newTestEthClient(t, 42), + Repository: repo, + }) + require.NoError(t, err) + t.Cleanup(func() { + if s.Ticker != nil { + s.Ticker.Stop() + } + if s.Cancel != nil { + s.Cancel() + } + }) + + blockchain, ok := s.blockchain.(*claimerBlockchain) + require.True(t, ok) + assert.Equal(t, model.DefaultBlock_Latest, blockchain.defaultBlock) + assert.False(t, s.submissionEnabled) + + repo.AssertExpectations(t) + repo.AssertNumberOfCalls(t, "SaveNodeConfigRaw", 0) +} + func TestDoNothing(t *testing.T) { m, r, _ := newServiceMock() defer r.AssertExpectations(t) @@ -336,685 +792,2512 @@ func TestDoNothing(t *testing.T) { assert.Equal(t, 0, transitions, "no transitions when no epochs to process") } -func TestSubmitFirstClaim(t *testing.T) { +func TestTickInterleavesStagesWithPinnedBlockAndReschedulesOnProgress(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - endBlock := big.NewInt(40) + ctx := context.Background() + err := service.Create(ctx, &service.CreateInfo{ + Name: "claimer-test", + Context: ctx, + Impl: m, + PollInterval: time.Hour, + EnableReschedule: true, + }, &m.Service) + require.NoError(t, err) + t.Cleanup(func() { + if m.Ticker != nil { + m.Ticker.Stop() + } + if m.Cancel != nil { + m.Cancel() + } + }) + + tickBlock := big.NewInt(100) app := makeApplication() currEpoch := makeComputedEpoch(app, 3) - var prevEvent *iconsensus.IConsensusClaimSubmitted = nil - var currEvent *iconsensus.IConsensusClaimSubmitted = nil + currEvent := makeSubmittedEvent(app, currEpoch) - b.On("getConsensusAddress", mock.Anything, app). + b.On("getDefaultBlockNumber", mock.Anything). + Return(tickBlock, nil).Once() + r.On("SelectSubmittedClaimPairsPerApp", mock.Anything). + Return(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), nil).Once() + b.On("getConsensusAddress", mock.Anything, app, tickBlock). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). - Return(common.HexToHash("0x10"), nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, tickBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, tickBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{currEvent}, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). + Return(nil).Once() - transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 0, len(errs)) - assert.Equal(t, 1, len(m.claimsInFlight)) - assert.Equal(t, 1, transitions, "submitting a claim counts as a transition") + r.On("SelectAcceptedClaimPairsPerApp", mock.Anything). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), nil).Once() + r.On("SelectStagedClaimPairsPerApp", mock.Anything). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), nil).Once() + r.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.State != nil && + *f.State == model.ApplicationState_Enabled && + f.ForeclosureRecorded != nil && + *f.ForeclosureRecorded + }), repository.Pagination{}, false). + Return([]*model.Application{}, 0, nil).Once() + + errs := m.Tick() + + require.Empty(t, errs) + assert.True(t, m.DrainReschedule(), "a successful stage transition should request an immediate follow-up tick") } -func TestSubmitClaimWithAntecessor(t *testing.T) { +func TestSubmitFirstClaim(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - endBlock := big.NewInt(100) + endBlock := big.NewInt(40) app := makeApplication() - prevEpoch := makeAcceptedEpoch(app, 1) currEpoch := makeComputedEpoch(app, 3) + var prevEvent *iconsensus.IConsensusClaimSubmitted = nil var currEvent *iconsensus.IConsensusClaimSubmitted = nil - prevEvent := makeSubmittedEvent(app, prevEpoch) - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). Return(common.HexToHash("0x10"), nil).Once() - transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, 0, len(errs)) assert.Equal(t, 1, len(m.claimsInFlight)) assert.Equal(t, 1, transitions, "submitting a claim counts as a transition") } -func TestSkipSubmitFirstClaim(t *testing.T) { +// 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 withForeclosed(app *model.Application, block uint64) *model.Application { + copy := *app + copy.ForecloseBlock = block + txHash := common.HexToHash("0xcafe") + copy.ForecloseTransaction = &txHash + return © +} + +// TestSubmitClaimSkipsBroadcastForForeclosedApp 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 so we don't burn gas +// on a guaranteed ApplicationForeclosed revert. +func TestSubmitClaimSkipsBroadcastForForeclosedApp(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - m.submissionEnabled = false endBlock := big.NewInt(40) - app := makeApplication() + app := withForeclosed(makeApplication(), 35) currEpoch := makeComputedEpoch(app, 3) - var prevEvent *iconsensus.IConsensusClaimSubmitted = nil - var currEvent *iconsensus.IConsensusClaimSubmitted = nil + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - - transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 0, len(errs)) - assert.Equal(t, 0, len(m.claimsInFlight)) - assert.Equal(t, 0, transitions, "no transition when submission is disabled") + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + // CRITICAL: no submitClaimToBlockchain expectation — testify reports + // an unexpected call if the guard fails. + + computedEpochs := makeEpochMap(currEpoch) + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), computedEpochs, makeApplicationMap(app), endBlock) + + assert.Equal(t, 0, len(errs), "skipping a foreclosed-app broadcast is not an error") + assert.Equal(t, 0, transitions, "no claim broadcast = no transition") + assert.Equal(t, 0, len(m.claimsInFlight), + "no claim should enter the in-flight set for a foreclosed app") } -func TestSkipSubmitClaimWithAntecessor(t *testing.T) { +// TestSubmitClaimForecloseMidFlight verifies the transition behaviour: a +// healthy app whose claim is broadcast on tick 1 must STOP broadcasting on +// tick 2 once the in-memory ForecloseBlock has been populated (by evmreader +// observing the on-chain Foreclosure event between ticks). The first +// claim's in-flight tracking is preserved — that broadcast already +// happened; it's the *next* epoch's broadcast that must be suppressed. +// +// Two-tick scenario: +// 1. Tick 1: app.ForecloseBlock == 0; epoch N broadcast fires. +// 2. Between ticks: evmreader observes Foreclosure; the in-memory app's +// ForecloseBlock is set to a value < epoch N+1's LastBlock. +// 3. Tick 2: epoch N+1 in the computedEpochs work-map. The pre-submit +// reconciliation reads still run (mirroring any pre-foreclosure +// ACCEPTED state into the local DB), but the broadcast must be +// SKIPPED so we don't burn gas on a guaranteed ApplicationForeclosed +// revert. +func TestSubmitClaimForecloseMidFlight(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - m.submissionEnabled = false endBlock := big.NewInt(40) app := makeApplication() - prevEpoch := makeAcceptedEpoch(app, 1) - currEpoch := makeComputedEpoch(app, 3) - prevEvent := makeSubmittedEvent(app, prevEpoch) - var currEvent *iconsensus.IConsensusClaimSubmitted = nil + epochN := makeComputedEpoch(app, 3) + epochNPlus1 := makeComputedEpoch(app, 4) + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted - b.On("getConsensusAddress", mock.Anything, app). + // --- Tick 1 — healthy app; broadcast fires for epoch N. + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + expectNoForeignClaimAccepted(b, app, epochN, epochN.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epochN, epochN.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - - _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, len(errs), 0) - assert.Equal(t, len(m.claimsInFlight), 0) + expectGetClaimStatusUnstaged(b, app, epochN, endBlock) + tick1TxHash := common.HexToHash("0xa1") + b.On("submitClaimToBlockchain", mock.Anything, app, epochN). + Return(tick1TxHash, nil).Once() + + transitions1, errs1 := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), makeEpochMap(epochN), makeApplicationMap(app), endBlock) + require.Empty(t, errs1) + require.Equal(t, 1, transitions1, "tick 1: broadcast counts as a transition") + require.Len(t, m.claimsInFlight, 1, "tick 1: claim enters in-flight set") + + // --- Between ticks — evmreader observes Foreclosure and sets the marker; + // the in-flight tick-1 receipt resolves successfully. Receipt processing + // is orthogonal to what this test pins (the broadcast guard on the next + // epoch); short-circuit it by clearing the in-flight entry directly. + app.ForecloseBlock = 35 + tick2TxHash := common.HexToHash("0xcafe") + app.ForecloseTransaction = &tick2TxHash + delete(m.claimsInFlight, app.ID) + + // --- Tick 2 — foreclosed app + a new computed epoch. Reconciliation + // runs (pre-foreclosure on-chain state must still mirror to the local + // DB), but the broadcast is SKIPPED because app.ForecloseBlock != 0. + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, epochNPlus1, epochNPlus1.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epochNPlus1, epochNPlus1.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + expectGetClaimStatusUnstaged(b, app, epochNPlus1, endBlock) + // CRITICAL: no second submitClaimToBlockchain expectation registered. + // testify reports an unexpected call if the broadcast guard fails to + // see the now-populated ForecloseBlock. + + transitions2, errs2 := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), makeEpochMap(epochNPlus1), makeApplicationMap(app), endBlock) + require.Empty(t, errs2, "skipping a foreclosed-app broadcast is not an error") + assert.Equal(t, 0, transitions2, "tick 2: no broadcast = no new transition") + assert.Empty(t, m.claimsInFlight, + "tick 2: no new in-flight entry — the broadcast guard fires before submit") } -func TestInFlightCompleted(t *testing.T) { +// TestSubmitClaimReconcilesAcceptedForForeclosedApp verifies the +// counterpoint to the broadcast-guard test: the read-only +// reconciliation path MUST still run for foreclosed apps so that +// pre-foreclosure on-chain-accepted epochs are mirrored to the local DB. +// Without this, a new node bootstrapped against an already-foreclosed +// application would leave its last successful epoch stuck at +// CLAIM_COMPUTED — diverging from chain reality. +func TestSubmitClaimReconcilesAcceptedForForeclosedApp(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - txHash := common.HexToHash("0x10") - endBlock := big.NewInt(100) - app := makeApplication() // default: Authority consensus + endBlock := big.NewInt(40) + app := withForeclosed(makeApplication(), 35) currEpoch := makeComputedEpoch(app, 3) - currEpoch.ClaimTransactionHash = &txHash - - m.claimsInFlight[app.ID] = *currEpoch.ClaimTransactionHash + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted - // Authority emits ClaimAccepted in the same tx. Include a matching - // log in the receipt so the fast-accept path fires. - acceptedLog := makeClaimAcceptedLog(app, currEpoch) - b.On("pollTransaction", mock.Anything, txHash, endBlock). - Return(true, &types.Receipt{ - ContractAddress: app.IApplicationAddress, - TxHash: txHash, - BlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock + 1), - Status: 1, - Logs: []*types.Log{&acceptedLog}, - }, nil).Once() - r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, txHash). - Return(nil).Once() - r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + // Chain returns ACCEPTED (status 2) — the reconcile-before-submit + // path mirrors this to the local DB and skips broadcast entirely. + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusAccepted, currEpoch, 0), nil).Once() + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index, mock.Anything). Return(nil).Once() - transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + computedEpochs := makeEpochMap(currEpoch) + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), computedEpochs, makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 1, transitions, "ACCEPTED reconciliation counts as a transition") assert.Equal(t, 0, len(m.claimsInFlight)) - // Authority fast path: submitted (1) + accepted (1) = 2 transitions. - assert.Equal(t, 2, transitions) } -func TestInFlightReverted(t *testing.T) { +func TestSubmitClaimReconcilesStagedBeforeBroadcast(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - txHash := common.HexToHash("0x10") - endBlock := big.NewInt(100) + endBlock := big.NewInt(40) app := makeApplication() + app.ConsensusType = model.Consensus_Quorum currEpoch := makeComputedEpoch(app, 3) - currEpoch.ClaimTransactionHash = &txHash + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted + stagedAt := currEpoch.LastBlock + 2 - var prevEvent *iconsensus.IConsensusClaimSubmitted = nil - var currEvent *iconsensus.IConsensusClaimSubmitted = nil + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + r.On("UpdateEpochReconciledStaged", mock.Anything, app.ID, currEpoch.Index, stagedAt). + Return(nil).Once() - m.claimsInFlight[app.ID] = *currEpoch.ClaimTransactionHash + computedEpochs := makeEpochMap(currEpoch) + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), computedEpochs, makeApplicationMap(app), endBlock) - b.On("pollTransaction", mock.Anything, txHash, endBlock). - Return(true, &types.Receipt{ - ContractAddress: app.IApplicationAddress, - TxHash: txHash, - BlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock + 1), - Status: 0, - }, nil).Once() - b.On("getConsensusAddress", mock.Anything, app). + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 1, transitions, "STAGED reconciliation counts as a transition") + assert.Empty(t, computedEpochs, "reconciled epoch must leave the computed work map") + assert.Equal(t, 0, len(m.claimsInFlight), "reconciled staged claim must not be submitted again") +} + +func TestReconcileBeforeSubmitAcceptedOutputsMismatchSetsInoperable(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + claim := makeClaimStatus(claimStatusAccepted, currEpoch, 0) + claim.StagedOutputsMerkleRoot = common.HexToHash("0xdeadbeef") + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(claim, nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.Equal(t, 0, transitions) + assert.Equal(t, 0, len(m.claimsInFlight)) +} + +func TestSubmitClaimWithAntecessor(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + var currEvent *iconsensus.IConsensusClaimSubmitted = nil + prevEvent := makeSubmittedEvent(app, prevEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). Return(common.HexToHash("0x10"), nil).Once() - _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, len(errs), 0) - assert.Equal(t, len(m.claimsInFlight), 1) + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 1, len(m.claimsInFlight)) + assert.Equal(t, 1, transitions, "submitting a claim counts as a transition") } -func TestUpdateFirstClaim(t *testing.T) { +func TestSubmitClaimWithAcceptedAntecessorWithoutClaimTransactionHash(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + prevEpoch.ClaimTransactionHash = nil + currEpoch := makeComputedEpoch(app, 3) + prevEvent := makeSubmittedEventWithTxHash(app, prevEpoch, common.HexToHash("0x20")) + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(common.HexToHash("0x10"), nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + require.Empty(t, errs) + assert.Len(t, m.claimsInFlight, 1) + assert.Equal(t, 1, transitions, "accepted predecessor with unknown tx hash must not block submission") +} + +func TestSkipSubmitClaimWithStagedAntecessor(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeStagedEpoch(app, 1, 25) + currEpoch := makeComputedEpoch(app, 3) + prevEvent := makeSubmittedEvent(app, prevEpoch) + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, len(m.claimsInFlight)) + assert.Equal(t, 0, transitions, "staged predecessor must block newer claim submission") +} + +func TestSkipSubmitFirstClaim(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) + m.submissionEnabled = false endBlock := big.NewInt(40) app := makeApplication() currEpoch := makeComputedEpoch(app, 3) var prevEvent *iconsensus.IConsensusClaimSubmitted = nil - currEvent := makeSubmittedEvent(app, currEpoch) + var currEvent *iconsensus.IConsensusClaimSubmitted = nil - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, currEvent, prevEvent, nil).Once() - r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). - Return(nil).Once() + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, 0, len(errs)) assert.Equal(t, 0, len(m.claimsInFlight)) - assert.Equal(t, 1, transitions, "finding on-chain event counts as a transition") + assert.Equal(t, 0, transitions, "no transition when submission is disabled") } -func TestUpdateClaimWithAntecessor(t *testing.T) { +func TestSkipSubmitClaimWithAntecessor(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - endBlock := big.NewInt(100) + m.submissionEnabled = false + endBlock := big.NewInt(40) app := makeApplication() prevEpoch := makeAcceptedEpoch(app, 1) currEpoch := makeComputedEpoch(app, 3) prevEvent := makeSubmittedEvent(app, prevEpoch) - currEvent := makeSubmittedEvent(app, currEpoch) + var currEvent *iconsensus.IConsensusClaimSubmitted = nil - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). - Return(nil).Once() _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, len(errs), 0) assert.Equal(t, len(m.claimsInFlight), 0) } -func TestAcceptFirstClaim(t *testing.T) { +func TestInFlightCompleted(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) + txHash := common.HexToHash("0x10") endBlock := big.NewInt(100) - app := makeApplication() - currEpoch := makeSubmittedEpoch(app, 3) - var prevEvent *iconsensus.IConsensusClaimAccepted = nil - currEvent := makeAcceptedEvent(app, currEpoch) + app := makeApplication() // default: Authority consensus + currEpoch := makeComputedEpoch(app, 3) + currEpoch.ClaimTransactionHash = &txHash - b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - b.On("getConsensusAddress", mock.Anything, app). - Return(app.IConsensusAddress, nil).Once() + m.claimsInFlight[app.ID] = inFlightTx{txHash: *currEpoch.ClaimTransactionHash} - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, len(errs), 0) + // v3 Authority emits ClaimSubmitted + ClaimStaged in the same tx. The + // staging fast-path captures this and records COMPUTED → SUBMITTED → + // STAGED atomically via UpdateEpochThroughStaging. + stagedLog := makeClaimStagedLog(app, currEpoch) + receiptBlock := uint64(currEpoch.LastBlock + 1) + stagedLog.BlockNumber = receiptBlock + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(true, &types.Receipt{ + ContractAddress: app.IApplicationAddress, + TxHash: txHash, + BlockNumber: new(big.Int).SetUint64(receiptBlock), + Status: 1, + Logs: []*types.Log{&stagedLog}, + }, nil).Once() + r.On("UpdateEpochThroughStaging", mock.Anything, app.ID, currEpoch.Index, txHash, receiptBlock). + Return(nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, len(m.claimsInFlight)) + // v3 fast path: submitted (1) + staged (1) = 2 transitions. + assert.Equal(t, 2, transitions) } -func TestAcceptClaimWithAntecessor(t *testing.T) { +// TestInFlightCompleted_QuorumNonDeciding — variant where the submit tx +// confirmed but the receipt does NOT contain a ClaimStaged log (Quorum, +// non-deciding vote). tryStageFromReceipt returns stageReceiptNoMatch; the +// caller falls back to UpdateEpochWithSubmittedClaim. Epoch transitions +// COMPUTED → SUBMITTED (not STAGED). +func TestInFlightCompleted_QuorumNonDeciding(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) + txHash := common.HexToHash("0x10") endBlock := big.NewInt(100) app := makeApplication() - prevEpoch := makeAcceptedEpoch(app, 1) - currEpoch := makeSubmittedEpoch(app, 3) - prevEvent := makeAcceptedEvent(app, prevEpoch) - currEvent := makeAcceptedEvent(app, currEpoch) + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + currEpoch.ClaimTransactionHash = &txHash - b.On("getConsensusAddress", mock.Anything, app). - Return(app.IConsensusAddress, nil).Once() - b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index). + m.claimsInFlight[app.ID] = inFlightTx{txHash: *currEpoch.ClaimTransactionHash} + + receiptBlock := uint64(currEpoch.LastBlock + 1) + // Quorum non-deciding submit: receipt has Status=1 but no ClaimStaged log. + // The submitClaim emits ClaimSubmitted, but tryStageFromReceipt only + // scans for ClaimStaged — so the log list can be empty here without + // affecting the assertion. + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(true, &types.Receipt{ + ContractAddress: app.IApplicationAddress, + TxHash: txHash, + BlockNumber: new(big.Int).SetUint64(receiptBlock), + Status: 1, + Logs: []*types.Log{}, + }, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, txHash). Return(nil).Once() - transitions, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, 0, len(errs)) - assert.Equal(t, 1, transitions, "accepting a claim counts as a transition") + assert.Equal(t, 0, len(m.claimsInFlight)) + // Fall-back path: one transition (COMPUTED → SUBMITTED), not the fast-path's two. + assert.Equal(t, 1, transitions) } -// ////////////////////////////////////////////////////////////////////////////// -// Failure -// ////////////////////////////////////////////////////////////////////////////// - -func TestClaimInFlightMissingFromCurrClaims(t *testing.T) { +func TestInFlightReverted(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) + txHash := common.HexToHash("0x10") endBlock := big.NewInt(100) - reqHash := common.HexToHash("0x01") - receipt := new(types.Receipt) - app := makeApplication() - m.claimsInFlight[app.ID] = reqHash + currEpoch := makeComputedEpoch(app, 3) + currEpoch.ClaimTransactionHash = &txHash - b.On("pollTransaction", mock.Anything, reqHash, endBlock). - Return(true, receipt, nil).Once() + var prevEvent *iconsensus.IConsensusClaimSubmitted = nil + var currEvent *iconsensus.IConsensusClaimSubmitted = nil - _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(), makeApplicationMap(app), endBlock) + m.claimsInFlight[app.ID] = inFlightTx{txHash: *currEpoch.ClaimTransactionHash} + + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(true, &types.Receipt{ + ContractAddress: app.IApplicationAddress, + TxHash: txHash, + BlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock + 1), + Status: 0, + }, nil).Once() + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(common.HexToHash("0x10"), nil).Once() + + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, len(errs), 0) + assert.Equal(t, len(m.claimsInFlight), 1) +} + +func TestUpdateFirstClaim(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var prevEvent *iconsensus.IConsensusClaimSubmitted = nil + currEvent := makeSubmittedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, prevEvent, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). + Return(nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, len(m.claimsInFlight)) + assert.Equal(t, 1, transitions, "finding on-chain event counts as a transition") } -// submit again after pollTransaction failure -func TestSubmitFailedClaim(t *testing.T) { +func TestUpdateClaimWithAntecessor(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - expectedErr := fmt.Errorf("not found") endBlock := big.NewInt(100) - reqHash := common.HexToHash("0x01") - var nilReceipt *types.Receipt - app := makeApplication() prevEpoch := makeAcceptedEpoch(app, 1) currEpoch := makeComputedEpoch(app, 3) prevEvent := makeSubmittedEvent(app, prevEpoch) - var currEvent *iconsensus.IConsensusClaimSubmitted = nil - - m.claimsInFlight[app.ID] = reqHash + currEvent := makeSubmittedEvent(app, currEpoch) - b.On("pollTransaction", mock.Anything, reqHash, endBlock). - Return(false, nilReceipt, expectedErr).Once() - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). - Return(common.HexToHash("0x10"), nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). + Return(nil).Once() _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 0, len(errs)) + assert.Equal(t, len(errs), 0) + assert.Equal(t, len(m.claimsInFlight), 0) } -// TestNotFirstClaimHandledGracefully verifies that when submitClaim reverts -// with NotFirstClaim (e.g., after a node restart where claimsInFlight was -// lost), the claimer handles it gracefully — no error, no claimsInFlight -// entry, and the claim is left for event sync to pick up. -func TestNotFirstClaimHandledGracefully(t *testing.T) { +func TestQuorumSubmittedEventsIgnoresForeignDifferentOutputsAndUpdatesMatchingEvent(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) endBlock := big.NewInt(40) app := makeApplication() + app.ConsensusType = model.Consensus_Quorum currEpoch := makeComputedEpoch(app, 3) - var prevEvent *iconsensus.IConsensusClaimSubmitted - var currEvent *iconsensus.IConsensusClaimSubmitted + foreignEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + common.HexToHash("0xf001"), + common.HexToHash("0xf002"), + ) + foreignEvent.Raw.TxHash = common.HexToHash("0xf003") + currEvent := makeSubmittedEvent(app, currEpoch) - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - // submitClaim reverts with NotFirstClaim (caught by eth_estimateGas). - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). - Return(common.Hash{}, notFirstClaimError()).Once() + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent, currEvent}, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). + Return(nil).Once() - _, errs := m.submitClaimsAndUpdateDatabase( - makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, 0, len(errs)) assert.Equal(t, 0, len(m.claimsInFlight)) + assert.Equal(t, 1, transitions, "matching later event counts as a transition") +} + +func TestQuorumDifferentOutputSubmittedEventStillSubmitsLocalClaim(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + common.HexToHash("0xf001"), + common.HexToHash("0xf002"), + ) + txHash := common.HexToHash("0x10") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(txHash, nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, txHash, m.claimsInFlight[app.ID].txHash) + assert.Equal(t, 1, transitions) +} + +func TestQuorumForeignMatchingSubmittedEventStillSubmitsLocalClaim(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEvent(app, currEpoch) + foreignEvent.Submitter = common.HexToAddress("0x0000000000000000000000000000000000000002") + txHash := common.HexToHash("0x10") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(txHash, nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, txHash, m.claimsInFlight[app.ID].txHash) + assert.Equal(t, 1, transitions) +} + +func TestQuorumReaderModeRecordsForeignMatchingSubmittedEvent(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + m.submissionEnabled = false + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEvent(app, currEpoch) + foreignEvent.Submitter = common.HexToAddress("0x0000000000000000000000000000000000000002") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, foreignEvent.Raw.TxHash). + Return(nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, len(m.claimsInFlight)) + assert.Equal(t, 1, transitions, "reader mode must mirror a matching Quorum ClaimSubmitted from any validator") +} + +func TestQuorumSubmittedEventsCatchAdversarialProofAfterForeignVote(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + common.HexToHash("0xf001"), + common.HexToHash("0xf002"), + ) + adversarialEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + *currEpoch.OutputsMerkleRoot, + common.HexToHash("0xf003"), + ) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent, adversarialEvent}, nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.Equal(t, 0, len(currEpochs)) + assert.Equal(t, 0, len(m.claimsInFlight)) + assert.Equal(t, 0, transitions) +} + +func TestAcceptFirstClaim(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeSubmittedEpoch(app, 3) + var prevEvent *iconsensus.IConsensusClaimAccepted = nil + currEvent := makeAcceptedEvent(app, currEpoch) + + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, len(errs), 0) +} + +func TestAcceptClaimWithAntecessor(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeSubmittedEpoch(app, 3) + prevEvent := makeAcceptedEvent(app, prevEpoch) + currEvent := makeAcceptedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index, mock.Anything). + Return(nil).Once() + + transitions, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 1, transitions, "accepting a claim counts as a transition") +} + +// ////////////////////////////////////////////////////////////////////////////// +// Failure +// ////////////////////////////////////////////////////////////////////////////// + +func TestClaimInFlightMissingFromCurrClaims(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + reqHash := common.HexToHash("0x01") + receipt := new(types.Receipt) + + app := makeApplication() + m.claimsInFlight[app.ID] = inFlightTx{txHash: reqHash} + + b.On("pollTransaction", mock.Anything, reqHash, endBlock). + Return(true, receipt, nil).Once() + + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(), makeApplicationMap(app), endBlock) + assert.Equal(t, len(errs), 0) +} + +func TestClaimInFlightPollErrorKeepsTrackingAndStopsDuplicateSubmit(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + expectedErr := fmt.Errorf("temporary receipt RPC failure") + endBlock := big.NewInt(100) + reqHash := common.HexToHash("0x01") + var nilReceipt *types.Receipt + + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + + m.claimsInFlight[app.ID] = inFlightTx{txHash: reqHash} + + b.On("pollTransaction", mock.Anything, reqHash, endBlock). + Return(false, nilReceipt, expectedErr).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + require.Equal(t, 1, len(errs)) + assert.ErrorIs(t, errs[0], expectedErr) + assert.Equal(t, 0, transitions) + assert.Contains(t, m.claimsInFlight, app.ID, + "receipt lookup errors do not prove the tx failed; keep in-flight tracking") +} + +func TestClaimInFlightReceiptNotFoundBeforeTimeoutKeepsTrackingAndStopsDuplicateSubmit(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + reqHash := common.HexToHash("0x01") + var nilReceipt *types.Receipt + + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + m.claimsInFlight[app.ID] = inFlightTx{ + txHash: reqHash, + firstSeenBlock: endBlock.Uint64() - maxInFlightReceiptNotFoundBlocks + 1, + } + + b.On("pollTransaction", mock.Anything, reqHash, endBlock). + Return(false, nilReceipt, nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + require.Empty(t, errs) + assert.Equal(t, 0, transitions) + assert.Contains(t, m.claimsInFlight, app.ID, + "receipt NotFound before timeout still means the tx may be pending") +} + +func TestClaimInFlightReceiptNotFoundAfterTimeoutClearsAndRetries(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + oldTxHash := common.HexToHash("0x01") + newTxHash := common.HexToHash("0x10") + var nilReceipt *types.Receipt + + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + m.claimsInFlight[app.ID] = inFlightTx{ + txHash: oldTxHash, + firstSeenBlock: endBlock.Uint64() - maxInFlightReceiptNotFoundBlocks, + } + + b.On("pollTransaction", mock.Anything, oldTxHash, endBlock). + Return(false, nilReceipt, nil).Once() + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(newTxHash, nil).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + require.Empty(t, errs) + assert.Equal(t, 1, transitions, "stale in-flight tx should allow the normal submit path to retry") + got, ok := m.claimsInFlight[app.ID] + require.True(t, ok) + assert.Equal(t, newTxHash, got.txHash) + assert.Equal(t, endBlock.Uint64(), got.firstSeenBlock) +} + +// TestNotFirstClaimHandledGracefully verifies that when submitClaim reverts +// with NotFirstClaim (e.g., after a node restart where claimsInFlight was +// lost), the claimer handles it gracefully — no error, no claimsInFlight +// entry, and the claim is left for event sync to pick up. +func TestNotFirstClaimHandledGracefully(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var prevEvent *iconsensus.IConsensusClaimSubmitted + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + // submitClaim reverts with NotFirstClaim (caught by eth_estimateGas). + b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). + Return(common.Hash{}, notFirstClaimError()).Once() + + _, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, len(m.claimsInFlight)) +} + +// TestNotFirstClaimQuorumRetriesForEventSync verifies that when submitClaim +// reverts with NotFirstClaim for a Quorum app, the claimer waits for event +// sync instead of marking the app INOPERABLE from the selector alone. In v3, +// Quorum raises NotFirstClaim for any prior validator vote in the epoch, +// including a duplicate vote for the same machine root. +func TestNotFirstClaimQuorumRetriesForEventSync(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + var prevEvent *iconsensus.IConsensusClaimSubmitted + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(common.Hash{}, notFirstClaimError()).Once() + + _, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, len(m.claimsInFlight)) +} + +// TestApplicationForeclosedIsTransient verifies that a submitClaim revert +// with ApplicationForeclosed is treated as transient: no error is surfaced, +// no state transition happens, and the epoch stays in computedEpochs so the +// next tick can retry while the EVM reader records foreclosure and future +// claim broadcasts are skipped. +func TestApplicationForeclosedIsTransient(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(common.Hash{}, consensusRevertError("ApplicationForeclosed")).Once() + + currEpochs := makeEpochMap(currEpoch) + transitions, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + assert.Equal(t, 0, transitions, "no DB transition on transient revert") + assert.Equal(t, 0, len(errs), "ApplicationForeclosed must not surface as an error") + assert.Equal(t, 1, len(currEpochs), "epoch must remain in work map for retry") + assert.Equal(t, 0, len(m.claimsInFlight), "no claim in flight") +} + +// TestInvalidOutputsMerkleRootProofSizeSetsInoperable verifies that a +// proof-size revert is treated as local data corruption — the app moves +// to INOPERABLE. +func TestInvalidOutputsMerkleRootProofSizeSetsInoperable(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(common.Hash{}, consensusRevertError("InvalidOutputsMerkleRootProofSize")).Once() + r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + _, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs), "INOPERABLE 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)) +} + +// TestCallerIsNotValidatorSetsFailed verifies that a Quorum membership +// failure is treated as a recoverable operator-config error: FAILED, not +// INOPERABLE. +func TestCallerIsNotValidatorSetsFailed(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + 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, app, currEpoch). + Return(common.Hash{}, consensusRevertError("CallerIsNotValidator")).Once() + r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Failed, mock.Anything). + Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + _, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + // SetFailedf returns nil on success — the call site only surfaces an + // error when state-update itself failed, so no error is expected here. + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, len(currEpochs), "epoch must be dropped from work map") +} + +// !claimSubmittedMatche(prevClaim, prevEvent) +func TestSubmitClaimWithAntecessorMismatch(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + + // event has an incorrect LastProcessedBlockNumber field. Every other + // field matches the epoch so the mismatch is unambiguously LastBlock. + prevEvent := &iconsensus.IConsensusClaimSubmitted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(prevEpoch.LastBlock + 1), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *prevEpoch.OutputsMerkleRoot, + MachineMerkleRoot: testMachineHash(prevEpoch), + } + var currEvent *iconsensus.IConsensusClaimSubmitted = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil). + Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil). + Once() + r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +// !claimMatchesEvent(currClaim, currEvent) +func TestSubmitClaimWithEventMismatch(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + prevEvent := makeSubmittedEvent(app, prevEpoch) + wrongEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + common.HexToHash("0xbad1"), + common.HexToHash("0xbad2"), + ) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{prevEvent, wrongEvent}, nil).Once() + r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +// !checkClaimsConstraint(prevClaim, currClaim) // epoch pair has its blocks out of order +func TestSubmitClaimWithAntecessorOutOfOrder(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + app := makeApplication() + prevEpoch := makeSubmittedEpoch(app, 2) + currEpoch := makeComputedEpoch(app, 1) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, uint64(0)) + r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), big.NewInt(0)) + assert.Equal(t, 1, len(errs)) +} + +func TestCheckEpochSequenceConstraintAllowsAcceptedPredecessorWithoutClaimTransactionHash(t *testing.T) { + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + prevEpoch.ClaimTransactionHash = nil + currEpoch := makeComputedEpoch(app, 2) + + require.NoError(t, checkEpochSequenceConstraint(prevEpoch, currEpoch)) +} + +func TestErrSubmittedMissingEvent(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeComputedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 2) + var prevEvent *iconsensus.IConsensusClaimSubmitted = nil + currEvent := makeSubmittedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +func TestConsensusAddressChangedOnSubmittedClaims(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + wrongConsensusAddress := app.IConsensusAddress + wrongConsensusAddress[0]++ + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(wrongConsensusAddress, nil). + Once() + r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, len(errs), 1) +} + +func TestCheckConsensusForAddressChangeUsesTickBlock(t *testing.T) { + m, _, b := newServiceMock() + defer b.AssertExpectations(t) + + app := makeApplication() + tickBlock := big.NewInt(123) + + b.On("getConsensusAddress", mock.Anything, app, mock.MatchedBy(func(blockNumber *big.Int) bool { + return blockNumber != nil && blockNumber.Cmp(tickBlock) == 0 + })). + Return(app.IConsensusAddress, nil). + Once() + + err := m.checkConsensusForAddressChange(app, tickBlock) + require.NoError(t, err) +} + +//////////////////////////////////////////////////////////////////////////////// + +func TestFindClaimAcceptedEventAndSuccFailure0(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + expectedErr := fmt.Errorf("not found") + endBlock := big.NewInt(100) + + app := makeApplication() + currEpoch := makeComputedEpoch(app, 2) + var prevEvent *iconsensus.IConsensusClaimAccepted = nil + currEvent := makeAcceptedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, expectedErr).Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +func TestFindClaimAcceptedEventAndSuccFailure1(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + expectedErr := fmt.Errorf("not found") + endBlock := big.NewInt(100) + + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 2) + prevEvent := makeAcceptedEvent(app, prevEpoch) + currEvent := makeAcceptedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, expectedErr).Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +// !claimAcceptedMatch(prevClaim, prevEvent) +func TestAcceptClaimWithAntecessorMismatch(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + + // Every field matches the epoch except LastProcessedBlockNumber. + prevEvent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(prevEpoch.LastBlock + 1), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *prevEpoch.OutputsMerkleRoot, + MachineMerkleRoot: testMachineHash(prevEpoch), + } + var currEvent *iconsensus.IConsensusClaimAccepted = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil) + r.On("UpdateApplicationState", mock.Anything, mock.Anything, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +// !claimAcceptedMatch(currClaim, currEvent) +func TestAcceptClaimWithEventMismatch(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + wrongEpoch := makeComputedEpoch(app, 2) + currEpoch := makeComputedEpoch(app, 3) + wrongEvent := makeAcceptedEvent(app, wrongEpoch) + prevEvent := makeAcceptedEvent(app, prevEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, wrongEvent, nil) + r.On("UpdateApplicationState", mock.Anything, mock.Anything, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +// !checkClaimsConstraint(prevClaim, currClaim) +func TestAcceptClaimWithAntecessorOutOfOrder(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + app := makeApplication() + wrongEpoch := makeComputedEpoch(app, 2) + currEpoch := makeComputedEpoch(app, 1) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + r.On("UpdateApplicationState", mock.Anything, mock.Anything, model.ApplicationState_Inoperable, mock.Anything). + Return(nil). + Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(wrongEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), big.NewInt(0)) + assert.Equal(t, 1, len(errs)) +} + +func TestErrAcceptedMissingEvent(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeComputedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 2) + var prevEvent *iconsensus.IConsensusClaimAccepted = nil + currEvent := makeAcceptedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + r.On("UpdateApplicationState", mock.Anything, mock.Anything, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +func TestUpdateEpochWithAcceptedClaimFailed(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + expectedErr := fmt.Errorf("not found") + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeSubmittedEpoch(app, 1) + currEpoch := makeSubmittedEpoch(app, 2) + prevEvent := makeAcceptedEvent(app, prevEpoch) + currEvent := makeAcceptedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index, mock.Anything). + Return(expectedErr).Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) +} + +func TestConsensusAddressChangedOnAcceptedClaims(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + wrongConsensusAddress := app.IConsensusAddress + wrongConsensusAddress[0]++ + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(wrongConsensusAddress, nil). + Once() + r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + Return(nil). + Once() + + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, len(errs), 1) +} + +// ---------------------------------------------------------------------------- +// Two-phase staging flow +// ---------------------------------------------------------------------------- + +// makeStagedEpoch returns an epoch in CLAIM_STAGED with staged_at_block set +// to satisfy the schema's staged_iff_block CHECK constraint. +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 +// the divergence and marks the app INOPERABLE with the staging-stage reason. +func TestStagingFastPathDivergence(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + txHash := common.HexToHash("0x10") + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + currEpoch.ClaimTransactionHash = &txHash + m.claimsInFlight[app.ID] = inFlightTx{txHash: txHash} + + // Build a divergent ClaimStaged log: same (app, lpbn, outputs) but + // different machineMerkleRoot. + divergent := makeStagedEvent(app, currEpoch) + differentMMR := common.HexToHash("0xdeadbeef") + divergent.MachineMerkleRoot = differentMMR + stagedLog := buildClaimStagedLog(app, currEpoch, *currEpoch.OutputsMerkleRoot, differentMMR) + receiptBlock := currEpoch.LastBlock + 1 + + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(true, &types.Receipt{ + TxHash: txHash, + BlockNumber: new(big.Int).SetUint64(receiptBlock), + Status: 1, + Logs: []*types.Log{&stagedLog}, + }, nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + // The fast-path consumed the receipt and triggered INOPERABLE. The + // divergence error is surfaced (matching the convention used by other + // INOPERABLE setters); + // UpdateEpochThroughStaging is NOT called and the in-flight tx is dropped. + assert.Equal(t, 1, len(errs), "divergence at staging fast-path must surface as an error") + assert.Equal(t, 0, len(m.claimsInFlight)) +} + +// TestStagingFastPathDBPending — happy fast-path match but the atomic +// UpdateEpochThroughStaging write fails. The fix must NOT fall back to +// UpdateEpochWithSubmittedClaim (which would hide the STAGED event from +// this tick's pipeline so the next tick's staging scan would have to +// re-discover it from chain — surface signal goes silent under correlated +// DB outages). Instead it surfaces the error and leaves the in-flight +// tracking + computedEpochs entry intact so the next tick polls the +// receipt again and retries the atomic write. +func TestStagingFastPathDBPending(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + txHash := common.HexToHash("0x10") + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + currEpoch.ClaimTransactionHash = &txHash + m.claimsInFlight[app.ID] = inFlightTx{txHash: txHash} + + stagedLog := makeClaimStagedLog(app, currEpoch) + receiptBlock := uint64(currEpoch.LastBlock + 1) + stagedLog.BlockNumber = receiptBlock + + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(true, &types.Receipt{ + ContractAddress: app.IApplicationAddress, + TxHash: txHash, + BlockNumber: new(big.Int).SetUint64(receiptBlock), + Status: 1, + Logs: []*types.Log{&stagedLog}, + }, nil).Once() + dbErr := fmt.Errorf("statement timeout") + r.On("UpdateEpochThroughStaging", mock.Anything, app.ID, currEpoch.Index, txHash, receiptBlock). + Return(dbErr).Once() + // No UpdateEpochWithSubmittedClaim expectation — falling back to a + // plain SUBMITTED update would lose the staged-at-block atomicity + // that UpdateEpochThroughStaging guarantees in a single transaction. + + computedEpochs := makeEpochMap(currEpoch) + _, errs := m.submitClaimsAndUpdateDatabase( + makeEpochMap(), computedEpochs, makeApplicationMap(app), endBlock) + + require.Equal(t, 1, len(errs), "DB-pending must surface as a tick-level error") + assert.ErrorIs(t, errs[0], dbErr) + // Both work-tracking entries must remain so the next tick can retry + // from the same receipt. + assert.Contains(t, m.claimsInFlight, app.ID, + "claimsInFlight must be retained so the next tick polls the receipt again") + assert.Contains(t, computedEpochs, app.ID, + "computedEpochs entry must be retained for cleanupOrphanedInFlight") +} + +// buildClaimStagedLog builds a types.Log for a ClaimStaged event with +// explicit outputs and machine roots. Used to construct divergent fixtures. +func buildClaimStagedLog(app *model.Application, epoch *model.Epoch, + outputs common.Hash, machine common.Hash) types.Log { + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + panic(err) + } + event := parsed.Events["ClaimStaged"] + data, err := event.Inputs.NonIndexed().Pack( + new(big.Int).SetUint64(epoch.LastBlock), + [32]byte(outputs), + [32]byte(machine), + ) + if err != nil { + panic(err) + } + return types.Log{ + Address: app.IConsensusAddress, + Topics: []common.Hash{ + event.ID, + common.BytesToHash(app.IApplicationAddress.Bytes()), + }, + Data: data, + } +} + +// TestStageByObservation — submitted epoch + ClaimStaged event observed in +// the next-tick scan → transition to CLAIM_STAGED with staged_at_block +// recorded from event.Raw.BlockNumber. +func TestStageByObservation(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeSubmittedEpoch(app, 3) + currEvent := makeStagedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("UpdateEpochToStaged", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.BlockNumber). + Return(nil).Once() + + transitions, errs := m.stageClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 1, transitions) +} + +// TestStagingDivergence_Quorum — Quorum case where ClaimStaged is observed +// with a machineMerkleRoot != ours → CLAIM_REJECTED and INOPERABLE with +// quorum_divergence_at_staging. +func TestStagingDivergence_Quorum(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeSubmittedEpoch(app, 3) + + // Divergent event: different MMR + differentMMR := common.HexToHash("0xfeed") + divergent := &iconsensus.IConsensusClaimStaged{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.OutputsMerkleRoot, + MachineMerkleRoot: differentMMR, + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("RejectEpochAndSetApplicationInoperable", mock.Anything, app.ID, currEpoch.Index, mock.MatchedBy(func(reason string) bool { + return strings.Contains(reason, "quorum_divergence_at_staging") + })). + Return(nil).Once() + + _, errs := m.stageClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.Equal(t, model.ApplicationState_Inoperable, app.State) + assert.Equal(t, model.EpochStatus_ClaimRejected, currEpoch.Status) +} + +func TestStagingDivergence_AuthorityDoesNotRejectEpoch(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeSubmittedEpoch(app, 3) + + divergent := &iconsensus.IConsensusClaimStaged{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.OutputsMerkleRoot, + MachineMerkleRoot: common.HexToHash("0xfeed"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + _, errs := m.stageClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.Equal(t, model.ApplicationState_Inoperable, app.State) + assert.Equal(t, model.EpochStatus_ClaimSubmitted, currEpoch.Status) +} + +func TestStagingMatcherPreconditionFailureMarksApplicationInoperable(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeSubmittedEpoch(app, 3) + event := makeStagedEvent(app, currEpoch) + currEpoch.MachineHash = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, event, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "cannot compare epoch") + })). + Return(nil).Once() + + _, errs := m.stageClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.Equal(t, model.ApplicationState_Inoperable, app.State) +} + +// TestAcceptStagedFrontRunner — staging period elapsed; pre-flight getClaim +// returns ACCEPTED (status=2) before our acceptClaim → reconcile to +// CLAIM_ACCEPTED with no tx issued. +func TestAcceptStagedFrontRunner(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusAccepted, currEpoch, stagedAt), nil).Once() + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index, mock.Anything). + Return(nil).Once() + + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 1, transitions) + assert.Equal(t, 0, len(m.acceptsInFlight)) +} + +func TestAcceptStagedBroadcastsWhenClaimStillStaged(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + txHash := common.HexToHash("0xabc") + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + b.On("acceptClaimOnBlockchain", app, currEpoch). + Return(txHash, nil).Once() + + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, transitions, "broadcasting acceptClaim records in-flight work but does not update DB yet") + + got, ok := m.acceptsInFlight[app.ID] + require.True(t, ok) + assert.Equal(t, txHash, got.txHash) + assert.Equal(t, endBlock.Uint64(), got.firstSeenBlock) + assert.Equal(t, 1, m.acceptAttempts[acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index}]) +} + +func TestAcceptStagedFrontRunnerOutputsMismatchSetsInoperable(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + claim := makeClaimStatus(claimStatusAccepted, currEpoch, stagedAt) + claim.StagedOutputsMerkleRoot = common.HexToHash("0xdeadbeef") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(claim, nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() + + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.Equal(t, 0, transitions) + assert.Equal(t, 0, len(m.acceptsInFlight)) +} + +func TestAcceptInFlightPollErrorKeepsTracking(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + txHash := common.HexToHash("0x10") + endBlock := big.NewInt(100) + app := makeApplication() + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + expectedErr := fmt.Errorf("temporary receipt RPC failure") + var nilReceipt *types.Receipt + + m.acceptsInFlight[app.ID] = inFlightTx{txHash: txHash} + + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(false, nilReceipt, expectedErr).Once() + + transitions, err := m.checkAcceptsInFlight(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + require.ErrorIs(t, err, expectedErr) + assert.Equal(t, 0, transitions) + assert.Contains(t, m.acceptsInFlight, app.ID, + "receipt lookup errors do not prove the tx failed; keep in-flight tracking") +} + +func TestAcceptInFlightReceiptNotFoundAfterTimeoutClearsTracking(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + txHash := common.HexToHash("0x10") + endBlock := big.NewInt(100) + app := makeApplication() + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + var nilReceipt *types.Receipt + + m.acceptsInFlight[app.ID] = inFlightTx{ + txHash: txHash, + firstSeenBlock: endBlock.Uint64() - maxInFlightReceiptNotFoundBlocks, + } + + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(false, nilReceipt, nil).Once() + + transitions, err := m.checkAcceptsInFlight(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + require.NoError(t, err) + assert.Equal(t, 0, transitions) + assert.NotContains(t, m.acceptsInFlight, app.ID, + "stale receipt NotFound should unblock the next accept lifecycle pass") +} + +func TestAcceptInFlightSuccessUpdatesEpochAndClearsTracking(t *testing.T) { + m, r, b := newServiceMock() + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + txHash := common.HexToHash("0x10") + endBlock := big.NewInt(100) + app := makeApplication() + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + attemptKey := acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index} + stagedEpochs := makeEpochMap(currEpoch) + + m.acceptsInFlight[app.ID] = inFlightTx{txHash: txHash} + m.acceptAttempts[attemptKey] = 2 + + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(true, &types.Receipt{ + TxHash: txHash, + Status: 1, + BlockNumber: big.NewInt(99), + }, nil).Once() + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index, (*common.Hash)(nil)). + Return(nil).Once() + + transitions, err := m.checkAcceptsInFlight(stagedEpochs, makeApplicationMap(app), endBlock) + require.NoError(t, err) + assert.Equal(t, 1, transitions) + assert.NotContains(t, m.acceptsInFlight, app.ID) + assert.NotContains(t, m.acceptAttempts, attemptKey) + assert.Empty(t, stagedEpochs, "accepted epoch must leave the staged work map") } -// TestNotFirstClaimQuorumSetsInoperable verifies that when submitClaim reverts -// with NotFirstClaim for a Quorum app, the claimer marks the application as -// inoperable. In Quorum, NotFirstClaim means the validator previously submitted -// a different merkle root — a determinism violation. -func TestNotFirstClaimQuorumSetsInoperable(t *testing.T) { +func TestAcceptInFlightRevertedAcceptedReconcilesEpoch(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - endBlock := big.NewInt(40) + txHash := common.HexToHash("0x10") + endBlock := big.NewInt(100) app := makeApplication() - app.ConsensusType = model.Consensus_Quorum - currEpoch := makeComputedEpoch(app, 3) - var prevEvent *iconsensus.IConsensusClaimSubmitted - var currEvent *iconsensus.IConsensusClaimSubmitted + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + attemptKey := acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index} + stagedEpochs := makeEpochMap(currEpoch) - b.On("getConsensusAddress", mock.Anything, app). - Return(app.IConsensusAddress, nil).Once() - b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). - Return(common.Hash{}, notFirstClaimError()).Once() - r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + m.acceptsInFlight[app.ID] = inFlightTx{txHash: txHash} + m.acceptAttempts[attemptKey] = 2 + + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(true, &types.Receipt{ + TxHash: txHash, + Status: 0, + BlockNumber: big.NewInt(99), + }, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusAccepted, currEpoch, stagedAt), nil).Once() + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index, (*common.Hash)(nil)). Return(nil).Once() - _, errs := m.submitClaimsAndUpdateDatabase( - makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 1, len(errs)) - assert.Equal(t, 0, len(m.claimsInFlight)) + transitions, err := m.checkAcceptsInFlight(stagedEpochs, makeApplicationMap(app), endBlock) + require.NoError(t, err) + assert.Equal(t, 1, transitions) + assert.NotContains(t, m.acceptsInFlight, app.ID) + assert.NotContains(t, m.acceptAttempts, attemptKey) + assert.Empty(t, stagedEpochs, "front-run accepted epoch must leave the staged work map") } -// !claimSubmittedMatche(prevClaim, prevEvent) -func TestSubmitClaimWithAntecessorMismatch(t *testing.T) { +func TestAcceptInFlightRevertedUnstagedMarksApplicationFailed(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) + txHash := common.HexToHash("0x10") endBlock := big.NewInt(100) app := makeApplication() - prevEpoch := makeAcceptedEpoch(app, 1) - currEpoch := makeComputedEpoch(app, 3) + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) - // event has an incorrect LastProcessedBlockNumber field. - prevEvent := &iconsensus.IConsensusClaimSubmitted{ - LastProcessedBlockNumber: new(big.Int).SetUint64(prevEpoch.LastBlock + 1), - AppContract: app.IApplicationAddress, - OutputsMerkleRoot: *prevEpoch.OutputsMerkleRoot, - } - var currEvent *iconsensus.IConsensusClaimSubmitted = nil + m.acceptsInFlight[app.ID] = inFlightTx{txHash: txHash} - b.On("getConsensusAddress", mock.Anything, app). - Return(app.IConsensusAddress, nil). - Once() - b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil). - Once() - r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + b.On("pollTransaction", mock.Anything, txHash, endBlock). + Return(true, &types.Receipt{ + TxHash: txHash, + Status: 0, + BlockNumber: big.NewInt(99), + }, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusUnstaged, currEpoch, 0), nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Failed, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "DB inconsistent with chain") + })). Return(nil).Once() - _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 1, len(errs)) + transitions, err := m.checkAcceptsInFlight(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + require.NoError(t, err) + assert.Equal(t, 0, transitions) + assert.Equal(t, model.ApplicationState_Failed, app.State) + assert.NotContains(t, m.acceptsInFlight, app.ID) } -// !claimMatchesEvent(currClaim, currEvent) -func TestSubmitClaimWithEventMismatch(t *testing.T) { +// TestAcceptStagedSkipsBroadcastForForeclosedApp verifies the symmetric +// guard at Stage 3: when the app is foreclosed, the pre-accept getClaim +// still runs (so a chain-side acceptance is reconciled), but the local +// acceptClaim broadcast is skipped. A foreclosed app's acceptClaim would +// revert with ApplicationForeclosed. +func TestAcceptStagedSkipsBroadcastForForeclosedApp(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - endBlock := big.NewInt(40) - app := makeApplication() - prevEpoch := makeAcceptedEpoch(app, 1) - currEpoch := makeComputedEpoch(app, 3) - prevEvent := makeSubmittedEvent(app, prevEpoch) - wrongEvent := makeSubmittedEvent(app, makeComputedEpoch(app, 2)) + endBlock := big.NewInt(100) + app := withForeclosed(makeApplication(), 60) + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, wrongEvent, nil) - r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). - Return(nil).Once() + // Chain reports STAGED (status 1) — non-foreclosed apps would + // fall through to acceptClaimOnBlockchain. Foreclosed apps must not. + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + // CRITICAL: no acceptClaimOnBlockchain expectation — testify reports + // an unexpected call if the guard fails. - _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 1, len(errs)) + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx( + makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, transitions) + assert.Equal(t, 0, len(m.acceptsInFlight), + "no acceptClaim should enter the in-flight set for a foreclosed app") } -// !checkClaimsConstraint(prevClaim, currClaim) // epoch pair has its blocks out of order -func TestSubmitClaimWithAntecessorOutOfOrder(t *testing.T) { +// TestAcceptStagedCapEnforced — after maxAcceptAttempts consecutive attempts +// to call acceptClaim, the next entry into the per-epoch budget exhausts it +// and the app is marked FAILED without another broadcast. +func TestAcceptStagedCapEnforced(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) + endBlock := big.NewInt(100) app := makeApplication() - prevEpoch := makeSubmittedEpoch(app, 2) - currEpoch := makeComputedEpoch(app, 1) + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + // Prime the counter to exactly the cap — the next attempt must trip it. + m.acceptAttempts[acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index}] = int(m.maxAcceptAttempts) //nolint:gosec - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + // No call to acceptClaimOnBlockchain — the cap stops it. + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Failed, mock.Anything). Return(nil).Once() - _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), big.NewInt(0)) - assert.Equal(t, 1, len(errs)) + _, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + // SetFailedf returns nil on success — no error surfaced. + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, len(m.acceptsInFlight)) + // Counter cleared once FAILED is set. + _, present := m.acceptAttempts[acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index}] + assert.False(t, present) } -func TestErrSubmittedMissingEvent(t *testing.T) { +func TestAcceptStagedUnknownBroadcastErrorsIncrementAttemptsUntilCap(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) endBlock := big.NewInt(100) app := makeApplication() - prevEpoch := makeComputedEpoch(app, 1) - currEpoch := makeComputedEpoch(app, 2) - var prevEvent *iconsensus.IConsensusClaimSubmitted = nil - currEvent := makeSubmittedEvent(app, currEpoch) + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + attemptKey := acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index} + broadcastErr := fmt.Errorf("gas estimation failed") + + for i := uint64(1); i <= m.maxAcceptAttempts; i++ { + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + b.On("acceptClaimOnBlockchain", app, currEpoch). + Return(common.Hash{}, broadcastErr).Once() + + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.Equal(t, 0, transitions) + require.Equal(t, 1, len(errs)) + assert.ErrorIs(t, errs[0], broadcastErr) + assert.Equal(t, int(i), m.acceptAttempts[attemptKey]) //nolint:gosec + assert.Equal(t, 0, len(m.acceptsInFlight)) + } - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Failed, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "acceptClaim has failed") + })). Return(nil).Once() - _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 1, len(errs)) + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.Equal(t, 0, transitions) + assert.Equal(t, 0, len(errs), "marking FAILED after the cap is a state transition outcome, not a tick error") + assert.Equal(t, model.ApplicationState_Failed, app.State) + assert.NotContains(t, m.acceptAttempts, attemptKey) + assert.Equal(t, 0, len(m.acceptsInFlight)) } -func TestConsensusAddressChangedOnSubmittedClaims(t *testing.T) { +func TestAcceptClaimNotStagedAcceptedRechecksOutputsMismatch(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) endBlock := big.NewInt(100) app := makeApplication() - currEpoch := makeComputedEpoch(app, 3) - wrongConsensusAddress := app.IConsensusAddress - wrongConsensusAddress[0]++ + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + mismatch := makeClaimStatus(claimStatusAccepted, currEpoch, stagedAt) + mismatch.StagedOutputsMerkleRoot = common.HexToHash("0xdeadbeef") - b.On("getConsensusAddress", mock.Anything, app). - Return(wrongConsensusAddress, nil). - Once() - r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + b.On("acceptClaimOnBlockchain", app, currEpoch). + Return(common.Hash{}, claimNotStagedError(claimStatusAccepted)).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(mismatch, nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.Anything). Return(nil).Once() - _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, len(errs), 1) + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.Equal(t, 0, transitions) + assert.Equal(t, 0, len(m.acceptsInFlight)) } -//////////////////////////////////////////////////////////////////////////////// - -func TestFindClaimAcceptedEventAndSuccFailure0(t *testing.T) { - m, r, b := newServiceMock() - defer r.AssertExpectations(t) +// TestAcceptStagedPeriodNotElapsed — current block too low; no tx issued. +func TestAcceptStagedPeriodNotElapsed(t *testing.T) { + m, _, b := newServiceMock() defer b.AssertExpectations(t) - expectedErr := fmt.Errorf("not found") - endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 100 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + + endBlock := big.NewInt(60) // only 10 blocks elapsed; need 100. + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, transitions) + assert.Equal(t, 0, len(m.acceptsInFlight)) +} + +// TestAcceptStagedReaderMode — submissionEnabled=false; no acceptClaim tx +// is ever issued even when the period has elapsed. Caller waits for +// someone else to call acceptClaim (observed via the ClaimAccepted scan). +func TestAcceptStagedReaderMode(t *testing.T) { + m, _, b := newServiceMock() + defer b.AssertExpectations(t) + m.submissionEnabled = false app := makeApplication() - currEpoch := makeComputedEpoch(app, 2) - var prevEvent *iconsensus.IConsensusClaimAccepted = nil - currEvent := makeAcceptedEvent(app, currEpoch) + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + endBlock := big.NewInt(100) - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, expectedErr).Once() - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 1, len(errs)) + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, len(errs)) + assert.Equal(t, 0, transitions) + assert.Equal(t, 0, len(m.acceptsInFlight)) } -func TestFindClaimAcceptedEventAndSuccFailure1(t *testing.T) { +// TestAcceptanceDivergence_QuorumStagedDoesNotRejectEpoch verifies that a +// divergent accepted claim observed after our claim is already staged halts the +// app without rewriting the epoch to CLAIM_REJECTED. Under Quorum this is an +// invariant violation, not the normal outvoted path. +func TestAcceptanceDivergence_QuorumStagedDoesNotRejectEpoch(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - expectedErr := fmt.Errorf("not found") endBlock := big.NewInt(100) - app := makeApplication() - prevEpoch := makeAcceptedEpoch(app, 1) - currEpoch := makeComputedEpoch(app, 2) - prevEvent := makeAcceptedEvent(app, prevEpoch) - currEvent := makeAcceptedEvent(app, currEpoch) + app.ConsensusType = model.Consensus_Quorum + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) - b.On("getConsensusAddress", mock.Anything, app). + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.OutputsMerkleRoot, + MachineMerkleRoot: common.HexToHash("0xbad"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, expectedErr).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "quorum_divergence_at_acceptance") + })). + Return(nil).Once() - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, 1, len(errs)) + assert.Equal(t, model.ApplicationState_Inoperable, app.State) + assert.Equal(t, model.EpochStatus_ClaimStaged, currEpoch.Status) } -// !claimAcceptedMatch(prevClaim, prevEvent) -func TestAcceptClaimWithAntecessorMismatch(t *testing.T) { +func TestAcceptanceDivergence_QuorumComputedRejectsEpoch(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) endBlock := big.NewInt(100) app := makeApplication() - prevEpoch := makeAcceptedEpoch(app, 1) + app.ConsensusType = model.Consensus_Quorum currEpoch := makeComputedEpoch(app, 3) - prevEvent := &iconsensus.IConsensusClaimAccepted{ - LastProcessedBlockNumber: new(big.Int).SetUint64(prevEpoch.LastBlock + 1), + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), AppContract: app.IApplicationAddress, - OutputsMerkleRoot: *prevEpoch.OutputsMerkleRoot, + OutputsMerkleRoot: *currEpoch.OutputsMerkleRoot, + MachineMerkleRoot: common.HexToHash("0xbad"), } - var currEvent *iconsensus.IConsensusClaimAccepted = nil - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil) - r.On("UpdateApplicationState", mock.Anything, mock.Anything, model.ApplicationState_Inoperable, mock.Anything). + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("RejectEpochAndSetApplicationInoperable", mock.Anything, app.ID, currEpoch.Index, mock.MatchedBy(func(reason string) bool { + return strings.Contains(reason, "quorum_divergence_at_acceptance") + })). Return(nil).Once() - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, 1, len(errs)) + assert.Equal(t, model.ApplicationState_Inoperable, app.State) + assert.Equal(t, model.EpochStatus_ClaimRejected, currEpoch.Status) } -// !claimAcceptedMatch(currClaim, currEvent) -func TestAcceptClaimWithEventMismatch(t *testing.T) { +func TestAcceptanceDivergence_AuthorityComputedSetsInoperableWithoutRejectingEpoch(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) endBlock := big.NewInt(100) app := makeApplication() - prevEpoch := makeAcceptedEpoch(app, 1) - wrongEpoch := makeComputedEpoch(app, 2) currEpoch := makeComputedEpoch(app, 3) - wrongEvent := makeAcceptedEvent(app, wrongEpoch) - prevEvent := makeAcceptedEvent(app, prevEpoch) - b.On("getConsensusAddress", mock.Anything, app). + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.OutputsMerkleRoot, + MachineMerkleRoot: common.HexToHash("0xbad"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, wrongEvent, nil) - r.On("UpdateApplicationState", mock.Anything, mock.Anything, model.ApplicationState_Inoperable, mock.Anything). + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "authority_divergence_at_acceptance") + })). Return(nil).Once() - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, 1, len(errs)) + assert.Equal(t, model.ApplicationState_Inoperable, app.State) + assert.Equal(t, model.EpochStatus_ClaimComputed, currEpoch.Status) } -// !checkClaimsConstraint(prevClaim, currClaim) -func TestAcceptClaimWithAntecessorOutOfOrder(t *testing.T) { +func TestAcceptanceDivergence_AuthorityDoesNotRejectEpoch(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) + endBlock := big.NewInt(100) app := makeApplication() - wrongEpoch := makeComputedEpoch(app, 2) - currEpoch := makeComputedEpoch(app, 1) + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.OutputsMerkleRoot, + MachineMerkleRoot: common.HexToHash("0xbad"), + } - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - r.On("UpdateApplicationState", mock.Anything, mock.Anything, model.ApplicationState_Inoperable, mock.Anything). - Return(nil). - Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(wrongEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), big.NewInt(0)) + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) assert.Equal(t, 1, len(errs)) + assert.Equal(t, model.ApplicationState_Inoperable, app.State) + assert.Equal(t, model.EpochStatus_ClaimStaged, currEpoch.Status) } -func TestErrAcceptedMissingEvent(t *testing.T) { +// TestStagingDivergenceReaderMode_Quorum — reader-mode parity: with +// submissionEnabled=false, a divergent ClaimStaged event still fires the +// same INOPERABLE transition as in submit mode. No tx is ever issued (the +// stage's broadcast path is unconditionally skipped, so we don't even need +// to mock the broadcast helpers). +func TestStagingDivergenceReaderMode_Quorum(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) + m.submissionEnabled = false endBlock := big.NewInt(100) app := makeApplication() - prevEpoch := makeComputedEpoch(app, 1) - currEpoch := makeComputedEpoch(app, 2) - var prevEvent *iconsensus.IConsensusClaimAccepted = nil - currEvent := makeAcceptedEvent(app, currEpoch) + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeSubmittedEpoch(app, 3) + + differentMMR := common.HexToHash("0xfeed") + divergent := &iconsensus.IConsensusClaimStaged{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.OutputsMerkleRoot, + MachineMerkleRoot: differentMMR, + } - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - r.On("UpdateApplicationState", mock.Anything, mock.Anything, model.ApplicationState_Inoperable, mock.Anything). + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("RejectEpochAndSetApplicationInoperable", mock.Anything, app.ID, currEpoch.Index, mock.MatchedBy(func(reason string) bool { + return strings.Contains(reason, "quorum_divergence_at_staging") + })). Return(nil).Once() - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 1, len(errs)) + _, errs := m.stageClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs), "divergence detection must fire in reader mode") + assert.Equal(t, model.EpochStatus_ClaimRejected, currEpoch.Status) } -func TestUpdateEpochWithAcceptedClaimFailed(t *testing.T) { +// TestAcceptanceDivergenceReaderMode_Quorum — reader-mode parity for the +// acceptance stage. submissionEnabled doesn't gate event-based divergence +// detection; the INOPERABLE transition must fire identically, but a staged +// epoch remains CLAIM_STAGED because a different accepted claim is an invariant +// problem rather than normal Quorum rejection. +func TestAcceptanceDivergenceReaderMode_Quorum(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) - - expectedErr := fmt.Errorf("not found") + m.submissionEnabled = false endBlock := big.NewInt(100) app := makeApplication() - prevEpoch := makeSubmittedEpoch(app, 1) - currEpoch := makeSubmittedEpoch(app, 2) - prevEvent := makeAcceptedEvent(app, prevEpoch) - currEvent := makeAcceptedEvent(app, currEpoch) + app.ConsensusType = model.Consensus_Quorum + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.OutputsMerkleRoot, + MachineMerkleRoot: common.HexToHash("0xbad"), + } - b.On("getConsensusAddress", mock.Anything, app). + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). Return(app.IConsensusAddress, nil).Once() - b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). - Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() - r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index). - Return(expectedErr).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "quorum_divergence_at_acceptance") + })). + Return(nil).Once() - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, 1, len(errs)) + _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs), "acceptance divergence detection must fire in reader mode") + assert.Equal(t, model.EpochStatus_ClaimStaged, currEpoch.Status) } -func TestConsensusAddressChangedOnAcceptedClaims(t *testing.T) { +// TestHandleAcceptClaimRevert — exhaustive dispatch matrix for the typed +// reverts handleAcceptClaimRevert recognises. The classifier never mutates +// state, so all stateErr returns must be nil. +func TestHandleAcceptClaimRevert(t *testing.T) { + cases := []struct { + name string + err error + want acceptClaimRevertOutcome + }{ + { + name: "ClaimNotStaged_ACCEPTED_reconciles", + err: claimNotStagedError(claimStatusAccepted), + want: acceptClaimReconciledAccepted, + }, + { + name: "ClaimNotStaged_UNSTAGED_retries", + err: claimNotStagedError(claimStatusUnstaged), + want: acceptClaimRetryLater, + }, + { + name: "ClaimNotStaged_STAGED_retries", + err: claimNotStagedError(claimStatusStaged), + want: acceptClaimRetryLater, + }, + { + name: "ClaimNotStaged_unknown_status_retries", + err: claimNotStagedError(99), + want: acceptClaimRetryLater, + }, + { + name: "ClaimStagingPeriodNotOverYet_retries", + err: consensusRevertError("ClaimStagingPeriodNotOverYet"), + want: acceptClaimRetryLater, + }, + { + name: "ApplicationForeclosed_retries", + err: consensusRevertError("ApplicationForeclosed"), + want: acceptClaimRetryLater, + }, + { + name: "NonceTooLow_retries", + err: fmt.Errorf("nonce too low"), + want: acceptClaimRetryLater, + }, + { + name: "NonceTooLow_wrapped_retries", + err: fmt.Errorf("send transaction: %w", fmt.Errorf("[nonce too low]")), + want: acceptClaimRetryLater, + }, + { + name: "unknown_error_returns_unknown", + err: fmt.Errorf("some non-typed RPC failure"), + want: acceptClaimUnknown, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m, _, _ := newServiceMock() + app := makeApplication() + epoch := makeStagedEpoch(app, 3, 50) + outcome, stateErr := m.handleAcceptClaimRevert(tc.err, app, epoch) + assert.Equal(t, tc.want, outcome) + assert.Nil(t, stateErr, "classifier must not mutate state") + }) + } +} + +// TestHandleSubmitClaimRevert — exhaustive dispatch matrix for the typed +// reverts handleSubmitClaimRevert recognises plus the JSON-RPC +// "nonce too low" broadcast rejection. The classifier mutates state only +// for the AppHalted outcomes (InvalidOutputsMerkleRootProofSize, +// CallerIsNotValidator); for the others stateErr must be nil. +func TestHandleSubmitClaimRevert(t *testing.T) { + cases := []struct { + name string + err error + // We compare outcomes only; mutating-outcome rows still flow + // through the classifier but we do not assert state changes + // here (those paths are exercised end-to-end elsewhere). + want submitClaimRevertOutcome + }{ + { + name: "NotFirstClaim_Authority_alreadyOnChain", + err: consensusRevertError("NotFirstClaim"), + want: submitClaimAlreadyOnChain, + }, + { + name: "ApplicationForeclosed_retries", + err: consensusRevertError("ApplicationForeclosed"), + want: submitClaimRetryLater, + }, + { + name: "NonceTooLow_retries", + err: fmt.Errorf("nonce too low"), + want: submitClaimRetryLater, + }, + { + name: "NonceTooLow_wrapped_retries", + err: fmt.Errorf("send transaction: %w", fmt.Errorf("[nonce too low]")), + want: submitClaimRetryLater, + }, + { + name: "unknown_error_returns_unknown", + err: fmt.Errorf("some non-typed RPC failure"), + want: submitClaimUnknown, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m, _, _ := newServiceMock() + app := makeApplication() + // Authority is the default; NotFirstClaim returns + // AlreadyOnChain for it. Quorum-specific routing is + // covered by the existing end-to-end pipeline tests. + app.ConsensusType = model.Consensus_Authority + epoch := makeEpoch(app.ID, model.EpochStatus_ClaimComputed, 3) + outcome, _ := m.handleSubmitClaimRevert(tc.err, app, epoch) + assert.Equal(t, tc.want, outcome) + }) + } +} + +// TestVerifyClaimOutputsMismatch — pre-accept getClaim returns STAGED but +// with a stagedOutputsMerkleRoot that disagrees with the local epoch. The +// app is set INOPERABLE with the chain_claim_outputs_mismatch reason; no +// broadcast follows. +func TestVerifyClaimOutputsMismatch(t *testing.T) { m, r, b := newServiceMock() defer r.AssertExpectations(t) defer b.AssertExpectations(t) endBlock := big.NewInt(100) app := makeApplication() - currEpoch := makeComputedEpoch(app, 3) - wrongConsensusAddress := app.IConsensusAddress - wrongConsensusAddress[0]++ + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) - b.On("getConsensusAddress", mock.Anything, app). - Return(wrongConsensusAddress, nil). - Once() - r.On("UpdateApplicationState", mock.Anything, int64(0), model.ApplicationState_Inoperable, mock.Anything). - Return(nil). - Once() + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + claim := makeClaimStatus(claimStatusStaged, currEpoch, stagedAt) + claim.StagedOutputsMerkleRoot = common.HexToHash("0xdeadbeef") + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(claim, nil).Once() + // No acceptClaimOnBlockchain call — mismatch trips before broadcast. + r.On("UpdateApplicationState", mock.Anything, app.ID, model.ApplicationState_Inoperable, mock.Anything). + Return(nil).Once() - _, errs := m.acceptClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) - assert.Equal(t, len(errs), 1) + _, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs), "chain_claim_outputs_mismatch must surface as an error") + assert.Equal(t, 0, len(m.acceptsInFlight)) +} + +// TestCleanupOrphanedInFlight — entries whose app is no longer in any work +// map (e.g. transitioned to FAILED/INOPERABLE/DISABLED mid-flight) must be +// dropped at end of tick. Entries for apps still present are kept. +func TestCleanupOrphanedInFlight(t *testing.T) { + m, _, _ := newServiceMock() + + liveApp := makeApplication() // ID = 0 + stagedApp := repotest.NewApplicationBuilder(). + WithName("staged-app").Build() + stagedApp.ID = 1 + stagedEpoch := makeStagedEpoch(stagedApp, 7, 50) + + // Live app: kept in computedApps. Its entry must survive. + m.claimsInFlight[liveApp.ID] = inFlightTx{txHash: common.HexToHash("0xaa")} + + // Orphan app: not in any work map. Its entries must be dropped. + const orphanAppID int64 = 99 + m.claimsInFlight[orphanAppID] = inFlightTx{txHash: common.HexToHash("0xbb")} + m.acceptsInFlight[orphanAppID] = inFlightTx{txHash: common.HexToHash("0xcc")} + m.acceptAttempts[acceptAttemptKey{orphanAppID, 3}] = 4 + + // Staged app present but for a different epoch — old counter must be dropped. + m.acceptsInFlight[stagedApp.ID] = inFlightTx{txHash: common.HexToHash("0xdd")} + m.acceptAttempts[acceptAttemptKey{stagedApp.ID, stagedEpoch.Index}] = 2 + m.acceptAttempts[acceptAttemptKey{stagedApp.ID, stagedEpoch.Index - 1}] = 9 + + m.cleanupOrphanedInFlight( + makeApplicationMap(liveApp), + makeApplicationMap(stagedApp), + makeEpochMap(stagedEpoch), + ) + + _, liveOK := m.claimsInFlight[liveApp.ID] + assert.True(t, liveOK, "live app's submit-in-flight must be kept") + + _, orphanSubmit := m.claimsInFlight[orphanAppID] + assert.False(t, orphanSubmit, "orphan submit-in-flight must be dropped") + _, orphanAccept := m.acceptsInFlight[orphanAppID] + assert.False(t, orphanAccept, "orphan accept-in-flight must be dropped") + _, orphanAttempts := m.acceptAttempts[acceptAttemptKey{orphanAppID, 3}] + assert.False(t, orphanAttempts, "orphan accept-attempt counter must be dropped") + + _, stagedAccept := m.acceptsInFlight[stagedApp.ID] + assert.True(t, stagedAccept, "live staged app's accept-in-flight must be kept") + _, currentCounter := m.acceptAttempts[acceptAttemptKey{stagedApp.ID, stagedEpoch.Index}] + assert.True(t, currentCounter, "live staged app's current-epoch counter must be kept") + _, oldCounter := m.acceptAttempts[acceptAttemptKey{stagedApp.ID, stagedEpoch.Index - 1}] + assert.False(t, oldCounter, "counter for a non-current epoch on the same app must be dropped") } diff --git a/internal/claimer/foreclosed_apps_test.go b/internal/claimer/foreclosed_apps_test.go new file mode 100644 index 000000000..37eb24f92 --- /dev/null +++ b/internal/claimer/foreclosed_apps_test.go @@ -0,0 +1,163 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "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/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// foreclosedAppHelper builds a foreclosed Application instance, optionally +// with a PRT consensus type. ForecloseBlock is non-zero, mirroring what +// the evmreader's checkForForeclosure would have persisted. +// LastInputCheckBlock is parked at the foreclose block so callers that do +// not exercise the bootstrap-readiness guard skip past it; tests that +// exercise the guard override the field explicitly. +func foreclosedAppHelper(id int64, block uint64, consensus model.Consensus) *model.Application { + txHash := common.HexToHash("0xdeadbeef") + return &model.Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(common.Big1), + ConsensusType: consensus, + State: model.ApplicationState_Enabled, + ForecloseBlock: block, + ForecloseTransaction: &txHash, + LastInputCheckBlock: block, + } +} + +// TestListEnabledForeclosedNonPRTApps_ExcludesPRT verifies the in-memory +// PRT filter applied after ListApplications. PRT apps have their own +// post-foreclosure drain visibility path; the claimer's non-PRT loop must +// never pick them up, even if the DB layer surfaces them. +func TestListEnabledForeclosedNonPRTApps_ExcludesPRT(t *testing.T) { + s, r, _ := newServiceMock() + defer r.AssertExpectations(t) + + auth := foreclosedAppHelper(1, 100, model.Consensus_Authority) + quorum := foreclosedAppHelper(2, 200, model.Consensus_Quorum) + prtApp := foreclosedAppHelper(3, 300, model.Consensus_PRT) + + // Match the exact filter the service issues so the test catches + // regressions in either side. ForeclosureRecorded must be passed + // as &yes; State as &Enabled. + r.On("ListApplications", + mock.Anything, + mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.State != nil && *f.State == model.ApplicationState_Enabled && + f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), + mock.Anything, + mock.Anything, + ).Return([]*model.Application{auth, quorum, prtApp}, 3, nil).Once() + + got, err := s.listEnabledForeclosedNonPRTApps() + require.NoError(t, err) + require.Len(t, got, 2) + assert.Contains(t, got, auth.ID) + assert.Contains(t, got, quorum.ID) + assert.NotContains(t, got, prtApp.ID, + "PRT apps are owned by the PRT service and must be filtered out") +} + +// TestProcessForeclosedApps_DefersWhenUnreconciled verifies that an app +// whose pre-foreclosure epochs have not all reached CLAIM_ACCEPTED stays +// in ENABLED. The deferral branch must NOT issue an UpdateApplicationState +// call — transitioning before the advancer/validator finish would lose +// the last-known epoch outputs needed for any in-flight dispute; firing +// before claim reconciliation completes would leave the local DB final +// state divergent from the chain. +func TestProcessForeclosedApps_DefersWhenUnreconciled(t *testing.T) { + s, r, _ := newServiceMock() + defer r.AssertExpectations(t) + + app := foreclosedAppHelper(1, 100, model.Consensus_Authority) + s.Context = context.Background() + + r.On("HasUnreconciledClaimsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + + // No UpdateApplicationState expectation — if it fires, the mock + // assertion fails the test because we registered no Setup for it. + + errs := s.processForeclosedApps(map[int64]*model.Application{app.ID: app}) + assert.Empty(t, errs, "deferral is not an error") +} + +// TestProcessForeclosedApps_NoTransitionWhenDrained verifies that once both +// gates clear (bootstrap-readiness + drain reconciliation), the per-app +// branch is a no-op. No UpdateApplicationState call fires — the app stays +// ENABLED with foreclose_block set, and the post-foreclosure observation +// work (drive-prove discovery, withdrawal indexing) lives in evmreader. +// INOPERABLE is reserved for genuine corruption. +// +// The mock has no UpdateApplicationState expectation registered; +// testify/mock fails the test on an unexpected call, so any regression that +// re-introduces a terminal-state transition trips this test loudly. +func TestProcessForeclosedApps_NoTransitionWhenDrained(t *testing.T) { + s, r, _ := newServiceMock() + defer r.AssertExpectations(t) + + app := foreclosedAppHelper(1, 100, model.Consensus_Authority) + s.Context = context.Background() + + r.On("HasUnreconciledClaimsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + + // No UpdateApplicationState expectation — the assertion is by negation. + + errs := s.processForeclosedApps(map[int64]*model.Application{app.ID: app}) + assert.Empty(t, errs) +} + +// TestProcessForeclosedApps_SkipsZeroForecloseBlock is a defensive check on +// the loop's "should have been filtered" guard. partitionForeclosedApps is +// the only intended source of input, but a caller bug or future refactor +// could feed an app with a zero ForecloseBlock here; the loop must skip it +// silently rather than treat block 0 as a real foreclosure marker. +func TestProcessForeclosedApps_SkipsZeroForecloseBlock(t *testing.T) { + s, r, _ := newServiceMock() + defer r.AssertExpectations(t) + + app := &model.Application{ID: 99, ConsensusType: model.Consensus_Authority} + s.Context = context.Background() + + // No mock expectations — the loop must skip before any repo call. + errs := s.processForeclosedApps(map[int64]*model.Application{app.ID: app}) + assert.Empty(t, errs) +} + +// TestProcessForeclosedApps_DefersWhenStillBackfilling verifies the +// bootstrap-readiness guard. When a freshly registered Authority/Quorum +// app encounters an already-foreclosed contract, evmreader sets +// ForecloseBlock before checkForNewInputs has had time to ingest the +// historical inputs. If the drain check fires inside that window, the gate +// sees an empty epoch table and incorrectly returns false, making the app look +// drained before any pre-foreclosure epoch is observed locally. The guard must +// defer the drain check until LastInputCheckBlock >= ForecloseBlock. +// +// Neither HasUnreconciledClaimsBeforeBlock nor UpdateApplicationState +// has an `.On` registered; testify/mock panics on an unexpected call, +// so either reach attempt fails the test loudly. +func TestProcessForeclosedApps_DefersWhenStillBackfilling(t *testing.T) { + s, r, _ := newServiceMock() + defer r.AssertExpectations(t) + + app := foreclosedAppHelper(1, 100, model.Consensus_Authority) + app.LastInputCheckBlock = 50 // scanner well below the foreclose block + s.Context = context.Background() + + errs := s.processForeclosedApps(map[int64]*model.Application{app.ID: app}) + assert.Empty(t, errs, "bootstrap deferral is not an error") +} diff --git a/internal/claimer/prior_counter_test.go b/internal/claimer/prior_counter_test.go new file mode 100644 index 000000000..b4fc8ccb5 --- /dev/null +++ b/internal/claimer/prior_counter_test.go @@ -0,0 +1,181 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeStepCounter returns an oracle whose value at block b is the count of +// transitions in `transitions` that are <= b. The transition list must be +// sorted ascending. This is a faithful stand-in for the on-chain +// GetNumberOfSubmittedClaims / GetNumberOfAcceptedClaims counters: monotonic, +// integer, increments at the event block. +func makeStepCounter(transitions []uint64, calls *[]uint64) ethutil.TransitionQueryFn { + return func(_ context.Context, block uint64) (*big.Int, error) { + if calls != nil { + *calls = append(*calls, block) + } + var n int64 + for _, t := range transitions { + if t <= block { + n++ + } + } + return big.NewInt(n), nil + } +} + +func TestPriorCounter_QueriesFromBlockMinusOne(t *testing.T) { + // fromBlock = 70 should hit oracle exactly once, at block 69. Counter + // at block 69 is 0 (the only acceptance fires at block 80), so + // priorCounter must return *big.Int(0), not nil and not the value + // at block 70 itself (which is also 0 here, indistinguishable) and + // definitely not the value at epoch.LastBlock=90 (which is 1). + calls := []uint64{} + oracle := makeStepCounter([]uint64{80}, &calls) + + got, err := priorCounter(context.Background(), oracle, 70) + require.NoError(t, err) + require.NotNil(t, got, "priorCounter must return a value (non-nil *big.Int) when fromBlock > 0") + assert.Equal(t, int64(0), got.Int64()) + require.Len(t, calls, 1, "priorCounter must make exactly one oracle call") + assert.Equal(t, uint64(69), calls[0], + "priorCounter must query block fromBlock-1, not fromBlock and not epoch.LastBlock") +} + +func TestPriorCounter_FromBlockOne(t *testing.T) { + // fromBlock = 1 is the smallest non-zero value; oracle must be called + // at block 0 (not block 1, not block -1). + calls := []uint64{} + oracle := makeStepCounter([]uint64{0}, &calls) + + got, err := priorCounter(context.Background(), oracle, 1) + require.NoError(t, err) + require.NotNil(t, got) + // Counter at block 0 with a transition AT block 0 is 1 (the step is + // "count of transitions <= block"). This pins that priorCounter does + // NOT off-by-one in the other direction (block 0 - 1 wrap-around). + assert.Equal(t, int64(1), got.Int64(), + "priorCounter(1) must query oracle(0); a counter that fired at block 0 must be visible") + require.Len(t, calls, 1) + assert.Equal(t, uint64(0), calls[0]) +} + +func TestPriorCounter_FromBlockZero(t *testing.T) { + // fromBlock = 0 has no "block before"; querying oracle(uint64(0)-1) + // would wrap to math.MaxUint64 and either error at the RPC layer or + // return a misleading head-of-chain counter. priorCounter must + // short-circuit and return (nil, nil) without calling the oracle. + calls := []uint64{} + oracle := makeStepCounter([]uint64{0, 5, 10}, &calls) + + got, err := priorCounter(context.Background(), oracle, 0) + require.NoError(t, err) + assert.Nil(t, got, + "priorCounter(fromBlock=0) must return nil (signaling FindTransitions to skip the boundary monotonic check)") + assert.Empty(t, calls, + "priorCounter(fromBlock=0) must NOT call the oracle — there is no fromBlock-1 to query") +} + +func TestPriorCounter_PropagatesOracleError(t *testing.T) { + // Oracle errors must surface unchanged so the caller can fail the + // claim cycle rather than silently treat a transient RPC failure as + // "no prior counter". + sentinel := errors.New("rpc unavailable") + oracle := func(_ context.Context, _ uint64) (*big.Int, error) { + return nil, sentinel + } + + got, err := priorCounter(context.Background(), oracle, 70) + assert.Nil(t, got) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) +} + +// TestFindTransitions_PrevValueRegression pins the prevValue contract of +// ethutil.FindTransitions: the caller must pass oracle(fromBlock-1), not +// oracle(epoch.LastBlock). Using the counter at any block past the scan +// window violates FindTransitions' monotonic invariant +// (prevValue <= oracle(fromBlock)) as soon as a transition fires inside +// the window, aborting the whole scan. +// +// Setup mirrors the multi-epoch foreclosure-replay scenario: +// +// fromBlock = 70 (prevEpoch.LastBlock + 1; scan starts here) +// currEpoch.LastBlock = 90 +// transitions at blocks 75, 85 (two acceptance events inside [70, 90]) +// oracle(69) = 0 (priorCounter — correct prevValue) +// oracle(70) = 0 (startValue — same block the scan begins from) +// oracle(90) = 2 (the buggy prevValue: counter at currEpoch.LastBlock) +// +// With prevValue = 2 (the bug) FindTransitions returns +// "monotonic assumption violated: prevValue 2 > startValue 0 at block 70" +// and never scans the interior. With prevValue = priorCounter(...) = 0 the +// scan completes and surfaces both transition blocks in chronological order. +func TestFindTransitions_PrevValueRegression(t *testing.T) { + ctx := context.Background() + const ( + fromBlock uint64 = 70 + currEpochLastBlk uint64 = 90 + ) + transitions := []uint64{75, 85} + + t.Run("BuggyOracleAtEpochLastBlockTripsMonotonicCheck", func(t *testing.T) { + oracle := makeStepCounter(transitions, nil) + + // The buggy pattern: pass the counter at the CURRENT epoch's + // LastBlock as prevValue. This is "the counter at some unrelated + // block" — specifically a block past several in-scan-window + // transitions. + buggyPrevValue, err := oracle(ctx, currEpochLastBlk) + require.NoError(t, err) + require.Equal(t, int64(2), buggyPrevValue.Int64(), + "sanity: oracle(currEpoch.LastBlock=90) must observe both transitions") + + hits := []uint64{} + onHit := func(block uint64) error { + hits = append(hits, block) + return nil + } + + _, err = ethutil.FindTransitions(ctx, fromBlock, currEpochLastBlk, + buggyPrevValue, oracle, onHit) + require.Error(t, err, "the buggy prevValue MUST trip the monotonic-assumption check") + assert.Contains(t, err.Error(), "monotonic assumption violated", + "the specific error string is the reason this bug went undetected for so long; pin it") + assert.Empty(t, hits, + "on monotonic violation the scan aborts before any interior split; no onHit call must fire") + }) + + t.Run("PriorCounterFixCompletesScan", func(t *testing.T) { + oracle := makeStepCounter(transitions, nil) + + fixedPrevValue, err := priorCounter(ctx, oracle, fromBlock) + require.NoError(t, err) + require.NotNil(t, fixedPrevValue) + require.Equal(t, int64(0), fixedPrevValue.Int64(), + "sanity: priorCounter at fromBlock=70 must read oracle(69)=0 (no transitions yet)") + + hits := []uint64{} + onHit := func(block uint64) error { + hits = append(hits, block) + return nil + } + + count, err := ethutil.FindTransitions(ctx, fromBlock, currEpochLastBlk, + fixedPrevValue, oracle, onHit) + require.NoError(t, err, "priorCounter MUST satisfy FindTransitions' monotonic invariant") + assert.Equal(t, uint64(len(transitions)), count) + assert.Equal(t, transitions, hits, + "every transition block in [fromBlock, currEpoch.LastBlock] must be reported in chronological order") + }) +} diff --git a/internal/claimer/service.go b/internal/claimer/service.go index 41039cca1..e629a6327 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log/slog" + "math/big" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" @@ -37,14 +38,66 @@ type Service struct { // submitted claims waiting for confirmation from the blockchain. // only accessed from tick, so no need for a lock - // contains: application ID -> transaction hash, with a maximum of one - // key per application due to the epoch advancement logic. - claimsInFlight map[int64]common.Hash + // contains: application ID -> transaction metadata, with a maximum of + // one key per application due to the epoch advancement logic. + claimsInFlight map[int64]inFlightTx + + // acceptClaim transactions waiting for confirmation. Same shape and + // ownership as claimsInFlight; tracked separately because at any moment + // an app may have both a submit-in-flight (for a newer epoch) and an + // accept-in-flight (for an older one). + acceptsInFlight map[int64]inFlightTx + + // acceptAttempts counts consecutive acceptClaim attempts per + // (appID, epochIndex). Bounded by maxAcceptAttempts; once exceeded the + // app is marked FAILED so the operator can intervene rather than the + // claimer burning gas on an indefinitely-reverting tx. In-memory only — + // restart clears the counter, which is acceptable because operators + // inspect logs/state on restart anyway. + acceptAttempts map[acceptAttemptKey]int + + // maxAcceptAttempts caps the per-(app,epoch) counter above. Sourced + // from CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS (default 5); operators with + // brittle gas markets may raise it. + maxAcceptAttempts uint64 + submissionEnabled bool } +// acceptAttemptKey identifies one outstanding acceptClaim retry budget. +type acceptAttemptKey struct { + appID int64 + epochIndex uint64 +} + +type inFlightTx struct { + txHash common.Hash + firstSeenBlock uint64 +} + +// defaultMaxAcceptAttempts is the fallback for the cap when no config is +// supplied (only the test harness; the env var has a default of 5). +const defaultMaxAcceptAttempts uint64 = 5 + +// maxInFlightReceiptNotFoundBlocks bounds how long we trust +// TransactionReceipt(ethereum.NotFound) as "still pending". Two Ethereum +// epochs are 64 slots; this watchdog uses block numbers because the claimer +// already reasons in terms of the configured default block tag. +const maxInFlightReceiptNotFoundBlocks uint64 = 64 + const ClaimerConfigKey = "claimer" +func (tx inFlightTx) ageAt(blockNumber *big.Int) uint64 { + if blockNumber == nil || blockNumber.Sign() < 0 { + return 0 + } + current := blockNumber.Uint64() + if current <= tx.firstSeenBlock { + return 0 + } + return current - tx.firstSeenBlock +} + type PersistentConfig struct { DefaultBlock model.DefaultBlock ClaimSubmissionEnabled bool @@ -94,7 +147,13 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { chainId.Uint64(), nodeConfig.ChainID) } s.submissionEnabled = nodeConfig.ClaimSubmissionEnabled - s.claimsInFlight = map[int64]common.Hash{} + s.claimsInFlight = map[int64]inFlightTx{} + s.acceptsInFlight = map[int64]inFlightTx{} + s.acceptAttempts = map[acceptAttemptKey]int{} + s.maxAcceptAttempts = c.Config.ClaimerMaxAcceptAttempts + if s.maxAcceptAttempts == 0 { + s.maxAcceptAttempts = defaultMaxAcceptAttempts + } var txOpts *bind.TransactOpts = nil if s.submissionEnabled { @@ -109,7 +168,7 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { logger: s.Logger, client: c.EthConn, txOpts: txOpts, - defaultBlock: c.Config.BlockchainDefaultBlock, + defaultBlock: nodeConfig.DefaultBlock, } return s, nil @@ -136,50 +195,260 @@ func (s *Service) Stop(bool) []error { func (s *Service) Tick() []error { errs := []error{} - // gather epochs pairs with open claims, either: - // - computed but not yet submitted - acceptedOrSubmittedEpochs, computedEpochs, computedApps, errSubmitted := s.repository.SelectSubmittedClaimPairsPerApp(s.Context) + // Pin all chain reads in this tick to the same finalized block. One RPC + // per tick even when there's no DB work — acceptable since the call is + // cheap and Tick is gated by polling interval. + defaultBlockNumber, err := s.blockchain.getDefaultBlockNumber(s.Context) + if err != nil { + // During shutdown the parent context is canceled and every + // in-flight RPC/DB call returns context.Canceled. Suppress only + // the graceful-shutdown case; deadline-exceeded (real failure) + // must still propagate. Mirrors internal/prt/service.go's Tick. + if s.IsStopping() && errors.Is(err, context.Canceled) { + s.Logger.Warn("Tick interrupted by shutdown", "stage", "getDefaultBlockNumber", "error", err) + return nil + } + errs = append(errs, err) + return errs + } + + // Stages are run interleaved with their input selects so each stage's + // snapshot reflects the previous stage's mutations. With all-selects-first + // (the prior shape), a row promoted to SUBMITTED by stage 1 wasn't visible + // to stage 2 until the next tick, spreading a single chain cascade + // (COMPUTED → SUBMITTED → STAGED) across three ticks. + + // Stage 1 — submit: COMPUTED → SUBMITTED (or STAGED via the receipt fast-path). + prevSubmittedOrStaged, computedEpochs, computedApps, errComputed := s.repository.SelectSubmittedClaimPairsPerApp(s.Context) + if errComputed != nil { + if s.IsStopping() && errors.Is(errComputed, context.Canceled) { + s.Logger.Warn("Tick interrupted by shutdown", "stage", "SelectSubmittedClaimPairsPerApp", "error", errComputed) + return nil + } + errs = append(errs, errComputed) + return errs + } + submitted, submitErrs := s.submitClaimsAndUpdateDatabase( + prevSubmittedOrStaged, computedEpochs, computedApps, defaultBlockNumber) + errs = append(errs, submitErrs...) + + // Stage 2 — stage: SUBMITTED → STAGED. Snapshot sees stage 1's mutations. + prevAcceptedForSubmitted, submittedEpochs, submittedApps, errSubmitted := s.repository.SelectAcceptedClaimPairsPerApp(s.Context) if errSubmitted != nil { + if s.IsStopping() && errors.Is(errSubmitted, context.Canceled) { + s.Logger.Warn("Tick interrupted by shutdown", "stage", "SelectAcceptedClaimPairsPerApp", "error", errSubmitted) + return nil + } errs = append(errs, errSubmitted) return errs } - - // - submitted but not yet accepted. - acceptedEpochs, submittedEpochs, submittedApps, errAccepted := s.repository.SelectAcceptedClaimPairsPerApp(s.Context) - if errAccepted != nil { - errs = append(errs, errAccepted) + staged, stageErrs := s.stageClaimsAndUpdateDatabase( + prevAcceptedForSubmitted, submittedEpochs, submittedApps, defaultBlockNumber) + errs = append(errs, stageErrs...) + + // Stages 3/4/5 — accept: STAGED → ACCEPTED via own tx, foreign event, or + // pre-accept getClaim reconciliation. Snapshot sees stage 1's and stage 2's + // mutations. + prevAcceptedForStaged, stagedEpochs, stagedApps, errStaged := s.repository.SelectStagedClaimPairsPerApp(s.Context) + if errStaged != nil { + if s.IsStopping() && errors.Is(errStaged, context.Canceled) { + s.Logger.Warn("Tick interrupted by shutdown", "stage", "SelectStagedClaimPairsPerApp", "error", errStaged) + return nil + } + errs = append(errs, errStaged) return errs } - s.Logger.Debug("Processing claims for epochs", + // Foreclosure drain set. Foreclosed apps remain in the three work-maps + // above so the read-only reconciliation steps inside Stages 1/2/3 + // (findClaimSubmittedEvent, getClaim, findClaimStagedEvent) can mirror + // pre-foreclosure on-chain-accepted epochs into the local DB as + // CLAIM_ACCEPTED. The submitClaim and acceptClaim broadcasts inside + // those stages are skipped when app.ForecloseBlock != nil — see the + // guards in submitClaimsAndUpdateDatabase and + // acceptStagedClaimsAndIssueAcceptTx — so no gas is burned on + // guaranteed reverts. The targeted query below only drives the + // post-foreclosure drain/reconciliation visibility path; once drained, + // the app remains ENABLED with foreclose_block set. + foreclosed, listErr := s.listEnabledForeclosedNonPRTApps() + if listErr != nil { + errs = append(errs, listErr) + } + + issuedAccepts, issueErrs := s.acceptStagedClaimsAndIssueAcceptTx( + stagedEpochs, stagedApps, defaultBlockNumber) + errs = append(errs, issueErrs...) + + confirmedAccepts, confirmErr := s.checkAcceptsInFlight( + stagedEpochs, stagedApps, defaultBlockNumber) + if confirmErr != nil { + errs = append(errs, confirmErr) + } + + accepted, acceptErrs := s.acceptClaimsAndUpdateDatabase( + prevAcceptedForStaged, stagedEpochs, stagedApps, defaultBlockNumber) + errs = append(errs, acceptErrs...) + + // Drain-detect foreclosed apps: keep logging visibility until the + // advancer/validator have processed every pre-foreclosure epoch. Once + // drained, processForeclosedApps is intentionally a no-op. + forecloseErrs := s.processForeclosedApps(foreclosed) + errs = append(errs, forecloseErrs...) + + s.cleanupOrphanedInFlight(computedApps, stagedApps, stagedEpochs) + + s.Logger.Debug("Processed claims for epochs", "computed", len(computedEpochs), "submitted", len(submittedEpochs), + "staged", len(stagedEpochs), ) - // return early if there is nothing to do - if len(computedEpochs) == 0 && len(submittedEpochs) == 0 { - return nil + // Signal reschedule whenever pipeline progress was made, even with errors. + if submitted > 0 || staged > 0 || issuedAccepts > 0 || confirmedAccepts > 0 || accepted > 0 { + s.SignalReschedule() } + return errs +} - // we have claims to check. Get the latest/safe/finalized, etc. block - defaultBlockNumber, err := s.blockchain.getDefaultBlockNumber(s.Context) - if err != nil { - errs = append(errs, err) - return errs +// cleanupOrphanedInFlight drops in-memory tracking for apps/epochs that no +// longer appear in any work map. An app whose state transitioned to +// FAILED/INOPERABLE/DISABLED mid-flight is excluded from the next tick's +// selects, but without this cleanup its claimsInFlight, acceptsInFlight, +// and acceptAttempts entries would leak for the process lifetime. Also +// covers the case where a broadcast tx is permanently dropped from the +// mempool (the app moves through its lifecycle, the in-flight key never +// resolves). +// +// claimsInFlight and acceptsInFlight are keyed by appID; an in-flight tx +// implies the source-state epoch is still present (CLAIM_COMPUTED for +// submits, CLAIM_STAGED for accepts), so we keep the entry iff the app is +// still in the corresponding work map. acceptAttempts is keyed by +// (appID, epochIndex) and is cleared when the matching epoch leaves +// stagedEpochs. +func (s *Service) cleanupOrphanedInFlight( + computedApps map[int64]*model.Application, + stagedApps map[int64]*model.Application, + stagedEpochs map[int64]*model.Epoch, +) { + for appID, tx := range s.claimsInFlight { + if _, ok := computedApps[appID]; ok { + continue + } + s.Logger.Debug("Dropping orphaned submit-in-flight entry", + "app_id", appID, "tx", tx.txHash) + delete(s.claimsInFlight, appID) + } + for appID, tx := range s.acceptsInFlight { + if _, ok := stagedApps[appID]; ok { + continue + } + s.Logger.Debug("Dropping orphaned accept-in-flight entry", + "app_id", appID, "tx", tx.txHash) + delete(s.acceptsInFlight, appID) + } + for key, attempts := range s.acceptAttempts { + if epoch, ok := stagedEpochs[key.appID]; ok && epoch.Index == key.epochIndex { + continue + } + s.Logger.Debug("Dropping orphaned accept-attempt counter", + "app_id", key.appID, "epoch_index", key.epochIndex, "attempts", attempts) + delete(s.acceptAttempts, key) } +} - submitted, submitErrs := s.submitClaimsAndUpdateDatabase(acceptedOrSubmittedEpochs, computedEpochs, computedApps, defaultBlockNumber) - accepted, acceptErrs := s.acceptClaimsAndUpdateDatabase(acceptedEpochs, submittedEpochs, submittedApps, defaultBlockNumber) - errs = append(errs, submitErrs...) - errs = append(errs, acceptErrs...) +// listEnabledForeclosedNonPRTApps returns ENABLED, foreclosed, non-PRT apps, +// keyed by Application.ID. Used to keep observing apps that have no pending +// claim work in the three SELECT queries (typical after the last +// pre-foreclosure claim is accepted) but still need drain/reconciliation +// visibility before settling into the terminal ENABLED + foreclose_block state. +func (s *Service) listEnabledForeclosedNonPRTApps() (map[int64]*model.Application, error) { + enabled := model.ApplicationState_Enabled + yes := true + apps, _, err := s.repository.ListApplications( + s.Context, + repository.ApplicationFilter{State: &enabled, ForeclosureRecorded: &yes}, + repository.Pagination{}, + false, + ) + if err != nil { + return nil, fmt.Errorf("listing enabled foreclosed apps: %w", err) + } + out := make(map[int64]*model.Application, len(apps)) + for _, app := range apps { + if app.ConsensusType == model.Consensus_PRT { + continue + } + out[app.ID] = app + } + return out, nil +} - // Signal reschedule whenever pipeline progress was made, even with errors. - // Accepting a claim frees the pipeline slot for the next epoch's submission. - // Confirming a submission enables the acceptance scan on the next tick. - // Erring apps are retried on the next tick regardless; suppressing - // reschedule would delay healthy apps by a full poll interval. - if submitted > 0 || accepted > 0 { - s.SignalReschedule() +// processForeclosedApps observes foreclosed-but-still-ENABLED apps once per +// tick, logging visibility into the two-phase drain gate. Foreclosure no +// longer transitions the app to INOPERABLE — evmreader continues observing +// the post-foreclosure phase (drive-prove, withdrawals) and the app stays +// ENABLED with `foreclose_block != 0` as the terminal state. INOPERABLE is +// reserved for genuine corruption (invalid proof, divergent submission, +// divergent acceptance, etc.). +// +// The function still runs because: +// - The Info logs give operators visibility while the bootstrap-readiness +// guard or the broad drain gate are still pending; once both clear, the +// function returns silently per app. +// - Other claim-broadcast guards (`if app.ForecloseBlock != 0 { continue }` +// in submit/accept stages) already short-circuit gas-burning work. +// +// Once both gates clear, the per-app branch is a no-op: there is no terminal +// action. The post-foreclosure observation lives in evmreader. +func (s *Service) processForeclosedApps( + apps map[int64]*model.Application, +) []error { + var errs []error + for _, app := range apps { + if app.ForecloseBlock == 0 { + // Defensive — should have been filtered. + continue + } + // Bootstrap-readiness guard. The drain gate below answers "given + // the rows currently in the local epoch/input tables, is there + // pending pre-foreclosure work?". For a freshly registered app + // against an already-foreclosed contract, evmreader's + // checkForForeclosure writes foreclose_block before + // checkForNewInputs has had a chance to ingest the historical + // inputs — so the gate would see an empty table and return false. + // While the InputAdded scanner cursor is below the foreclosure + // block we don't yet know whether pre-foreclosure work exists, so + // we log and skip. + if app.LastInputCheckBlock < app.ForecloseBlock { + s.Logger.Info( + "Foreclosed application still ingesting pre-foreclosure inputs", + "application", app.Name, + "address", app.IApplicationAddress, + "last_input_check_block", app.LastInputCheckBlock, + "foreclose_block", app.ForecloseBlock, + ) + continue + } + unreconciled, err := s.repository.HasUnreconciledClaimsBeforeBlock( + s.Context, app.ID, app.ForecloseBlock, + ) + if err != nil { + errs = append(errs, fmt.Errorf( + "checking drain progress for foreclosed app %s: %w", + app.IApplicationAddress, err)) + continue + } + if unreconciled { + s.Logger.Info( + "Foreclosed application still draining or reconciling pre-foreclosure epochs", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + ) + continue + } + // Both gates clear: no terminal action. evmreader picks up the + // post-foreclosure observation work from here. } return errs } diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index e0d777521..006eec24a 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -145,6 +145,19 @@ description = """ How many seconds the node will wait before trying to finish epochs for all applications.""" used-by = ["prt", "node"] +[rollups.CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS] +default = "5" +go-type = "uint64" +description = """ +Maximum number of consecutive acceptClaim attempts per (application, epoch) before +the application is marked FAILED. Bounds wasted gas on a persistently-reverting chain +(gas misconfig, nonce gap, signer not authorised, fork inconsistency). The default +of 5 tolerates short transient blips and escalates within ~5 ticks. High-traffic +mainnet apps with brittle gas markets may set this higher; conservative test networks +may set it lower. The counter is in-memory per (appID, epochIndex) and resets on +transition to CLAIM_ACCEPTED.""" +used-by = ["claimer", "node"] + [rollups.CARTESI_MAX_STARTUP_TIME] default = "15" go-type = "Duration" diff --git a/internal/config/generated.go b/internal/config/generated.go index 01d97c777..2037cb541 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -79,6 +79,7 @@ const ( BLOCKCHAIN_WS_LIVENESS_TIMEOUT = "CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT" BLOCKCHAIN_WS_MAX_RETRIES = "CARTESI_BLOCKCHAIN_WS_MAX_RETRIES" BLOCKCHAIN_WS_RECONNECT_INTERVAL = "CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL" + CLAIMER_MAX_ACCEPT_ATTEMPTS = "CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS" CLAIMER_POLLING_INTERVAL = "CARTESI_CLAIMER_POLLING_INTERVAL" MAX_STARTUP_TIME = "CARTESI_MAX_STARTUP_TIME" PRT_POLLING_INTERVAL = "CARTESI_PRT_POLLING_INTERVAL" @@ -216,6 +217,8 @@ func SetDefaults() { viper.SetDefault(BLOCKCHAIN_WS_RECONNECT_INTERVAL, "1") + viper.SetDefault(CLAIMER_MAX_ACCEPT_ATTEMPTS, "5") + viper.SetDefault(CLAIMER_POLLING_INTERVAL, "3") viper.SetDefault(MAX_STARTUP_TIME, "15") @@ -456,6 +459,15 @@ type ClaimerConfig struct { // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` + // Maximum number of consecutive acceptClaim attempts per (application, epoch) before + // the application is marked FAILED. Bounds wasted gas on a persistently-reverting chain + // (gas misconfig, nonce gap, signer not authorised, fork inconsistency). The default + // of 5 tolerates short transient blips and escalates within ~5 ticks. High-traffic + // mainnet apps with brittle gas markets may set this higher; conservative test networks + // may set it lower. The counter is in-memory per (appID, epochIndex) and resets on + // transition to CLAIM_ACCEPTED. + ClaimerMaxAcceptAttempts uint64 `mapstructure:"CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS"` + // How many seconds the node will wait before querying the database for new claims. ClaimerPollingInterval Duration `mapstructure:"CARTESI_CLAIMER_POLLING_INTERVAL"` @@ -568,6 +580,13 @@ func LoadClaimerConfig() (*ClaimerConfig, error) { return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE is required for the claimer service: %w", err) } + cfg.ClaimerMaxAcceptAttempts, err = GetClaimerMaxAcceptAttempts() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS is required for the claimer service: %w", err) + } + cfg.ClaimerPollingInterval, err = GetClaimerPollingInterval() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_POLLING_INTERVAL: %w", err) @@ -1013,6 +1032,15 @@ type NodeConfig struct { // Wait time in seconds between WebSocket subscription reconnection attempts after a connection failure. BlockchainWsReconnectInterval Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL"` + // Maximum number of consecutive acceptClaim attempts per (application, epoch) before + // the application is marked FAILED. Bounds wasted gas on a persistently-reverting chain + // (gas misconfig, nonce gap, signer not authorised, fork inconsistency). The default + // of 5 tolerates short transient blips and escalates within ~5 ticks. High-traffic + // mainnet apps with brittle gas markets may set this higher; conservative test networks + // may set it lower. The counter is in-memory per (appID, epochIndex) and resets on + // transition to CLAIM_ACCEPTED. + ClaimerMaxAcceptAttempts uint64 `mapstructure:"CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS"` + // How many seconds the node will wait before querying the database for new claims. ClaimerPollingInterval Duration `mapstructure:"CARTESI_CLAIMER_POLLING_INTERVAL"` @@ -1253,6 +1281,13 @@ func LoadNodeConfig() (*NodeConfig, error) { return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL is required for the node service: %w", err) } + cfg.ClaimerMaxAcceptAttempts, err = GetClaimerMaxAcceptAttempts() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS is required for the node service: %w", err) + } + cfg.ClaimerPollingInterval, err = GetClaimerPollingInterval() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_POLLING_INTERVAL: %w", err) @@ -1603,6 +1638,7 @@ func (c *NodeConfig) ToClaimerConfig() *ClaimerConfig { BlockchainHttpRetryMaxWait: c.BlockchainHttpRetryMaxWait, BlockchainHttpRetryMinWait: c.BlockchainHttpRetryMinWait, BlockchainMaxBlockRange: c.BlockchainMaxBlockRange, + ClaimerMaxAcceptAttempts: c.ClaimerMaxAcceptAttempts, ClaimerPollingInterval: c.ClaimerPollingInterval, MaxStartupTime: c.MaxStartupTime, } @@ -2464,6 +2500,19 @@ func GetBlockchainWsReconnectInterval() (Duration, error) { return notDefinedDuration(), fmt.Errorf("%s: %w", BLOCKCHAIN_WS_RECONNECT_INTERVAL, ErrNotDefined) } +// GetClaimerMaxAcceptAttempts returns the value for the environment variable CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS. +func GetClaimerMaxAcceptAttempts() (uint64, error) { + s := viper.GetString(CLAIMER_MAX_ACCEPT_ATTEMPTS) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CLAIMER_MAX_ACCEPT_ATTEMPTS, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", CLAIMER_MAX_ACCEPT_ATTEMPTS, ErrNotDefined) +} + // GetClaimerPollingInterval returns the value for the environment variable CARTESI_CLAIMER_POLLING_INTERVAL. func GetClaimerPollingInterval() (Duration, error) { s := viper.GetString(CLAIMER_POLLING_INTERVAL) From b818d5ab2aba9ec6729314d372f7c8380cd877e1 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:10:45 -0300 Subject: [PATCH 13/16] fix(advancer): keep Canceled graceful, propagate DeadlineExceeded --- internal/advancer/advancer.go | 44 +++++++++++++++++++++++++---------- internal/advancer/service.go | 9 +++++++ internal/manager/manager.go | 32 ++++++++++++++++++++----- 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/internal/advancer/advancer.go b/internal/advancer/advancer.go index 51a9dedd0..f89b4e6e2 100644 --- a/internal/advancer/advancer.go +++ b/internal/advancer/advancer.go @@ -248,14 +248,23 @@ func (s *Service) processInputs(ctx context.Context, app *Application, inputs [] result, err := machine.Advance(ctx, input.RawData, input.EpochIndex, input.Index, app.IsDaveConsensus()) input.RawData = nil // allow GC to collect payload while batch continues if err != nil { - // If there's an error, mark the application as failed + // Graceful shutdown: bail out quietly without marking FAILED. + if errors.Is(err, context.Canceled) { + s.Logger.Debug("Advance cancelled due to shutdown", + "application", app.Name, + "index", input.Index) + return err + } + + // Anything else (including DeadlineExceeded) is a real failure. s.Logger.Error("Error executing advance", "application", app.Name, "index", input.Index, "error", err) - // If the error is due to context cancellation, don't mark as failed - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // DeadlineExceeded is a real failure but not a state-corruption + // signal — let the upper layer retry rather than marking FAILED. + if errors.Is(err, context.DeadlineExceeded) { return err } @@ -314,11 +323,17 @@ func (s *Service) processInputs(ctx context.Context, app *Application, inputs [] if result.Status == InputCompletionStatus_Accepted { err = s.handleSnapshot(ctx, app, machine, input) if err != nil { - s.Logger.Error("Failed to create snapshot", - "application", app.Name, - "index", input.Index, - "error", err) - // Continue processing even if snapshot creation fails + if errors.Is(err, context.Canceled) { + s.Logger.Debug("Snapshot creation cancelled due to shutdown", + "application", app.Name, + "index", input.Index) + } else { + s.Logger.Error("Failed to create snapshot", + "application", app.Name, + "index", input.Index, + "error", err) + // Continue processing even if snapshot creation fails + } } } } @@ -487,10 +502,15 @@ func (s *Service) createSnapshot(ctx context.Context, app *Application, machine // return the snapshot we just created — that would cause self-deletion. previousSnapshot, err := s.repository.GetLastSnapshot(ctx, app.IApplicationAddress.String()) if err != nil { - s.Logger.Error("Failed to get previous snapshot", - "application", app.Name, - "error", err) - // Continue even if we can't get the previous snapshot + if errors.Is(err, context.Canceled) { + s.Logger.Debug("GetLastSnapshot cancelled due to shutdown", + "application", app.Name) + } else { + s.Logger.Error("Failed to get previous snapshot", + "application", app.Name, + "error", err) + // Continue even if we can't get the previous snapshot + } } // Update the input record with the snapshot URI diff --git a/internal/advancer/service.go b/internal/advancer/service.go index 3c6d4d7c2..3e657d5eb 100644 --- a/internal/advancer/service.go +++ b/internal/advancer/service.go @@ -129,6 +129,15 @@ func (s *Service) Tick() []error { s.Logger.Warn("Tick interrupted by shutdown", "error", err) return 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 nil + } return []error{err} } diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 22b9bec76..b7ab71dcf 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -125,9 +125,19 @@ func (m *MachineManager) UpdateMachines(ctx context.Context) error { // Find the latest snapshot for this application snapshot, err := m.repository.GetLastSnapshot(ctx, app.IApplicationAddress.String()) if err != nil { - m.logger.Error("Failed to find latest snapshot", - "application", app.Name, - "error", err) + // Shutdown cancels the ctx mid-query; downgrade to Debug so + // operators don't see spurious ERR lines during a graceful + // stop. DeadlineExceeded would still flow through the Error + // branch and demand investigation. + if errors.Is(err, context.Canceled) { + m.logger.Debug("GetLastSnapshot canceled during shutdown", + "application", app.Name, + "error", err) + } else { + m.logger.Error("Failed to find latest snapshot", + "application", app.Name, + "error", err) + } // Continue with template-based initialization } @@ -164,9 +174,19 @@ func (m *MachineManager) UpdateMachines(ctx context.Context) error { if instance == nil { instance, err = m.instanceFactory.NewFromTemplate(ctx, app, m.logger, m.checkHash) if err != nil { - m.logger.Error("Failed to create machine instance", - "application", app.IApplicationAddress, - "error", err) + // Shutdown cancels the ctx mid-spawn; the partially + // constructed machine is torn down by NewFromTemplate + // itself. Downgrade to Debug for the graceful-stop case + // so the noise doesn't drown out real spawn failures. + if errors.Is(err, context.Canceled) { + m.logger.Debug("NewFromTemplate canceled during shutdown", + "application", app.IApplicationAddress, + "error", err) + } else { + m.logger.Error("Failed to create machine instance", + "application", app.IApplicationAddress, + "error", err) + } continue } } From 2daddc32240e28b52a497abef314b4a685ef21e5 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 16:12:04 -0300 Subject: [PATCH 14/16] test(integration): cover staging path and foreclose lifecycle --- test/integration/cli_helpers_test.go | 11 + test/integration/divergent_claim_test.go | 387 +++++++++++++ .../echo_authority_staging_test.go | 95 ++++ test/integration/echo_quorum_test.go | 522 ++++++++++++++++++ test/integration/foreclose_prt_test.go | 156 ++++++ test/integration/foreclose_replay_test.go | 340 ++++++++++++ test/integration/foreclose_test.go | 227 ++++++++ test/integration/node_helpers_test.go | 18 +- test/integration/reject_exception_prt_test.go | 14 + test/integration/snapshot_policy_test.go | 13 + 10 files changed, 1780 insertions(+), 3 deletions(-) create mode 100644 test/integration/divergent_claim_test.go create mode 100644 test/integration/echo_authority_staging_test.go create mode 100644 test/integration/echo_quorum_test.go create mode 100644 test/integration/foreclose_prt_test.go create mode 100644 test/integration/foreclose_replay_test.go create mode 100644 test/integration/foreclose_test.go diff --git a/test/integration/cli_helpers_test.go b/test/integration/cli_helpers_test.go index 21b740b6c..1f350f91e 100644 --- a/test/integration/cli_helpers_test.go +++ b/test/integration/cli_helpers_test.go @@ -92,9 +92,20 @@ func isCLIExitError(err error) bool { // Each command is given an independent timeout (cliCommandTimeout) to prevent // a single hanging call from consuming the entire suite timeout. 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). +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...) + } out, err := cmd.Output() if err != nil { var exitErr *exec.ExitError diff --git a/test/integration/divergent_claim_test.go b/test/integration/divergent_claim_test.go new file mode 100644 index 000000000..9ea03c1dd --- /dev/null +++ b/test/integration/divergent_claim_test.go @@ -0,0 +1,387 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + "regexp" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iauthority" + "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/ethclient" + "github.com/stretchr/testify/suite" +) + +// DivergentClaimSuite models the compromised-owner-key attack on an Authority +// application: the operator's private key has been leaked, and the attacker +// uses it to push a crafted divergent claim to chain before the operator's +// node can submit the legitimate one. The node's claimer must observe the +// divergence and drive the application to INOPERABLE; the same outcome must +// hold when a fresh node bootstraps against the already-divergent chain. +// +// Phase 1 — attack: +// 1. Deploy Authority (node = owner). +// 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 +// 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. +// 6. Restart the node. It detects input 2, computes the legitimate claim +// locally, scans the chain via findClaimSubmittedEventAndSucc (the +// accepted-scan returns nil because no ClaimAccepted exists), and +// marks the application INOPERABLE with reason +// `authority_divergence_at_submission`. +// +// Phase 2 — replay against a now-divergent chain, in reader mode: +// 7. Remove app A. +// 8. Restart the node with CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false +// 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 +// INOPERABLE. 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 +// submission) still drives the right terminal state. +type DivergentClaimSuite struct { + suite.Suite + LogChecker + ctx context.Context + cancel context.CancelFunc +} + +func TestDivergentClaim(t *testing.T) { + if !isNodeSelfManaged() { + t.Skip("skipping: divergent-claim test requires test-managed node " + + "(it stops/starts the shared node mid-test)") + } + suite.Run(t, new(DivergentClaimSuite)) +} + +func (s *DivergentClaimSuite) SetupSuite() { + // Two-app lifecycle (deploy + 3 epochs + attack + replay) is comparable + // in length to the foreclose-replay suite. + s.ctx, s.cancel = context.WithTimeout(context.Background(), 20*time.Minute) +} + +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()) + } + s.T().Log("Restarting shared node in default mode for subsequent suites...") + startSharedNode(s.T()) + s.cancel() +} + +func (s *DivergentClaimSuite) SetupTest() { + s.StartLogCapture() +} + +func (s *DivergentClaimSuite) TearDownTest() { + // Both apps end the test in INOPERABLE (terminal); the disable helper + // rejects that state. Leave them; unique names mean no collision next run. + s.CheckLogs(s.T()) +} + +// TestDivergentClaimReplay is the full lifecycle described on the suite type. +func (s *DivergentClaimSuite) TestDivergentClaimReplay() { + r := s.Require() + + // Both apps end the test in INOPERABLE with one of the two Authority + // 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. + s.SetExpectedLogs(s.T(), + ExpectedLog{ + Pattern: regexp.MustCompile( + `marking application as inoperable.*authority_divergence_at_(submission|acceptance)`), + Level: LevelError, + Reason: "expected INOPERABLE transition for both the attacked original app and " + + "the re-registered replay app (compromised-owner-key attack scenario)", + }, + ExpectedLog{ + Pattern: regexp.MustCompile( + `Tick service=claimer.*authority_divergence_at_(submission|acceptance)`), + Level: LevelError, + Reason: "claimer Tick wraps and re-logs the divergence-induced INOPERABLE 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, + Reason: "transient EVM reader race against Anvil during rapid block mining", + }, + ) + + endpoint := envOrDefault("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", "http://localhost:8545") + client, err := ethclient.Dial(endpoint) + r.NoError(err, "dial ethclient") + defer client.Close() + + chainID, err := client.ChainID(s.ctx) + r.NoError(err, "fetch chain id") + + dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") + appAName := uniqueAppName("divergent-a") + + // ─── Phase 1: deploy and run epochs 0–1 to legitimate ACCEPT ──────── + s.T().Logf("--- Phase 1: deploy %s and accept two legitimate claims ---", appAName) + + appAddrStr, consensusAddrStr, err := deployApplicationWithConsensus(s.ctx, + appAName, dappPath, "--salt", uniqueSalt()) + r.NoError(err, "deploy A") + appAddr := common.HexToAddress(appAddrStr) + consensusAddr := common.HexToAddress(consensusAddrStr) + s.T().Logf(" app=%s consensus=%s", appAddr.Hex(), consensusAddr.Hex()) + + r.NoError(anvilSetBalance(s.ctx, appAddrStr, oneEtherWei), + "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. + inputEpochs := make([]uint64, 0, 3) //nolint:mnd + for i := 0; i < 2; i++ { //nolint:mnd + 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 + + procCtx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + input, err := waitForInputProcessed(procCtx, s.T(), appAName, idx) + cancel() + r.NoError(err, "wait for input %d", i) + r.Equal(model.InputCompletionStatus_Accepted, input.Status) + inputEpochs = append(inputEpochs, input.EpochIndex) + + claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + _, err = waitForEpochStatus(claimCtx, s.T(), appAName, input.EpochIndex, + model.EpochStatus_ClaimAccepted) + claimCancel() + 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 + } + + // 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.OutputsMerkleProof, + "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.OutputsMerkleProof)) + + // ─── 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 ---") + stopSharedNode(s.T()) + + // 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 + 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 + // uint64 subtraction and produce a nonsense target epoch. + r.Greater(block2, epoch1.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 + targetEpochLastBlock := targetEpochFirstBlock + epochLen - 1 + r.GreaterOrEqual(block2, targetEpochFirstBlock, + "input 2 block %d must be inside epoch %d's window [%d, %d]", + block2, targetEpochIndex, targetEpochFirstBlock, targetEpochLastBlock) + r.LessOrEqual(block2, targetEpochLastBlock, + "input 2 block %d must be inside epoch %d's window [%d, %d]", + block2, targetEpochIndex, targetEpochFirstBlock, targetEpochLastBlock) + s.T().Logf(" input 2 lands in epoch %d [blocks %d-%d]", + targetEpochIndex, targetEpochFirstBlock, targetEpochLastBlock) + + currentBlock, err := client.BlockNumber(s.ctx) + r.NoError(err, "read current block") + if currentBlock <= targetEpochLastBlock { + blocksToClose := int(targetEpochLastBlock - currentBlock + 1) //nolint:gosec + r.NoError(anvilMine(s.ctx, blocksToClose), "mine to close target epoch") + s.T().Logf(" mined %d blocks to close epoch %d at block %d", + blocksToClose, targetEpochIndex, targetEpochLastBlock) + } + + // ── Attacker submits the divergent claim ───────────────────────────── + // Using mnemonic[0] — the same key the operator/node uses. This models + // the compromised-key threat: the attacker holds the same private key, + // so the chain accepts the call as the legitimate Authority owner. + attackerKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + r.NoError(err, "derive attacker key (same as node owner)") + attackerOpts, err := bind.NewKeyedTransactorWithChainID(attackerKey, chainID) + r.NoError(err, "new keyed transactor") + attackerOpts.Context = s.ctx + + authorityBinding, err := iauthority.NewIAuthority(consensusAddr, client) + r.NoError(err, "bind iauthority") + + divergentOutputs := randomBytes32(s.T()) + proof := merkleProofToBytes32(epoch1.OutputsMerkleProof) + s.T().Logf(" attacker submitting divergent claim: lpbn=%d outputs=0x%x proof_siblings=%d", + targetEpochLastBlock, divergentOutputs, len(proof)) + submitTx, err := authorityBinding.SubmitClaim(attackerOpts, appAddr, + new(big.Int).SetUint64(targetEpochLastBlock), divergentOutputs, proof) + r.NoError(err, "attacker SubmitClaim") + submitReceipt, err := bind.WaitMined(s.ctx, client, submitTx) + r.NoError(err, "wait for divergent submitClaim tx to mine") + r.Equal(uint64(1), submitReceipt.Status, "divergent submitClaim tx must succeed on chain") + s.T().Logf(" divergent submitClaim mined in block %d tx=%s", + submitReceipt.BlockNumber.Uint64(), submitTx.Hash().Hex()) + + // Deliberately do NOT call acceptClaim. Modeling a realistic attacker + // pushing a single divergent claim to chain — and exercising the node's + // ClaimSubmitted-scan divergence path, which lives behind the service- + // level findClaimSubmittedEventAndSucc wrapper that asserts + // checkEpochSequenceConstraint on the previous epoch. Phase 2's + // reader-mode replay used to trip that invariant because the catch-up + // reconciliation of the prior legitimate epochs left + // claim_transaction_hash NULL; the production fix to + // UpdateEpochWithAcceptedClaim (optional txHash arg) and the relaxed + // checkEpochConstraint now let the divergence detection proceed. + + // ─── Phase 1 conclusion: restart node, expect INOPERABLE ──────────── + s.T().Log("--- Phase 1: restart node and wait for divergence-driven INOPERABLE ---") + startSharedNode(s.T()) + + stateCtx, stateCancel := context.WithTimeout(s.ctx, 5*time.Minute) //nolint:mnd + r.NoError(waitForApplicationState(stateCtx, s.T(), appAName, "INOPERABLE"), + "A should reach INOPERABLE after observing the divergent on-chain claim") + stateCancel() + statusA, err := readApplicationState(s.ctx, appAName) + r.NoError(err) + r.Regexp(`authority_divergence_at_(submission|acceptance)`, statusA, + "A's INOPERABLE reason must reference one of the two Authority divergence buckets") + s.T().Logf("=== Phase 1 complete: %s is INOPERABLE ===\n%s", appAName, statusA) + + // ─── Phase 2: reader mode replay ───────────────────────────────────── + s.T().Log("--- Phase 2: remove A, restart node in reader mode, re-register as B ---") + 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") + + appBName := uniqueAppName("divergent-b") + r.NoError(registerApplication(s.ctx, appBName, appAddrStr, dappPath), + "register %s at %s", appBName, appAddrStr) + s.T().Logf(" %s registered at %s", appBName, appAddrStr) + + // B has to replay all 3 inputs locally before it reaches the epoch + // where the divergent claim sits. Wait for the same INOPERABLE outcome. + for i := uint64(0); i < 3; i++ { //nolint:mnd + 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 + r.NoError(waitForApplicationState(stateCtx, s.T(), appBName, "INOPERABLE"), + "B should reach INOPERABLE via the read-only scan path") + stateCancel() + + statusB, err := readApplicationState(s.ctx, appBName) + r.NoError(err) + r.Regexp(`authority_divergence_at_(submission|acceptance)`, statusB, + "B's INOPERABLE reason must reference one of the Authority divergence buckets "+ + "(the read-only scan path proves the divergence even with submission disabled)") + s.T().Logf("=== Phase 2 complete: %s is INOPERABLE 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 { + 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 + } + return out +} + diff --git a/test/integration/echo_authority_staging_test.go b/test/integration/echo_authority_staging_test.go new file mode 100644 index 000000000..02237d846 --- /dev/null +++ b/test/integration/echo_authority_staging_test.go @@ -0,0 +1,95 @@ +// (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/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/suite" +) + +// 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 → +// ACCEPTED (with a wait for the period to elapse). The default tests use +// claimStagingPeriod = 0, where submit and stage happen atomically and the +// staged-then-accepted gap is not observable. +type EchoAuthorityStagingSuite struct { + suite.Suite + LogChecker + ctx context.Context + cancel context.CancelFunc + appName string +} + +func TestEchoAuthorityStaging(t *testing.T) { + suite.Run(t, new(EchoAuthorityStagingSuite)) +} + +func (s *EchoAuthorityStagingSuite) SetupSuite() { + s.ctx, s.cancel = context.WithTimeout(context.Background(), 10*time.Minute) +} + +func (s *EchoAuthorityStagingSuite) TearDownSuite() { + s.cancel() +} + +func (s *EchoAuthorityStagingSuite) SetupTest() { + s.StartLogCapture() + s.appName = "" +} + +func (s *EchoAuthorityStagingSuite) TearDownTest() { + if s.appName != "" { + s.T().Logf("Disabling application %s", s.appName) + if err := disableApplication(s.ctx, s.appName); err != nil { + s.T().Errorf("failed to disable application %s: %v", s.appName, err) + } + } + 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. +func (s *EchoAuthorityStagingSuite) TestEchoAuthorityStagingPath() { + r := s.Require() + dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") + s.appName = uniqueAppName("echo-authority-staging") + + runEchoLifecycleTest(s.ctx, s.T(), r, echoLifecycleConfig{ + AppName: s.appName, + DappPath: dappPath, + Payload: "hello cartesi (staging)", + ExtraDeployArgs: []string{"--claim-staging-period", "5"}, + }) + + // Pin the staging-path invariant: the epoch must have gone through + // CLAIM_STAGED with a recorded staged_at_block. A regression that + // skipped CLAIM_STAGED entirely (e.g., a re-introduced fast-path that + // ignored claim_staging_period > 0) would leave staged_at_block NULL + // even after the epoch reached CLAIM_ACCEPTED — the schema preserves + // the column through the accept transition (`epoch_staged_requires_block` + // CHECK fires only on CLAIM_STAGED rows). + input, err := readInput(s.ctx, s.appName, 0) + r.NoError(err, "read input 0 to find its epoch") + epoch, err := readEpoch(s.ctx, s.appName, input.EpochIndex) + r.NoError(err, "read epoch %d", input.EpochIndex) + r.Equal(model.EpochStatus_ClaimAccepted, epoch.Status, + "epoch must reach CLAIM_ACCEPTED before this assertion is meaningful") + r.NotNil(epoch.StagedAtBlock, + "epoch must have gone through CLAIM_STAGED — staged_at_block is preserved through ACCEPTED") + + s.T().Log("=== Authority staging-path lifecycle complete (STAGED observation pinned) ===") +} diff --git a/test/integration/echo_quorum_test.go b/test/integration/echo_quorum_test.go new file mode 100644 index 000000000..7caf64a38 --- /dev/null +++ b/test/integration/echo_quorum_test.go @@ -0,0 +1,522 @@ +// (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" + "os" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "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/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + quorumClaimStagingPeriod uint64 = 8 + quorumNodeValidatorIndex uint32 = 0 + quorumValidatorIndexA uint32 = 2 + quorumValidatorIndexB uint32 = 3 +) + +type quorumAppDeployment struct { + appName string + appAddress common.Address + consensusAddress common.Address + quorum *iquorum.IQuorum +} + +// EchoQuorumSuite covers the Authority-like happy path plus Quorum-specific +// voting order and minority/majority divergence cases. The non-node validators +// are direct SubmitClaim calls signed with Foundry mnemonic account indexes 2 +// and 3; no extra node processes are needed. +type EchoQuorumSuite struct { + suite.Suite + LogChecker + ctx context.Context + cancel context.CancelFunc + client *ethclient.Client + chainID *big.Int + appName string +} + +func TestEchoQuorum(t *testing.T) { + suite.Run(t, new(EchoQuorumSuite)) +} + +func (s *EchoQuorumSuite) SetupSuite() { + s.ctx, s.cancel = context.WithTimeout(context.Background(), 30*time.Minute) + + endpoint := envOrDefault("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", "http://localhost:8545") + client, err := ethclient.DialContext(s.ctx, endpoint) + s.Require().NoError(err, "dial ethclient") + s.client = client + + chainID, err := client.ChainID(s.ctx) + s.Require().NoError(err, "fetch chain id") + s.chainID = chainID +} + +func (s *EchoQuorumSuite) TearDownSuite() { + if s.client != nil { + s.client.Close() + } + s.cancel() +} + +func (s *EchoQuorumSuite) SetupTest() { + s.StartLogCapture() + s.appName = "" +} + +func (s *EchoQuorumSuite) TearDownTest() { + if s.appName != "" { + s.T().Logf("Disabling application %s", s.appName) + if err := disableApplication(s.ctx, s.appName); err != nil { + s.T().Errorf("failed to disable application %s: %v", s.appName, err) + } + } + s.CheckLogs(s.T()) +} + +func (s *EchoQuorumSuite) TestNodeVoteFirstThenOtherValidatorsStageAndAccept() { + app := s.deployQuorumEchoApp("echo-quorum-node-first") + epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum node first)") + + 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") + + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.OutputsMerkleRoot) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.OutputsMerkleRoot) + + s.waitForQuorumAccepted(app.appName, epoch.Index) +} + +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", + }) + + stopSharedNode(s.T()) + startSharedNodeWithEnv(s.T(), "CARTESI_CLAIMER_POLLING_INTERVAL=3600") + slowClaimer := true + defer func() { + if slowClaimer { + stopSharedNode(s.T()) + startSharedNode(s.T()) + } + }() + + app := s.deployQuorumEchoApp("echo-quorum-node-second") + epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum node second)") + 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.OutputsMerkleRoot) + + stopSharedNode(s.T()) + startSharedNode(s.T()) + slowClaimer = false + + s.waitForQuorumAccepted(app.appName, epoch.Index) +} + +func (s *EchoQuorumSuite) TestExternalMajorityStagesBeforeNodeVoteThenNodeAccepts() { + 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", + }) + + stopSharedNode(s.T()) + startSharedNodeWithEnv(s.T(), "CARTESI_CLAIMER_POLLING_INTERVAL=3600") + slowClaimer := true + defer func() { + if slowClaimer { + stopSharedNode(s.T()) + startSharedNode(s.T()) + } + }() + + app := s.deployQuorumEchoApp("echo-quorum-external-majority") + epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum external majority)") + 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.OutputsMerkleRoot) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.OutputsMerkleRoot) + + stopSharedNode(s.T()) + startSharedNode(s.T()) + slowClaimer = false + + s.waitForQuorumAccepted(app.appName, epoch.Index) +} + +func (s *EchoQuorumSuite) TestDivergentMinorityVoteDoesNotBlockAcceptance() { + 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") + + divergentOutputs := randomOutputsMerkleRoot(s.T(), *epoch.OutputsMerkleRoot) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.OutputsMerkleRoot) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, divergentOutputs) + + s.waitForQuorumAccepted(app.appName, epoch.Index) +} + +func (s *EchoQuorumSuite) TestDivergentMajorityMarksApplicationInoperable() { + s.SetExpectedLogs(s.T(), + ExpectedLog{ + Pattern: regexp.MustCompile(`marking application as inoperable.*quorum_divergence_at_staging`), + Level: LevelError, + Reason: "expected INOPERABLE transition after a divergent Quorum majority stages a different claim", + }, + ExpectedLog{ + Pattern: regexp.MustCompile(`Tick service=claimer.*quorum_divergence_at_staging`), + Level: LevelError, + Reason: "claimer Tick wraps and re-logs the divergence-induced INOPERABLE error", + }, + ) + + 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") + + divergentOutputs := randomOutputsMerkleRoot(s.T(), *epoch.OutputsMerkleRoot) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, divergentOutputs) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, divergentOutputs) + + rejectedCtx, rejectedCancel := context.WithTimeout(s.ctx, 5*time.Minute) + epoch, 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") + + stateCtx, stateCancel := context.WithTimeout(s.ctx, time.Minute) + err = waitForApplicationState(stateCtx, s.T(), app.appName, "INOPERABLE") + stateCancel() + s.Require().NoError(err, "wait for outvoted quorum app to become INOPERABLE") + + status, err := readApplicationState(s.ctx, app.appName) + s.Require().NoError(err, "read app status after quorum divergence") + s.Require().Contains(status, "quorum_divergence_at_staging") + + // INOPERABLE is terminal, and disableApplication rejects terminal states. + s.appName = "" +} + +func (s *EchoQuorumSuite) TestForecloseQuorumApplication() { + r := s.Require() + + builderEnv := os.Getenv("CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS") + r.NotEmpty(builderEnv, + "CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS must be set (run `eval $(make env)`)") + + const guardianIndex uint32 = 1 + guardianKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, guardianIndex) + r.NoError(err, "derive guardian key") + guardianAddr := crypto.PubkeyToAddress(guardianKey.PublicKey) + + withdrawalConfigJSON := fmt.Sprintf(`{ + "guardian": "%s", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "%s" + }`, guardianAddr.Hex(), builderEnv) + withdrawalConfigJSON = strings.ReplaceAll(withdrawalConfigJSON, "\n", "") + withdrawalConfigJSON = strings.ReplaceAll(withdrawalConfigJSON, "\t", "") + + app := s.deployQuorumEchoApp("foreclose-quorum", "--withdrawal-config", withdrawalConfigJSON) + epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (foreclose quorum)") + + submittedCtx, submittedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + epoch, err = waitForEpochStatus(submittedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimSubmitted) + submittedCancel() + r.NoError(err, "wait for node to submit quorum claim") + + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.OutputsMerkleRoot) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.OutputsMerkleRoot) + s.waitForQuorumAccepted(app.appName, epoch.Index) + + out, err := runCLIWithEnv(s.ctx, + []string{fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", guardianIndex)}, + "foreclose", app.appName, "--yes", "--json", + ) + r.NoError(err, "guardian foreclose CLI call: %s", out) + s.T().Logf(" foreclose tx: %s", strings.TrimSpace(out)) + + stateCtx, stateCancel := context.WithTimeout(s.ctx, 30*time.Second) + err = waitForApplicationForeclosed(stateCtx, s.T(), app.appName) + stateCancel() + r.NoError(err, "app did not record foreclose_block after guardian foreclose()") + + // Foreclosure does not transition state; the marker lives on the + // application row (foreclose_block / foreclose_transaction) and is + // surfaced in `app status`. + status, err := readApplicationState(s.ctx, app.appName) + r.NoError(err, "read app status after foreclosure") + r.Contains(status, "ENABLED") + r.NotContains(status, "INOPERABLE", + "foreclosure must not transition the app to INOPERABLE") + r.Contains(status, "Foreclose block:") + r.Contains(status, "Foreclose transaction:") +} + +func (s *EchoQuorumSuite) deployQuorumEchoApp(prefix string, extraApplicationArgs ...string) quorumAppDeployment { + r := s.Require() + + validators := quorumValidatorAddresses(s.T()) + quorumArgs := []string{ + "deploy", "quorum", + "--json", + "--salt", uniqueSalt(), + "--claim-staging-period", strconv.FormatUint(quorumClaimStagingPeriod, 10), + } + for _, validator := range validators { + quorumArgs = append(quorumArgs, "--validator", validator.Hex()) + } + + out, err := runCLI(s.ctx, quorumArgs...) + r.NoError(err, "deploy quorum") + + var quorumDeployment struct { + Address string `json:"address"` + } + r.NoError(json.Unmarshal([]byte(out), &quorumDeployment), "parse quorum deployment") + r.NotEmpty(quorumDeployment.Address, "quorum deployment missing address") + + dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") + appName := uniqueAppName(prefix) + applicationArgs := []string{ + "--consensus", quorumDeployment.Address, + "--salt", uniqueSalt(), + } + applicationArgs = append(applicationArgs, extraApplicationArgs...) + appAddrStr, consensusAddrStr, err := deployApplicationWithConsensus( + s.ctx, + appName, + dappPath, + applicationArgs..., + ) + r.NoError(err, "deploy quorum echo application") + r.Equal(common.HexToAddress(quorumDeployment.Address), common.HexToAddress(consensusAddrStr), + "application must use the freshly deployed quorum consensus") + + r.NoError(anvilSetBalance(s.ctx, appAddrStr, oneEtherWei), "fund application contract") + + quorumBinding, err := iquorum.NewIQuorum(common.HexToAddress(consensusAddrStr), s.client) + r.NoError(err, "bind quorum consensus") + + s.appName = appName + return quorumAppDeployment{ + appName: appName, + appAddress: common.HexToAddress(appAddrStr), + consensusAddress: common.HexToAddress(consensusAddrStr), + quorum: quorumBinding, + } +} + +func (s *EchoQuorumSuite) prepareQuorumEpoch(appName string, payload string) *model.Epoch { + r := s.Require() + + inputIndex, blockNum, err := sendInput(s.ctx, appName, payload) + r.NoError(err, "send input") + r.Equal(uint64(0), inputIndex) + s.T().Logf(" quorum input accepted on-chain: index=%d block=%d", inputIndex, blockNum) + + processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + input, err := waitForInputProcessed(processCtx, s.T(), appName, inputIndex) + processCancel() + r.NoError(err, "wait for quorum input processing") + r.Equal(model.InputCompletionStatus_Accepted, input.Status) + + epoch := s.waitForEpochAvailable(appName, input.EpochIndex) + s.minePastBlock(epoch.LastBlock) + return s.waitForEpochWithClaim(appName, input.EpochIndex) +} + +func (s *EchoQuorumSuite) waitForEpochAvailable(appName string, epochIndex uint64) *model.Epoch { + ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + defer cancel() + + var result *model.Epoch + var lastErr error + err := pollUntil(ctx, 2*time.Second, func() (bool, error) { + epoch, err := readEpoch(ctx, appName, epochIndex) + if err != nil { + if isCLIExitError(err) { + lastErr = err + s.T().Logf("poll epoch %d: %v (retrying)", epochIndex, err) + return false, nil + } + return false, fmt.Errorf("poll epoch %d: %w", epochIndex, err) + } + result = epoch + return true, nil + }) + if err != nil && lastErr != nil { + err = fmt.Errorf("%w (last poll error: %v)", err, lastErr) + } + s.Require().NoError(err, "wait for epoch %d to exist", epochIndex) + return result +} + +func (s *EchoQuorumSuite) waitForEpochWithClaim(appName string, epochIndex uint64) *model.Epoch { + ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + defer cancel() + + var result *model.Epoch + var lastErr error + err := pollUntil(ctx, 2*time.Second, func() (bool, error) { + epoch, err := readEpoch(ctx, appName, epochIndex) + if err != nil { + if isCLIExitError(err) { + lastErr = err + s.T().Logf("poll epoch %d claim: %v (retrying)", epochIndex, err) + return false, nil + } + return false, fmt.Errorf("poll epoch %d claim: %w", epochIndex, err) + } + if epoch.OutputsMerkleRoot != nil && epoch.MachineHash != nil && isQuorumClaimReadyStatus(epoch.Status) { + result = epoch + return true, nil + } + s.T().Logf(" waiting for quorum claim for epoch %d (status=%s)", epochIndex, epoch.Status) + return false, nil + }) + if err != nil && lastErr != nil { + err = fmt.Errorf("%w (last poll error: %v)", err, lastErr) + } + s.Require().NoError(err, "wait for epoch %d claim computation", epochIndex) + return result +} + +func isQuorumClaimReadyStatus(status model.EpochStatus) bool { + switch status { + case model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimSubmitted, + model.EpochStatus_ClaimStaged, + model.EpochStatus_ClaimAccepted: + return true + default: + return false + } +} + +func (s *EchoQuorumSuite) waitForQuorumAccepted(appName string, epochIndex uint64) { + stagedCtx, stagedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + staged, err := waitForEpochStatus(stagedCtx, s.T(), appName, epochIndex, model.EpochStatus_ClaimStaged) + stagedCancel() + s.Require().NoError(err, "wait for quorum claim to stage") + + if staged.StagedAtBlock != nil { + s.minePastBlock(*staged.StagedAtBlock + quorumClaimStagingPeriod) + } else { + s.Require().NoError(anvilMine(s.ctx, int(quorumClaimStagingPeriod)+1), "mine past claim staging period") + } + + acceptedCtx, acceptedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + _, err = waitForEpochStatus(acceptedCtx, s.T(), appName, epochIndex, model.EpochStatus_ClaimAccepted) + acceptedCancel() + s.Require().NoError(err, "wait for quorum claim to be accepted") +} + +func (s *EchoQuorumSuite) minePastBlock(block uint64) { + currentBlock, err := s.client.BlockNumber(s.ctx) + s.Require().NoError(err, "read current block") + if currentBlock > block { + return + } + blocksToMine := int(block - currentBlock + 1) + s.Require().NoError(anvilMine(s.ctx, blocksToMine), "mine past block %d", block) +} + +func (s *EchoQuorumSuite) submitQuorumClaim( + app quorumAppDeployment, + epoch *model.Epoch, + accountIndex uint32, + outputsMerkleRoot [32]byte, +) common.Hash { + r := s.Require() + r.NotNil(epoch.OutputsMerkleRoot, "epoch %d missing outputs merkle root", epoch.Index) + + key, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, accountIndex) + r.NoError(err, "derive validator key %d", accountIndex) + + opts, err := bind.NewKeyedTransactorWithChainID(key, s.chainID) + r.NoError(err, "new validator transactor %d", accountIndex) + opts.Context = s.ctx + + tx, err := app.quorum.SubmitClaim( + opts, + app.appAddress, + new(big.Int).SetUint64(epoch.LastBlock), + outputsMerkleRoot, + merkleProofToBytes32(epoch.OutputsMerkleProof), + ) + 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()) + return tx.Hash() +} + +func quorumValidatorAddresses(t testing.TB) []common.Address { + t.Helper() + indexes := []uint32{quorumNodeValidatorIndex, quorumValidatorIndexA, quorumValidatorIndexB} + addresses := make([]common.Address, 0, len(indexes)) + for _, index := range indexes { + key, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, index) + require.NoError(t, err, "derive validator key %d", index) + addresses = append(addresses, crypto.PubkeyToAddress(key.PublicKey)) + } + 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 new file mode 100644 index 000000000..b0c95bc09 --- /dev/null +++ b/test/integration/foreclose_prt_test.go @@ -0,0 +1,156 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// ForeclosePrtSuite is the PRT-consensus counterpart to ForecloseSuite. +// Authority and PRT route foreclosed apps through structurally-different +// code paths (claimer's processForeclosedApps vs prt's handleForeclosedApp, +// each with its own drain gate), but the operator-visible outcome must be +// identical: the app stays ENABLED with foreclose_block set, and evmreader +// continues observing post-foreclosure activity. A regression in either +// service's per-consensus drain path would not be caught by the Authority +// foreclose test. +type ForeclosePrtSuite struct { + suite.Suite + LogChecker + ctx context.Context + cancel context.CancelFunc + appName string + ethClient *ethclient.Client +} + +func TestForeclosePrt(t *testing.T) { + suite.Run(t, new(ForeclosePrtSuite)) +} + +func (s *ForeclosePrtSuite) SetupSuite() { + s.ctx, s.cancel = context.WithTimeout(context.Background(), 10*time.Minute) + + endpoint := envOrDefault("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", "http://localhost:8545") + client, err := ethclient.Dial(endpoint) + s.Require().NoError(err, "dial ethclient") + s.ethClient = client +} + +func (s *ForeclosePrtSuite) TearDownSuite() { + s.cancel() + s.ethClient.Close() +} + +func (s *ForeclosePrtSuite) SetupTest() { + s.StartLogCapture() + s.appName = "" +} + +func (s *ForeclosePrtSuite) TearDownTest() { + if s.appName != "" { + _ = disableApplication(s.ctx, s.appName) //nolint:errcheck + } + s.CheckLogs(s.T()) +} + +// TestForeclosePrtLifecycle deploys a PRT app with the second derived +// mnemonic account as guardian, runs the normal PRT lifecycle (input, +// tournament settlement, claim accepted), then forecloses on-chain via the +// CLI. The node must record foreclose_block and the app must stay ENABLED — +// same operator-visible contract as the Authority path, but routed through +// prt.handleForeclosedApp rather than claimer.processForeclosedApps. +func (s *ForeclosePrtSuite) TestForeclosePrtLifecycle() { + r := s.Require() + + builderEnv := os.Getenv("CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS") + r.NotEmpty(builderEnv, + "CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS must be set (run `eval $(make env)`)") + + const guardianIndex = 1 + guardianKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, guardianIndex) + r.NoError(err, "derive guardian key") + guardianAddr := crypto.PubkeyToAddress(guardianKey.PublicKey) + s.T().Logf("Guardian address (mnemonic[%d]): %s", guardianIndex, guardianAddr.Hex()) + + withdrawalConfigJSON := fmt.Sprintf(`{ + "guardian": "%s", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "%s" + }`, guardianAddr.Hex(), builderEnv) + withdrawalConfigJSON = strings.ReplaceAll(withdrawalConfigJSON, "\n", "") + withdrawalConfigJSON = strings.ReplaceAll(withdrawalConfigJSON, "\t", "") + + dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") + s.appName = uniqueAppName("foreclose-prt") + + // Phase 1 — full PRT lifecycle (input + tournament settlement + claim). + // PreClaimHook settles epoch 0 (sealed-empty at deploy) and epoch 1 + // (carrying our input), matching the existing TestEchoPrtLifecycle. + ethClient := s.ethClient + runEchoLifecycleTest(s.ctx, s.T(), r, echoLifecycleConfig{ + AppName: s.appName, + DappPath: dappPath, + Payload: "hello cartesi (foreclose prt)", + ExtraDeployArgs: []string{ + "--prt", + "--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) + }, + }) + s.T().Log("=== Pre-foreclosure PRT lifecycle complete ===") + + // Phase 2 — guardian forecloses via CLI (signer = mnemonic[1]). + s.T().Logf("Foreclosing %s with guardian wallet (mnemonic[%d])", s.appName, guardianIndex) + out, err := runCLIWithEnv(s.ctx, + []string{fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", guardianIndex)}, + "foreclose", s.appName, "--yes", "--json", + ) + r.NoError(err, "guardian foreclose CLI call: %s", out) + s.T().Logf(" foreclose tx: %s", strings.TrimSpace(out)) + + // Phase 3 — wait for the foreclose marker. evmreader is consensus- + // agnostic; the marker lands the same way regardless of Authority vs + // PRT, but the next services that read it differ. + const observeTimeout = 30 * time.Second + stateCtx, stateCancel := context.WithTimeout(s.ctx, observeTimeout) + defer stateCancel() + r.NoError(waitForApplicationForeclosed(stateCtx, s.T(), s.appName), + "node did not record foreclose_block after guardian foreclose() on PRT app") + + // Sanity: the PRT service's handleForeclosedApp must NOT transition the + // app to INOPERABLE. The operator-visible contract is identical to the + // Authority path even though the service that observed the foreclosure + // and its drain gate differ. + status, err := readApplicationState(s.ctx, s.appName) + r.NoError(err, "read app status after foreclosure") + r.Contains(status, "ENABLED", + "PRT app state should stay ENABLED after foreclosure") + r.NotContains(status, "INOPERABLE", + "foreclosure must not transition a PRT app to INOPERABLE") + r.Contains(status, "Foreclose block:", + "app status must surface the recorded foreclose_block") + r.Contains(status, "Foreclose transaction:", + "app status must surface the recorded foreclose_transaction") + + s.T().Logf("Final app status:\n%s", status) + s.T().Log("=== PRT foreclosure lifecycle complete ===") +} diff --git a/test/integration/foreclose_replay_test.go b/test/integration/foreclose_replay_test.go new file mode 100644 index 000000000..a3228e03e --- /dev/null +++ b/test/integration/foreclose_replay_test.go @@ -0,0 +1,340 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" + + "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" +) + +// ForecloseReplaySuite verifies the "new node bootstraps against an already- +// foreclosed application" scenario: a fresh node entry for a foreclosed +// contract must +// +// 1. ingest pre-foreclosure inputs from chain, +// 2. process them through the advancer + validator to produce the same +// local epoch/input state the original node had, +// 3. reconcile the pre-foreclosure on-chain-accepted claims to +// CLAIM_ACCEPTED locally via the claimer's read-only getClaim path, +// 4. record the on-chain Foreclosure event as a foreclose marker on the +// application row. +// +// The claimer must keep reconciling foreclosed apps via its read-only +// getClaim path; filtering them out of the claimer SELECTs entirely would +// leave the new node's local DB stuck at CLAIM_COMPUTED — diverging from +// chain reality and breaking downstream tooling that depends on the final +// accepted state. +type ForecloseReplaySuite struct { + suite.Suite + LogChecker + ctx context.Context + cancel context.CancelFunc +} + +func TestForecloseReplay(t *testing.T) { + suite.Run(t, new(ForecloseReplaySuite)) +} + +func (s *ForecloseReplaySuite) SetupSuite() { + // Two-app lifecycle (deploy + send + accept x 3 epochs + foreclose + + // remove + register + replay + drain) needs more headroom than the + // single-app foreclose test. + s.ctx, s.cancel = context.WithTimeout(context.Background(), 20*time.Minute) +} + +func (s *ForecloseReplaySuite) TearDownSuite() { + s.cancel() +} + +func (s *ForecloseReplaySuite) SetupTest() { + s.StartLogCapture() +} + +func (s *ForecloseReplaySuite) TearDownTest() { + // Unique-suffix names avoid collision across runs, so explicit teardown + // is not required; leaving the apps registered also lets a debugger + // inspect their final state. + s.CheckLogs(s.T()) +} + +// TestForecloseReregisterReplay is the full lifecycle described on the +// suite type. +func (s *ForecloseReplaySuite) TestForecloseReregisterReplay() { + r := s.Require() + + // The test mines large block batches (15 per input × 3 inputs, plus + // the wait for each claim acceptance, plus the B-side replay) which + // races the EVM reader's block-by-block fetcher when other tests run + // in parallel against the same Anvil instance — the reader can + // briefly query a height the chain hasn't quite reached. + s.SetExpectedLogs(s.T(), + ExpectedLog{ + Pattern: regexp.MustCompile(`BlockOutOfRangeError`), + Level: LevelError, + Reason: "transient EVM reader race against Anvil block production during " + + "rapid mining; the reader retries on its next tick", + }, + ) + + builderEnv := os.Getenv("CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS") + r.NotEmpty(builderEnv, + "CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS must be set (run `eval $(make env)`)") + + const guardianIndex = 1 + guardianKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, guardianIndex) + r.NoError(err, "derive guardian key") + guardianAddr := crypto.PubkeyToAddress(guardianKey.PublicKey) + + withdrawalConfigJSON := strings.NewReplacer("\n", "", "\t", "").Replace(fmt.Sprintf(`{ + "guardian": "%s", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "%s" + }`, guardianAddr.Hex(), builderEnv)) + + dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") + + // ─── Phase 1 — deploy A, send 3 inputs across 3 epochs, wait for all + // claims accepted on chain, then foreclose ───────────── + appAName := uniqueAppName("foreclose-replay-a") + s.T().Logf("--- Phase 1: deploy %s with guardian=%s ---", appAName, guardianAddr.Hex()) + + appAddr, err := deployApplication(s.ctx, appAName, dappPath, + "--salt", uniqueSalt(), + "--withdrawal-config", withdrawalConfigJSON, + ) + r.NoError(err, "deploy A") + s.T().Logf(" application deployed at %s", appAddr) + + r.NoError(anvilSetBalance(s.ctx, appAddr, oneEtherWei), + "fund application contract") + + // Send 3 inputs and bump anvil between each so they land in 3 distinct + // epochs. Default epoch_length = 10 blocks, so mining 15 blocks + // guarantees the next input falls into a fresh epoch. + const numInputs = 3 + inputEpochs := make([]uint64, numInputs) + for i := range numInputs { + payload := fmt.Sprintf("foreclose-replay-input-%d", i) + idx, _, err := sendInput(s.ctx, appAName, payload) + r.NoError(err, "send input %d", i) + r.Equal(uint64(i), idx, "input index must be %d", i) + + processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + input, err := waitForInputProcessed(processCtx, s.T(), appAName, idx) + processCancel() + r.NoError(err, "wait for input %d", i) + r.Equal(model.InputCompletionStatus_Accepted, input.Status) + inputEpochs[i] = input.EpochIndex + s.T().Logf(" input %d processed in epoch %d", i, input.EpochIndex) + + if i < numInputs-1 { + r.NoError(anvilMine(s.ctx, 15), "mine to next epoch") + } + } + + distinctEpochs := dedupAscending(inputEpochs) + r.Len(distinctEpochs, numInputs, + "inputs must land in 3 distinct epochs; got %v", inputEpochs) + + // Wait for every epoch to reach CLAIM_ACCEPTED on chain. + for _, ep := range distinctEpochs { + claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + epoch, err := waitForEpochStatus(claimCtx, s.T(), appAName, ep, model.EpochStatus_ClaimAccepted) + claimCancel() + r.NoError(err, "epoch %d should reach CLAIM_ACCEPTED", ep) + r.NotNil(epoch.OutputsMerkleRoot, "epoch %d outputs merkle root", ep) + s.T().Logf(" epoch %d accepted", ep) + } + + snapA := captureAppSnapshot(s.ctx, s.T(), r, appAName, numInputs, distinctEpochs) + s.T().Logf(" snapshot A: %d inputs, %d epochs", len(snapA.Inputs), len(snapA.Epochs)) + + // Foreclose with the guardian wallet (mnemonic[1]). + s.T().Logf(" foreclosing %s with guardian (mnemonic[%d])", appAName, guardianIndex) + out, err := runCLIWithEnv(s.ctx, + []string{fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", guardianIndex)}, + "foreclose", appAName, "--yes", "--json", + ) + r.NoError(err, "guardian foreclose: %s", out) + + aCtx, aCancel := context.WithTimeout(s.ctx, 30*time.Second) + r.NoError(waitForApplicationForeclosed(aCtx, s.T(), appAName), + "A did not record foreclose_block after guardian foreclose()") + aCancel() + s.T().Logf("=== Phase 1 complete: %s foreclose marker recorded ===", appAName) + + // ─── Phase 2 — remove A and re-register the same on-chain address + // under a new name B ───────────────────────────────── + // `app remove` rejects ENABLED apps, so disable A first. Foreclosure + // alone does not transition state, so the app is still ENABLED here. + s.T().Logf("--- Phase 2: remove %s and register the same address as B ---", appAName) + r.NoError(disableApplication(s.ctx, appAName), "disable %s before remove", appAName) + r.NoError(removeApplication(s.ctx, appAName), "remove %s", appAName) + + appBName := uniqueAppName("foreclose-replay-b") + r.NoError(registerApplication(s.ctx, appBName, appAddr, dappPath), + "register %s pointing at %s", appBName, appAddr) + s.T().Logf(" %s registered at %s", appBName, appAddr) + + // ─── Phase 3 — wait for B to replay all inputs and to observe the + // foreclosure marker (the same on-chain Foreclosure + // event A saw, since both apps point at the same + // IApplication address). B stays ENABLED with + // foreclose_block set, mirroring A's terminal state. + s.T().Log("--- Phase 3: wait for B to replay + observe foreclosure marker ---") + for i := range uint64(numInputs) { + processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + input, err := waitForInputProcessed(processCtx, s.T(), appBName, i) + processCancel() + r.NoError(err, "B: wait for input %d", i) + r.Equal(snapA.Inputs[i].Status, input.Status, + "input %d status must match A", i) + } + + bCtx, bCancel := context.WithTimeout(s.ctx, 30*time.Second) + r.NoError(waitForApplicationForeclosed(bCtx, s.T(), appBName), + "B did not record foreclose_block after replay") + bCancel() + + // The foreclose marker is recorded on the first evmreader tick that sees + // the Foreclosure event, which fires earlier than the claimer's per-epoch + // reconciliation. Wait explicitly for the claimer's read-only getClaim + // path to flip every replayed epoch CLAIM_COMPUTED → CLAIM_ACCEPTED + // before snapshotting. + for _, ep := range distinctEpochs { + claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + _, err := waitForEpochStatus(claimCtx, s.T(), appBName, ep, model.EpochStatus_ClaimAccepted) + claimCancel() + r.NoError(err, "B: epoch %d should reconcile to CLAIM_ACCEPTED", ep) + } + s.T().Logf("=== Phase 3 complete: %s foreclose marker recorded ===", appBName) + + // ─── Phase 4 — compare B's persisted state to A's snapshot ──────── + s.T().Log("--- Phase 4: compare B's persisted state to A's snapshot ---") + snapB := captureAppSnapshot(s.ctx, s.T(), r, appBName, numInputs, distinctEpochs) + + r.Len(snapB.Inputs, len(snapA.Inputs), + "B should have the same number of inputs as A") + r.Len(snapB.Epochs, len(snapA.Epochs), + "B should have the same number of accepted epochs as A") + + for i := range snapA.Inputs { + compareReplayedInput(s.T(), r, &snapA.Inputs[i], &snapB.Inputs[i], i) + } + for ep, epochA := range snapA.Epochs { + epochB, ok := snapB.Epochs[ep] + r.True(ok, "B is missing epoch %d", ep) + compareReplayedEpoch(s.T(), r, epochA, epochB, ep) + } + + s.T().Log("=== Foreclosure-replay test complete: B's state matches A's snapshot ===") +} + +// appSnapshot is the subset of an application's persisted state we compare +// between the original node (A) and the re-registered node (B). Fields whose +// value comes from the broadcast tx (claim_transaction_hash, staged_at_block, +// timestamps) are deliberately excluded — A produces them through Stage-1/2 +// broadcasts while B reaches CLAIM_ACCEPTED purely through the read-only +// reconciliation path, so equality there is not expected. +type appSnapshot struct { + Inputs []model.Input + Epochs map[uint64]*model.Epoch +} + +func captureAppSnapshot( + ctx context.Context, + t testing.TB, + r *require.Assertions, + appName string, + numInputs int, + epochIndices []uint64, +) appSnapshot { + t.Helper() + snap := appSnapshot{ + Inputs: make([]model.Input, numInputs), + Epochs: make(map[uint64]*model.Epoch, len(epochIndices)), + } + for i := range uint64(numInputs) { + input, err := readInput(ctx, appName, i) + r.NoError(err, "read %s input %d", appName, i) + snap.Inputs[i] = *input + } + for _, ep := range epochIndices { + epoch, err := readEpoch(ctx, appName, ep) + r.NoError(err, "read %s epoch %d", appName, ep) + snap.Epochs[ep] = epoch + } + return snap +} + +func compareReplayedInput(t testing.TB, r *require.Assertions, a, b *model.Input, i int) { + t.Helper() + r.Equal(a.Index, b.Index, "input %d: index", i) + r.Equal(a.EpochIndex, b.EpochIndex, "input %d: epoch index", i) + r.Equal(a.Status, b.Status, "input %d: status", i) + r.Equal(a.BlockNumber, b.BlockNumber, "input %d: block number", i) + r.Equal(a.RawData, b.RawData, "input %d: raw data", i) + r.Equal(a.TransactionReference, b.TransactionReference, "input %d: tx reference", i) +} + +func compareReplayedEpoch(t testing.TB, r *require.Assertions, a, b *model.Epoch, ep uint64) { + t.Helper() + r.Equal(a.Status, b.Status, "epoch %d: status", ep) + r.Equal(a.Index, b.Index, "epoch %d: index", ep) + r.Equal(a.FirstBlock, b.FirstBlock, "epoch %d: first block", ep) + r.Equal(a.LastBlock, b.LastBlock, "epoch %d: last block", ep) + r.Equal(a.InputIndexLowerBound, b.InputIndexLowerBound, "epoch %d: input lower bound", ep) + r.Equal(a.InputIndexUpperBound, b.InputIndexUpperBound, "epoch %d: input upper bound", ep) + r.Equal(a.OutputsMerkleRoot, b.OutputsMerkleRoot, "epoch %d: outputs merkle root", ep) + r.Equal(a.MachineHash, b.MachineHash, "epoch %d: machine hash", ep) +} + +func dedupAscending(in []uint64) []uint64 { + seen := map[uint64]bool{} + out := make([]uint64, 0, len(in)) + for _, v := range in { + if seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + return out +} + +// removeApplication removes a registered application from the local DB via +// `cartesi-rollups-cli app remove`. The CLI rejects ENABLED apps, so callers +// must transition through FAILED, INOPERABLE, or DISABLED first. +func removeApplication(ctx context.Context, appName string) error { + _, err := runCLI(ctx, "app", "remove", appName, "--yes") + return err +} + +// registerApplication registers an existing on-chain Application contract in +// the local DB under a new name, without redeploying. Reads consensus address, +// epoch length, withdrawal config, etc. from the on-chain contract — only +// the name, address, and template path are required. +func registerApplication(ctx context.Context, appName, appAddress, templatePath string) error { + _, err := runCLI(ctx, "app", "register", + "-n", appName, + "-a", appAddress, + "-t", templatePath, + ) + return err +} diff --git a/test/integration/foreclose_test.go b/test/integration/foreclose_test.go new file mode 100644 index 000000000..ca3f78199 --- /dev/null +++ b/test/integration/foreclose_test.go @@ -0,0 +1,227 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/suite" +) + +// ForecloseSuite exercises the full foreclosure lifecycle: +// +// 1. Deploy an Authority app where the guardian wallet differs from the +// node's default signer (FoundryMnemonic, account index 1) and the +// withdrawal output builder is the devnet-deployed UsdWithdrawalOutputBuilder +// (address surfaced via CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER). +// 2. Send one input through, claim accepted as usual. +// 3. The guardian (account index 1) calls IApplication.foreclose() via +// `cartesi-rollups-cli foreclose`. +// 4. The evmreader observes the Foreclosure() event and records +// (foreclose_block, foreclose_transaction) on the application row. +// +// Foreclosure does not transition state — the app stays ENABLED and evmreader +// continues observing post-foreclosure activity (drive-prove discovery, then +// Withdrawal indexing). This suite asserts only the foreclose-observed signal. +type ForecloseSuite struct { + suite.Suite + LogChecker + ctx context.Context + cancel context.CancelFunc + appName string +} + +func TestForeclose(t *testing.T) { + suite.Run(t, new(ForecloseSuite)) +} + +func (s *ForecloseSuite) SetupSuite() { + s.ctx, s.cancel = context.WithTimeout(context.Background(), 10*time.Minute) +} + +func (s *ForecloseSuite) TearDownSuite() { + s.cancel() +} + +func (s *ForecloseSuite) SetupTest() { + s.StartLogCapture() + s.appName = "" +} + +func (s *ForecloseSuite) TearDownTest() { + if s.appName != "" { + _ = disableApplication(s.ctx, s.appName) //nolint:errcheck + } + s.CheckLogs(s.T()) +} + +// TestForecloseLifecycle deploys an authority app with the second derived +// mnemonic account as guardian, sends an input, confirms the claim is +// accepted, then forecloses on-chain via the CLI and waits for the node to +// record the foreclosure marker. The app stays ENABLED with foreclose_block +// set; INOPERABLE is reserved for genuine corruption. +func (s *ForecloseSuite) TestForecloseLifecycle() { + require := s.Require() + + builderEnv := os.Getenv("CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS") + require.NotEmpty(builderEnv, + "CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS must be set (run `eval $(make env)`)") + + // Derive the guardian wallet from the same mnemonic the node uses, but + // at index 1 so it's a distinct account from the node's default signer. + const guardianIndex = 1 + guardianKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, guardianIndex) + require.NoError(err, "derive guardian key") + guardianAddr := crypto.PubkeyToAddress(guardianKey.PublicKey) + s.T().Logf("Guardian address (mnemonic[%d]): %s", guardianIndex, guardianAddr.Hex()) + s.T().Logf("Withdrawal output builder: %s", builderEnv) + + withdrawalConfigJSON := fmt.Sprintf(`{ + "guardian": "%s", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "%s" + }`, guardianAddr.Hex(), builderEnv) + withdrawalConfigJSON = strings.ReplaceAll(withdrawalConfigJSON, "\n", "") + withdrawalConfigJSON = strings.ReplaceAll(withdrawalConfigJSON, "\t", "") + + dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") + s.appName = uniqueAppName("foreclose-test") + + // Phase 1 — normal lifecycle: deploy + send + claim accepted. + runEchoLifecycleTest(s.ctx, s.T(), require, echoLifecycleConfig{ + AppName: s.appName, + DappPath: dappPath, + Payload: "hello cartesi (foreclose)", + ExtraDeployArgs: []string{ + "--withdrawal-config", withdrawalConfigJSON, + }, + }) + s.T().Log("=== Pre-foreclosure lifecycle complete ===") + + // Phase 2 — guardian forecloses via CLI. Use CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX + // to switch the signer from the node's default (index 0) to the guardian + // (index 1). The node will pick up the Foreclosure event on the next tick. + s.T().Logf("Foreclosing %s with guardian wallet (mnemonic[%d])", s.appName, guardianIndex) + out, err := runCLIWithEnv(s.ctx, + []string{fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", guardianIndex)}, + "foreclose", s.appName, "--yes", "--json", + ) + require.NoError(err, "guardian foreclose CLI call: %s", out) + s.T().Logf(" foreclose tx: %s", strings.TrimSpace(out)) + + // Phase 3 — wait for the node to record the foreclosure marker. evmreader + // detects Foreclosure within a few ticks of its polling cadence and + // writes (foreclose_block, foreclose_transaction) to the application + // row. The `app status` CLI now emits a "Foreclose block:" line when + // app.ForecloseBlock != 0, which is what waitForApplicationForeclosed + // polls for. + const observeTimeout = 30 * time.Second + stateCtx, stateCancel := context.WithTimeout(s.ctx, observeTimeout) + defer stateCancel() + require.NoError(waitForApplicationForeclosed(stateCtx, s.T(), s.appName), + "node did not record foreclose_block after guardian foreclose()") + + // Sanity: foreclosure is not terminal — the app stays ENABLED and the + // node continues observing post-foreclosure events (drive-prove, + // withdrawals). + status, err := readApplicationState(s.ctx, s.appName) + require.NoError(err, "read app status after foreclosure") + require.Contains(status, "ENABLED", + "state should stay ENABLED after foreclosure") + require.NotContains(status, "INOPERABLE", + "foreclosure must not transition the app to INOPERABLE") + require.Contains(status, "Foreclose block:", + "app status must surface the recorded foreclose_block") + require.Contains(status, "Foreclose transaction:", + "app status must surface the recorded foreclose_transaction") + + s.T().Logf("Final app status:\n%s", status) + s.T().Log("=== Foreclosure lifecycle complete ===") +} + +// readApplicationState invokes `cartesi-rollups-cli app status ` and +// returns the raw output (state on first line; "Reason: …" on second line +// when the state has one). +func readApplicationState(ctx context.Context, appName string) (string, error) { + return runCLI(ctx, "app", "status", appName) +} + +// waitForApplicationState polls `app status` until the first line equals +// the wanted state or the context is cancelled. +func waitForApplicationState( + ctx context.Context, + t testing.TB, + appName string, + want string, +) error { + var lastErr error + err := pollUntil(ctx, 3*time.Second, func() (bool, error) { + out, err := readApplicationState(ctx, appName) + if err != nil { + if isCLIExitError(err) { + lastErr = err + t.Logf(" waiting for state %s (poll error: %v)", want, err) + return false, nil + } + return false, fmt.Errorf("poll app status: %w", err) + } + line := strings.SplitN(strings.TrimSpace(out), "\n", 2)[0] //nolint:mnd + line = strings.TrimSpace(line) + if line == want { + return true, nil + } + t.Logf(" waiting for state %s (have %s)", want, line) + return false, nil + }) + if err != nil && lastErr != nil { + return fmt.Errorf("%w (last poll error: %v)", err, lastErr) + } + return err +} + +// waitForApplicationForeclosed polls `app status` until the output contains +// a "Foreclose block:" line (emitted by app/status/status.go when +// app.ForecloseBlock != 0). Foreclosure does not transition state — the app +// stays ENABLED with foreclose_block set — so this is the gating signal for +// any test that drives a guardian foreclose() and waits for the node to +// observe it. +func waitForApplicationForeclosed( + ctx context.Context, + t testing.TB, + appName string, +) error { + var lastErr error + err := pollUntil(ctx, 3*time.Second, func() (bool, error) { + out, err := readApplicationState(ctx, appName) + if err != nil { + if isCLIExitError(err) { + lastErr = err + t.Logf(" waiting for foreclosure on %s (poll error: %v)", appName, err) + return false, nil + } + return false, fmt.Errorf("poll app status: %w", err) + } + if strings.Contains(out, "Foreclose block:") { + return true, nil + } + t.Logf(" waiting for foreclosure on %s (status: %q)", + appName, strings.SplitN(strings.TrimSpace(out), "\n", 2)[0]) //nolint:mnd + return false, nil + }) + if err != nil && lastErr != nil { + return fmt.Errorf("%w (last poll error: %v)", err, lastErr) + } + return err +} diff --git a/test/integration/node_helpers_test.go b/test/integration/node_helpers_test.go index e914ae427..df7ff58fb 100644 --- a/test/integration/node_helpers_test.go +++ b/test/integration/node_helpers_test.go @@ -44,13 +44,22 @@ func stopSharedNode(t testing.TB) { // startSharedNode starts a new test-managed node, reusing the existing log // file. Call this after stopSharedNode to restart the node. func startSharedNode(t testing.TB) { + startSharedNodeWithEnv(t) +} + +// 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. +func startSharedNodeWithEnv(t testing.TB, extraEnv ...string) { if sharedNode != nil { t.Fatal("cannot start node: already running") } logPath := os.Getenv("CARTESI_TEST_NODE_LOG_FILE") var err error - sharedNode, err = startNodeWithLog(logPath) + sharedNode, err = startNodeWithLog(logPath, extraEnv...) if err != nil { t.Fatalf("failed to start node: %v", err) } @@ -85,12 +94,14 @@ type nodeProcess struct { // startNodeWithLog starts the node binary as a subprocess, appending output // 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. +// 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). // // 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 // because `go test` captures the test process's stdout/stderr. -func startNodeWithLog(logPath string) (*nodeProcess, error) { +func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) { if _, err := exec.LookPath(nodeBinary); err != nil { return nil, fmt.Errorf("%s not found on PATH: %w", nodeBinary, err) } @@ -110,6 +121,7 @@ func startNodeWithLog(logPath string) (*nodeProcess, error) { "CARTESI_CLAIMER_POLLING_INTERVAL=1", "CARTESI_PRT_POLLING_INTERVAL=1", ) + cmd.Env = append(cmd.Env, extraEnv...) if err := cmd.Start(); err != nil { logFile.Close() diff --git a/test/integration/reject_exception_prt_test.go b/test/integration/reject_exception_prt_test.go index d19334b22..93fc31e7d 100644 --- a/test/integration/reject_exception_prt_test.go +++ b/test/integration/reject_exception_prt_test.go @@ -7,6 +7,7 @@ package integration import ( "context" + "regexp" "testing" "time" @@ -16,6 +17,15 @@ import ( "github.com/stretchr/testify/suite" ) +// prtBlockOutOfRangeAllowlist tolerates the transient Anvil +// BlockOutOfRangeError that surfaces when PRT settlement mines hundreds of +// blocks rapidly past the EVM reader's last polled head. +var prtBlockOutOfRangeAllowlist = ExpectedLog{ + Pattern: regexp.MustCompile(`BlockOutOfRangeError`), + Level: LevelError, + Reason: "transient Anvil error during rapid block mining in PRT settlement", +} + type RejectExceptionPrtSuite struct { suite.Suite LogChecker @@ -63,6 +73,8 @@ func (s *RejectExceptionPrtSuite) TearDownTest() { // sends 3 inputs, and verifies that input 1 is REJECTED while inputs 0 and 2 // are ACCEPTED. Then settles tournaments and executes outputs on L1. func (s *RejectExceptionPrtSuite) TestRejectInputPrt() { + s.SetExpectedLogs(s.T(), prtBlockOutOfRangeAllowlist) + ethClient := s.ethClient prtEpoch := uint64(1) appName := uniqueAppName("reject-prt-loop") @@ -85,6 +97,8 @@ func (s *RejectExceptionPrtSuite) TestRejectInputPrt() { // sends 3 inputs, and verifies that input 1 is EXCEPTION while inputs 0 and 2 // are ACCEPTED. Then settles tournaments and executes outputs on L1. func (s *RejectExceptionPrtSuite) TestExceptionInputPrt() { + s.SetExpectedLogs(s.T(), prtBlockOutOfRangeAllowlist) + ethClient := s.ethClient prtEpoch := uint64(1) appName := uniqueAppName("exception-prt-loop") diff --git a/test/integration/snapshot_policy_test.go b/test/integration/snapshot_policy_test.go index 81dcdf5e5..5597604c6 100644 --- a/test/integration/snapshot_policy_test.go +++ b/test/integration/snapshot_policy_test.go @@ -259,6 +259,14 @@ func (s *SnapshotPolicySuite) runSnapshotPolicyTest(cfg snapshotPolicyConfig) { // TestSnapshotPolicyEveryInput tests the EVERY_INPUT snapshot policy // with Authority consensus. func (s *SnapshotPolicySuite) TestSnapshotPolicyEveryInput() { + // The node restart mid-test interrupts in-flight RPC queries against + // Anvil; the reader-side scan can also briefly outpace block + // production. Tolerate the transient error class — it retries. + s.SetExpectedLogs(s.T(), ExpectedLog{ + Pattern: regexp.MustCompile(`BlockOutOfRangeError`), + Level: LevelError, + Reason: "transient Anvil error during rapid block mining or post-restart catchup", + }) s.runSnapshotPolicyTest(snapshotPolicyConfig{ Policy: model.SnapshotPolicy_EveryInput, }) @@ -267,6 +275,11 @@ func (s *SnapshotPolicySuite) TestSnapshotPolicyEveryInput() { // TestSnapshotPolicyEveryEpoch tests the EVERY_EPOCH snapshot policy // with Authority consensus. func (s *SnapshotPolicySuite) TestSnapshotPolicyEveryEpoch() { + s.SetExpectedLogs(s.T(), ExpectedLog{ + Pattern: regexp.MustCompile(`BlockOutOfRangeError`), + Level: LevelError, + Reason: "transient Anvil error during rapid block mining or post-restart catchup", + }) s.runSnapshotPolicyTest(snapshotPolicyConfig{ Policy: model.SnapshotPolicy_EveryEpoch, }) From 7b97ab18bce3bdb9b63fc163e7bd711afb53cadb Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Thu, 14 May 2026 21:13:11 -0300 Subject: [PATCH 15/16] test(appstatus): assert SetInoperable logs both lines on DB failure --- internal/appstatus/appstatus.go | 6 +++ internal/appstatus/appstatus_test.go | 68 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/internal/appstatus/appstatus.go b/internal/appstatus/appstatus.go index 19ef3a798..de8a8bd13 100644 --- a/internal/appstatus/appstatus.go +++ b/internal/appstatus/appstatus.go @@ -66,6 +66,12 @@ func SetFailedf( // The reason parameter must be a pre-formatted string describing the failure. // Always returns a non-nil error containing the reason because INOPERABLE is // a terminal state and callers should always stop processing the application. +// +// Logging contract: both the reason and any DB write error are logged at +// ERROR level via slog before the function returns. Callers that don't need +// to propagate the failure upward (e.g. best-effort loops over multiple +// applications) may discard the returned error with `_ =` without losing +// operator visibility. func SetInoperable( ctx context.Context, logger *slog.Logger, diff --git a/internal/appstatus/appstatus_test.go b/internal/appstatus/appstatus_test.go index 07e48261a..1b5e70e57 100644 --- a/internal/appstatus/appstatus_test.go +++ b/internal/appstatus/appstatus_test.go @@ -224,6 +224,74 @@ func (s *AppStatusSuite) TestSetFailedfDBError() { require.Equal("input 7: crash", *repo.lastReason) } +// captureHandler is an slog.Handler that records every emitted Record so +// tests can assert on log output. It is concurrency-safe enough for +// single-goroutine test scenarios. +type captureHandler struct { + records []slog.Record +} + +func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { + h.records = append(h.records, r) + return nil +} +func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } + +// findRecord returns the first record whose message equals msg, or nil. +func findRecord(records []slog.Record, msg string) *slog.Record { + for i := range records { + if records[i].Message == msg { + return &records[i] + } + } + return nil +} + +// attrValue extracts the value of a named attribute from a record, or nil. +func attrValue(r *slog.Record, key string) any { + var found any + r.Attrs(func(a slog.Attr) bool { + if a.Key == key { + found = a.Value.Any() + return false + } + return true + }) + return found +} + +// TestSetInoperableDBErrorLogsBothLines asserts the logging contract +// documented on SetInoperable: when the DB write fails, BOTH the "marking +// application as inoperable" line and the "failed to update application +// state" line are emitted at ERROR level. This is the invariant that lets +// callers discard the returned error with `_ =` without losing operator +// visibility into the DB failure. +func (s *AppStatusSuite) TestSetInoperableDBErrorLogsBothLines() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + handler := &captureHandler{} + logger := slog.New(handler) + app := newTestApp() + + err := SetInoperable(context.Background(), logger, repo, app, "state corruption") + require.ErrorIs(err, dbErr) + + transition := findRecord(handler.records, "marking application as inoperable (irrecoverable)") + require.NotNil(transition, "transition log line must fire even on DB failure") + require.Equal(slog.LevelError, transition.Level) + require.Equal("state corruption", attrValue(transition, "reason")) + + dbFailure := findRecord(handler.records, "failed to update application state") + require.NotNil(dbFailure, "DB-failure log line must fire so operators see the persist error") + require.Equal(slog.LevelError, dbFailure.Level) + loggedErr, ok := attrValue(dbFailure, "error").(error) + require.True(ok, "error attr must be an error value") + require.ErrorIs(loggedErr, dbErr) +} + func (s *AppStatusSuite) TestReasonTruncation() { require := s.Require() repo := &mockRepo{} From 29d3a2239480f4a7f652a70077b66dd9d2d5376a Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Mon, 18 May 2026 13:10:38 -0300 Subject: [PATCH 16/16] fix(validator): keep Canceled graceful, propagate DeadlineExceeded --- internal/validator/validator.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/internal/validator/validator.go b/internal/validator/validator.go index fcb214865..410310539 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -7,6 +7,7 @@ package validator import ( "context" + "errors" "fmt" "github.com/ethereum/go-ethereum/common" @@ -72,6 +73,14 @@ func (s *Service) Reload() []error { return nil } func (s *Service) Tick() []error { apps, _, err := getAllRunningApplications(s.Context, 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 s.IsStopping() && errors.Is(err, context.Canceled) { + s.Logger.Warn("Tick interrupted by shutdown", "error", err) + return nil + } return []error{fmt.Errorf("failed to get running applications. %w", err)} } @@ -79,6 +88,12 @@ func (s *Service) Tick() []error { errs := []error{} for idx := range apps { if err := s.validateApplication(s.Context, apps[idx]); err != nil { + // Same shutdown-cancellation suppression as above, per-app. + if s.IsStopping() && errors.Is(err, context.Canceled) { + s.Logger.Warn("Tick interrupted by shutdown", + "application", apps[idx].IApplicationAddress, "error", err) + continue + } errs = append(errs, err) } } @@ -144,7 +159,14 @@ func (s *Service) validateApplication(ctx context.Context, app *Application) err ) merkleRoot, outputs, err := s.computeMerkleTreeAndProofs(ctx, app, epoch) if err != nil { - s.Logger.Error("failed to create claim and proofs.", "error", err) + // Don't log shutdown-cancellation at ERR — every in-flight DB + // query returns context.Canceled and Tick's outer suppression + // (s.IsStopping() && errors.Is(err, context.Canceled)) handles + // the propagation. DeadlineExceeded is a real failure and + // must still be logged. + if !(s.IsStopping() && errors.Is(err, context.Canceled)) { + s.Logger.Error("failed to create claim and proofs.", "error", err) + } return err }