diff --git a/Dockerfile b/Dockerfile index 5c6f64b7f..9306b036a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,8 +112,8 @@ RUN make build-go FROM go-builder AS tester # Install golangci-lint for linting inside Docker. -ARG GOLANGCI_LINT_VERSION=1.64.5 -RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v${GOLANGCI_LINT_VERSION} +ARG GOLANGCI_LINT_VERSION=2.13.2 +RUN GOTOOLCHAIN=local go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v${GOLANGCI_LINT_VERSION} # Install gotestsum for test output formatting and summaries. ARG GOTESTSUM_VERSION=1.13.0 diff --git a/Makefile b/Makefile index 5ee243aae..63800670b 100644 --- a/Makefile +++ b/Makefile @@ -18,14 +18,14 @@ TARGET_OS?=$(shell uname) export TARGET_OS ROLLUPS_NODE_VERSION := 2.0.0-alpha.12 -ROLLUPS_CONTRACTS_VERSION := 3.0.0-alpha.6 +ROLLUPS_CONTRACTS_VERSION := 3.0.0-alpha.10 ROLLUPS_CONTRACTS_URL:=https://github.com/cartesi/rollups-contracts/releases/download/ -ROLLUPS_CONTRACTS_ARTIFACT:=rollups-contracts-$(ROLLUPS_CONTRACTS_VERSION)-artifacts.tar.gz -ROLLUPS_CONTRACTS_SHA256:=ad1e0880766d25419fc6da1858ea4e7b9074b400e9d9ef68da88b12f4a8bba45 -ROLLUPS_PRT_CONTRACTS_VERSION := 3.0.0-alpha.3 +ROLLUPS_CONTRACTS_ARTIFACT:=cartesi-rollups-contracts-$(ROLLUPS_CONTRACTS_VERSION)-artifacts.tar.gz +ROLLUPS_CONTRACTS_SHA256:=5213ce59d0f5a1c4fef4ebf17b6ef999be709c32b4b94511c320729bb2afa959 +ROLLUPS_PRT_CONTRACTS_VERSION := 3.0.0-alpha.4 ROLLUPS_PRT_CONTRACTS_URL:=https://github.com/cartesi/dave/releases/download/ ROLLUPS_PRT_CONTRACTS_ARTIFACT:=cartesi-rollups-prt-$(ROLLUPS_PRT_CONTRACTS_VERSION)-contract-artifacts.tar.gz -ROLLUPS_PRT_CONTRACTS_SHA256:=240f4934df7a313dc05a4ae6cc3eee97b5c146952c4218502fec0db83f36a5a5 +ROLLUPS_PRT_CONTRACTS_SHA256:=622964166b4049b556dc20b26ef2b9a5e8621a2ad3e1f43eee0b802a085244b3 IMAGE_TAG ?= devel @@ -123,6 +123,8 @@ endif ROLLUPS_CONTRACTS_ABI_BASEDIR:= rollups-contracts/ ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR:= rollups-prt-contracts/ +ROLLUPS_CONTRACTS_ABI_STAMP:= $(ROLLUPS_CONTRACTS_ABI_BASEDIR).$(ROLLUPS_CONTRACTS_VERSION)-$(ROLLUPS_CONTRACTS_SHA256).stamp +ROLLUPS_PRT_CONTRACTS_ABI_STAMP:= $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR).$(ROLLUPS_PRT_CONTRACTS_VERSION)-$(ROLLUPS_PRT_CONTRACTS_SHA256).stamp all: build @@ -141,16 +143,19 @@ env: @echo export CARTESI_BLOCKCHAIN_HTTP_ENDPOINT="http://localhost:8545" @echo export CARTESI_BLOCKCHAIN_ID="31337" @echo export CARTESI_EVM_READER_POLLING_INTERVAL="1" - @echo export CARTESI_CONTRACTS_INPUT_BOX_ADDRESS="0x346B3df038FE9f8380071eC6514D5a83aD143939" - @echo export CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS="0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0" - @echo export CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS="0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F" - @echo export CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS="0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483" - @echo export CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS="0x6145C5996a71a379E030aEb0440df79D60833418" - @echo export CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS="0x33FFf0b681c90664dD048a60400AE2D827a4c5bb" - @echo export CARTESI_DEVNET_ERC20_PORTAL_ADDRESS="0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1" - @echo export CARTESI_DEVNET_TEST_ERC20_ADDRESS="0x88A2120B7068E78692C8fd12E751d610B6377E4d" - @echo export CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS="0x0745787835A019cd4dae8EDB541Fbc0647793d63" + @echo export CARTESI_CONTRACTS_INPUT_BOX_ADDRESS="0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + @echo export CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS="0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + @echo export CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS="0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + @echo export CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS="0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + @echo export CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS="0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + @echo export CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS="0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + @echo export CARTESI_DEVNET_ERC20_PORTAL_ADDRESS="0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419" + @echo export CARTESI_DEVNET_TEST_ERC20_ADDRESS="0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" + @echo export CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS="0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" @echo export CARTESI_AUTH_MNEMONIC=\"test test test test test test test test test test test junk\" + @echo export CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX="0" + @echo export CARTESI_PRT_AUTH_MNEMONIC=\"test test test test test test test test test test test junk\" + @echo export CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX="6" @echo export CARTESI_DATABASE_CONNECTION="postgres://postgres:password@localhost:5432/rollupsdb?sslmode=disable" @echo export CARTESI_SNAPSHOTS_DIR="snapshots" @echo export CARTESI_TEST_DATABASE_CONNECTION="postgres://test_user:password@localhost:5432/test_rollupsdb?sslmode=disable" @@ -195,10 +200,11 @@ check-generate: generate ## Check whether the generated files are in sync exit 1; \ fi -contracts: $(ROLLUPS_CONTRACTS_ABI_BASEDIR)/.stamp $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR)/.stamp ## Export the contract artifacts +contracts: $(ROLLUPS_CONTRACTS_ABI_STAMP) $(ROLLUPS_PRT_CONTRACTS_ABI_STAMP) ## Export the contract artifacts -$(ROLLUPS_CONTRACTS_ABI_BASEDIR)/.stamp: +$(ROLLUPS_CONTRACTS_ABI_STAMP): @echo "Downloading rollups-contracts artifacts" + @rm -rf $(ROLLUPS_CONTRACTS_ABI_BASEDIR) @mkdir -p $(ROLLUPS_CONTRACTS_ABI_BASEDIR) @curl -sSL $(ROLLUPS_CONTRACTS_URL)/v$(ROLLUPS_CONTRACTS_VERSION)/$(ROLLUPS_CONTRACTS_ARTIFACT) -o $(ROLLUPS_CONTRACTS_ARTIFACT) @echo "$(ROLLUPS_CONTRACTS_SHA256) $(ROLLUPS_CONTRACTS_ARTIFACT)" | shasum -a 256 --check > /dev/null @@ -206,8 +212,9 @@ $(ROLLUPS_CONTRACTS_ABI_BASEDIR)/.stamp: @touch $@ @rm -f $(ROLLUPS_CONTRACTS_ARTIFACT) -$(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR)/.stamp: +$(ROLLUPS_PRT_CONTRACTS_ABI_STAMP): @echo "Downloading rollups-prt-contracts artifacts" + @rm -rf $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR) @mkdir -p $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR) @curl -sSL $(ROLLUPS_PRT_CONTRACTS_URL)/v$(ROLLUPS_PRT_CONTRACTS_VERSION)/$(ROLLUPS_PRT_CONTRACTS_ARTIFACT) -o $(ROLLUPS_PRT_CONTRACTS_ARTIFACT) @echo "$(ROLLUPS_PRT_CONTRACTS_SHA256) $(ROLLUPS_PRT_CONTRACTS_ARTIFACT)" | shasum -a 256 --check > /dev/null @@ -224,6 +231,7 @@ generate-db: ## Generate repository/db with Jet @rm -rf internal/repository/postgres/db @go run github.com/go-jet/jet/v2/cmd/jet -dsn=$$CARTESI_DATABASE_CONNECTION -schema=public -path=./internal/repository/postgres/db @rm -rf internal/repository/postgres/db/rollupsdb/public/model + @chmod -R g+rwX internal/repository/postgres/db # ============================================================================= # Clean @@ -384,8 +392,8 @@ applications/unexpected-yield-dapp: ## Create unexpected-yield test application applications/erc20-withdrawal-dapp: test/dapps/erc20-withdrawal/install.sh ## Create ERC-20 withdrawal test application @echo "Creating ERC-20 withdrawal test application" @mkdir -p applications - @PORTAL=$${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1}; \ - TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x88A2120B7068E78692C8fd12E751d610B6377E4d}; \ + @PORTAL=$${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419}; \ + TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}; \ cartesi-machine --ram-length=128Mi \ --flash-drive=label:accounts,length:4Mi,mke2fs:false,mount:false,user:dapp \ --env=TRUSTED_ERC20_PORTAL=$$PORTAL \ @@ -413,7 +421,7 @@ deploy-erc20-withdrawal-dapp: applications/erc20-withdrawal-dapp ## Deploy ERC-2 @set -e; \ APP=$${APP:-erc20-withdrawal-dapp}; \ GUARDIAN=$${GUARDIAN:-0x70997970C51812dc3A010C7d01b50e0d17dc79C8}; \ - BUILDER=$${CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS:-0x0745787835A019cd4dae8EDB541Fbc0647793d63}; \ + BUILDER=$${CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS:-0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7}; \ DRIVE_START_INDEX=$$(jq -r '.config.flash_drive[] | select(.length == 4194304) | (.start / 4194304 | floor)' \ applications/erc20-withdrawal-dapp/config.json); \ WITHDRAWAL_CONFIG=$$(jq -cn \ @@ -432,7 +440,7 @@ deploy-erc20-withdrawal-dapp: applications/erc20-withdrawal-dapp ## Deploy ERC-2 fund-wallet: ## Fund the default Anvil wallet with ETH and test ERC-20 @set -e; \ RPC_URL=$${CARTESI_BLOCKCHAIN_HTTP_ENDPOINT:-http://localhost:8545}; \ - TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x88A2120B7068E78692C8fd12E751d610B6377E4d}; \ + TOKEN=$${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}; \ WALLET=$${WALLET:-$$(cast rpc --rpc-url "$$RPC_URL" eth_accounts | jq -r '.[0]')}; \ ETH_WEI=$${ETH_WEI:-0x8ac7230489e80000}; \ TOKEN_AMOUNT=$${TOKEN_AMOUNT:-1000000}; \ @@ -598,7 +606,7 @@ check-license: ## Verify license headers on Go source files # `make integration-test-shard-check`. # # Shards are grouped by semantic family, not balanced by runtime: `withdrawal` -# is a single test while `restart` and `replay` are the heaviest. Each shard +# covers withdrawals and deposit refunds; `restart` and `replay` are the heaviest. Each shard # gets its own CI runner and the full per-job `go test -timeout 55m` # (run-integration-tests.sh) budget; `restart` (multi-suite, ~25-min setup # contexts) is the first to watch if a shard ever approaches that ceiling. @@ -607,12 +615,12 @@ check-license: ## Verify license headers on Go source files # dependency for the check to build on the CI setup runner. INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal awskms -INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|TerminalMachineStates|MultiApp|EchoAuthorityStaging)$$ +INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|TerminalMachineStates|MultiApp|EchoAuthorityStaging|IntegrationCLI|IntegrationLogScanner)$$ INTEGRATION_SHARD_quorum := ^Test(EchoQuorum|SameBlockInputs)$$ -INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt)$$ +INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt|PrtPassiveDisputeObserver|SparseDisputeCommitment(MatchesDenseTrees|CanonicalGeometry|RejectsOutOfRangeRequests)|PassiveObserver(CleanupPreservesRestoreOrder|Config(PreservesOriginalAndUnrelatedFields|RejectsMissingFieldsAndInvalidJSON)))$$ INTEGRATION_SHARD_replay := ^Test(Foreclose|ForecloseReplay|DivergentClaim)$$ -INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy)$$ -INTEGRATION_SHARD_withdrawal := ^TestWithdrawalLifecycle$$ +INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy|NodeSubprocess)$$ +INTEGRATION_SHARD_withdrawal := ^Test(WithdrawalLifecycle|RefundLifecycle)$$ INTEGRATION_SHARD_awskms := ^TestLocalStackAWSIntegration$$ # ----------------------------------------------------------------------------- @@ -757,7 +765,7 @@ _local-topology-%: if [ -n "$(filter awskms,$(SHARD))" ]; then \ if [ -z "$$LOCALSTACK_KMS_ENDPOINT" ]; then \ echo "ERROR: LOCALSTACK_KMS_ENDPOINT is required when SHARD includes awskms." >&2; \ - echo "Run 'make start-awslocalstack' and export the variables it prints." >&2; \ + echo "Run 'make start-awslocalstack', then eval \"\$$(make env-awslocalstack)\"." >&2; \ exit 1; \ fi; \ export LOCALSTACK_KMS_REQUIRED=true; \ diff --git a/cmd/cartesi-rollups-cli/root/app/register/register.go b/cmd/cartesi-rollups-cli/root/app/register/register.go index 73db034ff..ef7e88822 100644 --- a/cmd/cartesi-rollups-cli/root/app/register/register.go +++ b/cmd/cartesi-rollups-cli/root/app/register/register.go @@ -5,7 +5,6 @@ package register import ( "context" - "encoding/hex" "encoding/json" "fmt" "math/big" @@ -17,7 +16,6 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository/factory" - "github.com/cartesi/rollups-node/pkg/contracts/dataavailability" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/contracts/iquorum" @@ -39,7 +37,6 @@ var Cmd = &cobra.Command{ Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS Input Box contract address CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED Enable machine hash check`, } @@ -54,9 +51,6 @@ var ( templateHash string epochLength uint64 claimStagingPeriod uint64 - inputBoxBlockNumber uint64 - inputBoxAddressFromEnv bool - dataAvailability string enableMachineHashCheck bool applicationTypePRT bool disabled bool @@ -87,17 +81,10 @@ func init() { ) Cmd.Flags().Uint64Var(&claimStagingPeriod, "claim-staging-period", 0, - "Consensus claim staging period in blocks (Authority/Quorum only). "+ + "Consensus claim staging period in blocks. "+ "(DO NOT USE IN PRODUCTION)\nThis value is retrieved from the consensus contract", ) - Cmd.Flags().StringVarP(&dataAvailability, "data-availability", "D", "", - "Application ABI encoded Data Availability. If not provided, it will be read from the InputBox Address", - ) - - Cmd.Flags().BoolVar(&inputBoxAddressFromEnv, "inputbox-from-env", false, "Read Input Box contract address from environment") - Cmd.Flags().Uint64Var(&inputBoxBlockNumber, "inputbox-block-number", 0, "InputBox deployment block number") - Cmd.Flags().BoolVarP(&disabled, "disabled", "d", false, "Registers the application with enabled=false") Cmd.Flags().BoolVarP(&printAsJSON, "print-json", "j", false, "Prints the application data as JSON") @@ -116,7 +103,6 @@ func init() { command.Flags().Lookup("verbose").Hidden = false command.Flags().Lookup("database-connection").Hidden = false command.Flags().Lookup("blockchain-http-endpoint").Hidden = false - command.Flags().Lookup("inputbox").Hidden = false origHelpFunc(command, strings) }) } @@ -186,7 +172,7 @@ func run(cmd *cobra.Command, _ []string) { } } - if !cmd.Flags().Changed("claim-staging-period") && !applicationTypePRT { + if !cmd.Flags().Changed("claim-staging-period") { claimStagingPeriod, err = getClaimStagingPeriod(ctx, consensus) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get claim staging period from consensus: %v\n", @@ -202,31 +188,32 @@ func run(cmd *cobra.Command, _ []string) { os.Exit(1) } - inputBoxAddress, encodedDA, err := processDataAvailability( - ctx, - address, - cmd.Flags().Changed("data-availability"), - cmd.Flags().Changed("inputbox") || cmd.Flags().Changed("inputbox-from-env"), - ) - cobra.CheckErr(err) + inputBoxAddress, err := getInputBox(ctx, address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get input box address from application: %v\n", + cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + os.Exit(1) + } - if !cmd.Flags().Changed("inputbox-block-number") { - block, err := getInputBoxDeploymentBlock(ctx, *inputBoxAddress) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to get deployment block number: %v\n", err) - os.Exit(1) - } - inputBoxBlockNumber = block.Uint64() + block, err := getInputBoxDeploymentBlock(ctx, inputBoxAddress) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get deployment block number: %v\n", err) + os.Exit(1) } + if !block.IsUint64() { + fmt.Fprintf(os.Stderr, "Input box deployment block does not fit uint64: %v\n", block) + os.Exit(1) + } + inputBoxBlockNumber := block.Uint64() // ensure there is a contract deployed at the input box address - hasCode, err := hasCodeAt(ctx, *inputBoxAddress) + hasCode, err := hasCodeAt(ctx, inputBoxAddress) if err != nil { fmt.Fprintf(os.Stderr, "failed to probe input box address for contract: %v\n", err) os.Exit(1) } if !hasCode { - fmt.Fprintf(os.Stderr, "input box address has no code: %v\n", consensus) + fmt.Fprintf(os.Stderr, "input box address has no code: %v\n", inputBoxAddress) os.Exit(1) } @@ -240,13 +227,12 @@ func run(cmd *cobra.Command, _ []string) { Name: validName, IApplicationAddress: address, IConsensusAddress: consensus, - IInputBoxAddress: *inputBoxAddress, + IInputBoxAddress: inputBoxAddress, TemplateURI: templatePath, TemplateHash: parsedTemplateHash, EpochLength: epochLength, ClaimStagingPeriod: claimStagingPeriod, WithdrawalConfig: withdrawalConfig, - DataAvailability: encodedDA, ConsensusType: consensusType, Enabled: applicationEnabled, Status: model.ApplicationStatus_OK, @@ -339,6 +325,21 @@ func getConsensus( return ethutil.GetConsensus(ctx, client, appAddress) } +func getInputBox( + ctx context.Context, + appAddress common.Address, +) (common.Address, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return common.Address{}, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := ethclient.Dial(ethEndpoint.Raw()) + if err != nil { + return common.Address{}, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetInputBox(ctx, client, appAddress) +} + func getEpochLength( ctx context.Context, consensusAddr common.Address, @@ -447,90 +448,3 @@ func getInputBoxDeploymentBlock( } return ethutil.GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) } - -func getDataAvailability( - ctx context.Context, - appAddress common.Address, -) ([]byte, error) { - ethEndpoint, err := config.GetBlockchainHttpEndpoint() - if err != nil { - return nil, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) - } - client, err := ethclient.Dial(ethEndpoint.Raw()) - if err != nil { - return nil, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) - } - return ethutil.GetDataAvailability(ctx, client, appAddress) -} - -func processDataAvailability( - ctx context.Context, - appAddress common.Address, - hasDataAvailabilityFlag bool, - hasInputBoxAddressFlag bool, -) (*common.Address, []byte, error) { - var inputBoxAddress common.Address - var encodedDA []byte - var err error - - parsedAbi, err := dataavailability.DataAvailabilityMetaData.GetAbi() - if err != nil { - return nil, nil, fmt.Errorf("failed to get ABI: %w", err) - } - - if hasInputBoxAddressFlag { - inputBoxAddress, err = config.GetContractsInputBoxAddress() - if err != nil { - return nil, nil, fmt.Errorf("failed to get input box address: %w", err) - } - - encodedDA, err = parsedAbi.Pack("InputBox", inputBoxAddress) - if err != nil { - return nil, nil, fmt.Errorf("failed to pack InputBox: %w", err) - } - } else { - if hasDataAvailabilityFlag { - if len(dataAvailability) < 3 || (!strings.HasPrefix(dataAvailability, "0x") && !strings.HasPrefix(dataAvailability, "0X")) { - return nil, nil, fmt.Errorf("data Availability should be an ABI encoded value") - } - - s := dataAvailability[2:] - encodedDA, err = hex.DecodeString(s) - if err != nil { - return nil, nil, fmt.Errorf("error parsing Data Availability value: %w", err) - } - } else { - encodedDA, err = getDataAvailability(ctx, appAddress) - if err != nil { - return nil, nil, fmt.Errorf("failed to get Data Availability from Application: %w", err) - } - } - - if len(encodedDA) < model.DATA_AVAILABILITY_SELECTOR_SIZE { - return nil, nil, fmt.Errorf("invalid Data Availability") - } - - method, err := parsedAbi.MethodById(encodedDA[:model.DATA_AVAILABILITY_SELECTOR_SIZE]) - if err != nil { - return nil, nil, fmt.Errorf("failed to get method by ID: %w", err) - } - - args, err := method.Inputs.Unpack(encodedDA[model.DATA_AVAILABILITY_SELECTOR_SIZE:]) - if err != nil { - return nil, nil, fmt.Errorf("failed to unpack inputs: %w", err) - } - - if len(args) == 0 { - return nil, nil, fmt.Errorf("invalid Data Availability. Should at least contain InputBox Address") - } - - switch addr := args[0].(type) { - case common.Address: - inputBoxAddress = addr - default: - return nil, nil, fmt.Errorf("first argument in Data Availability is not an address (got %T)", args[0]) - } - } - - return &inputBoxAddress, encodedDA, nil -} diff --git a/cmd/cartesi-rollups-cli/root/app/register/register_test.go b/cmd/cartesi-rollups-cli/root/app/register/register_test.go index 8cfd58b59..5720161cd 100644 --- a/cmd/cartesi-rollups-cli/root/app/register/register_test.go +++ b/cmd/cartesi-rollups-cli/root/app/register/register_test.go @@ -70,3 +70,9 @@ func TestConsensusTypeFromQuorumProbe_RejectsZeroValidatorQuorum(t *testing.T) { require.ErrorContains(t, err, "zero validators") require.True(t, probe.called) } + +func TestRegisterCommandDoesNotExposeLegacyInputBoxFlags(t *testing.T) { + require.Nil(t, Cmd.Flags().Lookup("data-availability")) + require.Nil(t, Cmd.Flags().Lookup("inputbox-from-env")) + require.Nil(t, Cmd.Flags().Lookup("inputbox-block-number")) +} diff --git a/cmd/cartesi-rollups-cli/root/app/status/status.go b/cmd/cartesi-rollups-cli/root/app/status/status.go index 7ba3482d5..5f65b2993 100644 --- a/cmd/cartesi-rollups-cli/root/app/status/status.go +++ b/cmd/cartesi-rollups-cli/root/app/status/status.go @@ -25,6 +25,10 @@ var Cmd = &cobra.Command{ Args: cobra.RangeArgs(1, 2), //nolint:mnd Run: run, Long: ` +FAILED stops local processing and foreclosure work. Repair the failure cause +and verify snapshot/database consistency before you enable the application. +Enabling clears FAILED. It does not repair state or cancel foreclosure. + Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string`, } @@ -36,7 +40,7 @@ cartesi-rollups-cli app status echo-dapp cartesi-rollups-cli app status echo-dapp enabled cartesi-rollups-cli app status echo-dapp disabled -# Re-enable a FAILED application without confirmation prompt: +# After repairing the cause, re-enable a FAILED application without a prompt: cartesi-rollups-cli app status echo-dapp enabled --yes` func init() { @@ -84,6 +88,9 @@ func run(cmd *cobra.Command, args []string) { } if app.ForecloseBlock != 0 { fmt.Printf("Foreclose block: 0x%x\n", app.ForecloseBlock) + if app.Status == model.ApplicationStatus_Failed { + fmt.Println("Foreclosure work is blocked by FAILED. Repair the cause before enabling the application.") + } if app.ForecloseTransaction != nil { fmt.Printf("Foreclose transaction: %s\n", app.ForecloseTransaction.Hex()) } @@ -127,6 +134,7 @@ func run(cmd *cobra.Command, args []string) { fmt.Printf("Reason: %s\n", *app.Reason) } fmt.Println("Re-enabling will attempt to restart processing from the last snapshot.") + fmt.Println("Repair the failure cause and verify snapshot/database consistency before you proceed.") confirmed, err := cli.ConfirmPrompt("Proceed?") if err != nil { fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) diff --git a/cmd/cartesi-rollups-cli/root/contract/app.go b/cmd/cartesi-rollups-cli/root/contract/app.go index e1d6a6990..23b4536e2 100644 --- a/cmd/cartesi-rollups-cli/root/contract/app.go +++ b/cmd/cartesi-rollups-cli/root/contract/app.go @@ -4,7 +4,6 @@ package contract import ( - "encoding/hex" "encoding/json" "fmt" "log/slog" @@ -87,9 +86,9 @@ func (c *chainClient) queryApp() (*AppResult, error) { return nil, fmt.Errorf("GetOutputsMerkleRootValidator: %w", err) } - dataAvailability, err := app.GetDataAvailability(c.callOpts) + inputBox, err := app.GetInputBox(c.callOpts) if err != nil { - return nil, fmt.Errorf("GetDataAvailability: %w", err) + return nil, fmt.Errorf("GetInputBox: %w", err) } isForeclosed, err := app.IsForeclosed(c.callOpts) @@ -126,7 +125,7 @@ func (c *chainClient) queryApp() (*AppResult, error) { ExecutedOutputs: executedOutputs, ConsensusAddress: formatAddr(consensusAddr), ConsensusType: consensusLabel, - DataAvailability: "0x" + hex.EncodeToString(dataAvailability), + InputBox: formatAddr(inputBox), IsForeclosed: isForeclosed, Guardian: formatAddr(wc.Guardian), WithdrawalOutputBuilder: formatAddr(wc.WithdrawalOutputBuilder), @@ -152,7 +151,7 @@ func printAppFields(p *printer, r *AppResult) { p.field("Deployment Block", fmt.Sprintf("%d", r.DeploymentBlock)) p.field("Executed Outputs", fmt.Sprintf("%d", r.ExecutedOutputs)) p.field("Consensus", fmt.Sprintf("%s (%s)", r.ConsensusAddress, r.ConsensusType)) - p.field("Data Availability", r.DataAvailability) + p.field("InputBox", r.InputBox) p.field("Foreclosed", formatBool(r.IsForeclosed)) // WithdrawalConfig is logically grouped — a zero guardian means // no foreclosure was configured on deploy, so other fields are diff --git a/cmd/cartesi-rollups-cli/root/contract/commitment.go b/cmd/cartesi-rollups-cli/root/contract/commitment.go index ce35527ad..19c97413f 100644 --- a/cmd/cartesi-rollups-cli/root/contract/commitment.go +++ b/cmd/cartesi-rollups-cli/root/contract/commitment.go @@ -48,29 +48,42 @@ func runCommitment(cmd *cobra.Command, args []string) error { return fmt.Errorf("bind ITournament: %w", err) } - commitment, err := caller.GetCommitment(cc.callOpts, [32]byte(commitmentHash)) + commitment, err := caller.CommitmentStanding(cc.callOpts, [32]byte(commitmentHash)) if err != nil { - return fmt.Errorf("GetCommitment: %w", err) + return fmt.Errorf("CommitmentStanding: %w", err) } - levelConsts, err := caller.TournamentLevelConstants(cc.callOpts) + descriptor, err := caller.TournamentDescriptor(cc.callOpts) if err != nil { - return fmt.Errorf("TournamentLevelConstants: %w", err) + return fmt.Errorf("TournamentDescriptor: %w", err) + } + maxLevel, err := cc.tournamentMaxLevel() + if err != nil { + return err + } + if descriptor.Level > maxLevel { + return fmt.Errorf("tournament level %d exceeds maximum level %d", descriptor.Level, maxLevel) } - levelName := "root" - if levelConsts.Level == levelConsts.MaxLevel { - levelName = "leaf" - } else if levelConsts.Level > 0 { - levelName = "inner" + blocksRemaining := uint64(0) + if commitment.ClockRunning && commitment.ClockDeadline > cc.blockNum { + blocksRemaining = commitment.ClockDeadline - cc.blockNum + } + claimer := "" + if commitment.Claimer != (common.Address{}) { + claimer = formatAddr(commitment.Claimer) } result := &CommitmentResult{ Commitment: formatHash([32]byte(commitmentHash)), Tournament: formatAddr(tournamentAddr), - TournamentLevel: fmt.Sprintf("%d/%d (%s)", levelConsts.Level, levelConsts.MaxLevel, levelName), - ClockAllowance: commitment.Clock.Allowance, - ClockStartBlock: commitment.Clock.StartInstant, + TournamentLevel: fmt.Sprintf("%d/%d (%s)", descriptor.Level, maxLevel, tournamentLevelName(descriptor.Level, descriptor.Kind)), + Joined: commitment.Joined, + Claimer: claimer, + ClockRunning: commitment.ClockRunning, + ClockAllowance: commitment.ClockAllowance, + ClockDeadline: commitment.ClockDeadline, + BlocksRemaining: blocksRemaining, FinalMachineHash: formatHash(commitment.FinalState), } @@ -82,12 +95,18 @@ func runCommitment(cmd *cobra.Command, args []string) error { p.withSection(fmt.Sprintf("Commitment %s", result.Commitment), func() { p.field("Tournament", fmt.Sprintf("%s (level %s)", result.Tournament, result.TournamentLevel)) + p.field("Joined", formatBool(result.Joined)) + if result.Claimer != "" { + p.field("Claimer", result.Claimer) + } + p.field("Clock Running", formatBool(result.ClockRunning)) p.field("Clock Allowance", - fmt.Sprintf("%d blocks remaining", result.ClockAllowance)) - if result.ClockStartBlock > 0 { - p.field("Clock Start", fmt.Sprintf("block %d", result.ClockStartBlock)) + fmt.Sprintf("%d blocks (raw allowance)", result.ClockAllowance)) + if result.ClockRunning { + p.field("Clock Deadline", fmt.Sprintf("block %d (inclusive)", result.ClockDeadline)) + p.field("Blocks Remaining", fmt.Sprintf("%d", result.BlocksRemaining)) } else { - p.field("Clock Start", "not started") + p.field("Clock Deadline", "paused") } p.field("Final Machine Hash", result.FinalMachineHash) }) diff --git a/cmd/cartesi-rollups-cli/root/contract/consensus.go b/cmd/cartesi-rollups-cli/root/contract/consensus.go index f162e9114..dea3c849d 100644 --- a/cmd/cartesi-rollups-cli/root/contract/consensus.go +++ b/cmd/cartesi-rollups-cli/root/contract/consensus.go @@ -126,27 +126,58 @@ func (c *chainClient) printDave(addr common.Address) error { p.field("Input Range", fmt.Sprintf("[%d, %d)", result.InputLowerBound, result.InputUpperBound)) p.field("Root Tournament", result.RootTournament) + printDaveSentries(p, result) + printDaveStaging(p, result) }) p.footer(c.blockNum, c.chainID, c.resolveTimestamp(c.blockNum)) return nil } -// printTournamentFinished renders the IsFinished field, distinguishing winner from no-winner. -// Note: IsFinished means the tournament has concluded, NOT that settle() can be called -// successfully — settle() will revert if there is no winner. +func printDaveSentries(p *printer, r *DaveConsensusResult) { + p.field("Sentry Manager", r.SentryManager) + p.field("Sentries", fmt.Sprintf("%d", r.NumSentries)) + for _, sentry := range r.Sentries { + p.field(fmt.Sprintf(" Sentry #%d", sentry.ID), sentry.Address) + } +} + +// printTournamentFinished renders the IsFinished field and distinguishes a +// winner from a failed tournament. A failed tournament has no result to stage. func printTournamentFinished(p *printer, r *DaveConsensusResult) { if !r.IsFinished { p.field("Tournament Finished", "no") return } - if r.HasWinner != nil && !*r.HasWinner { + if r.IsTournamentFailed { p.field("Tournament Finished", "yes (NO WINNER — all commitments eliminated)") return } if r.WinnerCommitment != "" { p.field("Tournament Finished", fmt.Sprintf("yes (winner: %s)", r.WinnerCommitment)) + if r.WinnerPostEpochMachineHash != "" { + p.field("Winner Machine Hash", r.WinnerPostEpochMachineHash) + } return } p.field("Tournament Finished", "yes") } + +func printDaveStaging(p *printer, r *DaveConsensusResult) { + p.field("Claim Staging Period", fmt.Sprintf("%d blocks", r.ClaimStagingPeriod)) + p.field("Tournament Result Staged", formatBool(r.IsTournamentResultStaged)) + if !r.IsTournamentResultStaged { + return + } + if r.StagingBlock != nil { + p.field("Staging Block", fmt.Sprintf("%d", *r.StagingBlock)) + } + p.field("Staged Machine Hash", r.StagedMachineHash) + p.field("Staged Outputs Root", r.StagedOutputsMerkleRoot) + if r.AllSentriesAgree != nil { + p.field("All Sentries Agree", formatBool(*r.AllSentriesAgree)) + } + if r.ClaimStagingPeriodOver != nil { + p.field("Staging Period Over", formatBool(*r.ClaimStagingPeriodOver)) + } +} diff --git a/cmd/cartesi-rollups-cli/root/contract/contract.go b/cmd/cartesi-rollups-cli/root/contract/contract.go index 1c0420e70..ea20f74db 100644 --- a/cmd/cartesi-rollups-cli/root/contract/contract.go +++ b/cmd/cartesi-rollups-cli/root/contract/contract.go @@ -45,8 +45,7 @@ directly from on-chain contracts and may not reflect finalized state. Verify critical information independently before acting on it. Supported Environment Variables: - CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum RPC endpoint URL - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS InputBox contract address (optional override)`, + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum RPC endpoint URL`, Run: runContract, } @@ -66,9 +65,6 @@ func init() { if f := command.Flags().Lookup("blockchain-http-endpoint"); f != nil { f.Hidden = false } - if f := command.Flags().Lookup("inputbox"); f != nil { - f.Hidden = false - } origHelpFunc(command, args) }) diff --git a/cmd/cartesi-rollups-cli/root/contract/format.go b/cmd/cartesi-rollups-cli/root/contract/format.go index 0d6442cee..0927dec06 100644 --- a/cmd/cartesi-rollups-cli/root/contract/format.go +++ b/cmd/cartesi-rollups-cli/root/contract/format.go @@ -109,6 +109,40 @@ func matchWinner(winner uint8) string { } } +const ( + matchPhaseUninitialized uint8 = iota + matchPhaseBisecting + matchPhaseReadyToSeal + matchPhaseSealed +) + +func matchPhaseName(phase uint8) string { + names := [...]string{"UNINITIALIZED", "BISECTING", "READY_TO_SEAL", "SEALED"} + if int(phase) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", phase) + } + return names[phase] +} + +func matchTimeoutOutcomeName(outcome uint8) string { + names := [...]string{"NONE", "ONE_WINS", "TWO_WINS", "ELIMINATE_BOTH"} + if int(outcome) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", outcome) + } + return names[outcome] +} + +func commitmentSideName(side uint8) string { + switch side { + case 0: + return "ONE" + case 1: + return "TWO" + default: + return fmt.Sprintf("UNKNOWN(%d)", side) + } +} + // formatBlockTime returns " (2006-01-02 15:04:05 UTC)" if ts > 0, else "". func formatBlockTime(ts uint64) string { if ts == 0 { diff --git a/cmd/cartesi-rollups-cli/root/contract/format_test.go b/cmd/cartesi-rollups-cli/root/contract/format_test.go index a5e528d9d..1b753cf74 100644 --- a/cmd/cartesi-rollups-cli/root/contract/format_test.go +++ b/cmd/cartesi-rollups-cli/root/contract/format_test.go @@ -132,6 +132,24 @@ func TestMatchWinner(t *testing.T) { assert.Equal(t, "UNKNOWN(99)", matchWinner(99)) } +func TestMatchEnumNamesMatchSolidityOrder(t *testing.T) { + assert.Equal(t, "UNINITIALIZED", matchPhaseName(0)) + assert.Equal(t, "BISECTING", matchPhaseName(1)) + assert.Equal(t, "READY_TO_SEAL", matchPhaseName(2)) + assert.Equal(t, "SEALED", matchPhaseName(3)) + assert.Equal(t, "UNKNOWN(4)", matchPhaseName(4)) + + assert.Equal(t, "NONE", matchTimeoutOutcomeName(0)) + assert.Equal(t, "ONE_WINS", matchTimeoutOutcomeName(1)) + assert.Equal(t, "TWO_WINS", matchTimeoutOutcomeName(2)) + assert.Equal(t, "ELIMINATE_BOTH", matchTimeoutOutcomeName(3)) + assert.Equal(t, "UNKNOWN(4)", matchTimeoutOutcomeName(4)) + + assert.Equal(t, "ONE", commitmentSideName(0)) + assert.Equal(t, "TWO", commitmentSideName(1)) + assert.Equal(t, "UNKNOWN(2)", commitmentSideName(2)) +} + func TestFormatBlockTime(t *testing.T) { assert.Equal(t, "", formatBlockTime(0)) // Unix timestamp 1700000000 = 2023-11-14 22:13:20 UTC diff --git a/cmd/cartesi-rollups-cli/root/contract/inputbox.go b/cmd/cartesi-rollups-cli/root/contract/inputbox.go index aafdcfecf..72b624c2c 100644 --- a/cmd/cartesi-rollups-cli/root/contract/inputbox.go +++ b/cmd/cartesi-rollups-cli/root/contract/inputbox.go @@ -7,9 +7,9 @@ import ( "fmt" "os" - "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" ) @@ -50,7 +50,6 @@ func runInputBox(cmd *cobra.Command, args []string) error { } // queryInputBox returns the InputBox state for the application. -// The InputBox address is auto-discovered from DaveConsensus or provided via --inputbox flag. func (c *chainClient) queryInputBox() (*InputBoxResult, error) { inputBoxAddr, err := c.resolveInputBoxAddress() if err != nil { @@ -81,43 +80,26 @@ func (c *chainClient) queryInputBox() (*InputBoxResult, error) { }, nil } -// resolveInputBoxAddress discovers the InputBox address. -// Priority: (1) --inputbox flag / env var, (2) DaveConsensus.GetInputBox(). -func (c *chainClient) resolveInputBoxAddress() (common.Address, error) { - // Try config (--inputbox flag or CARTESI_CONTRACTS_INPUT_BOX_ADDRESS env var). - addr, err := config.GetContractsInputBoxAddress() - if err == nil && addr != (common.Address{}) { - return addr, nil - } +type applicationInputBoxCaller interface { + GetInputBox(opts *bind.CallOpts) (common.Address, error) +} - // Try auto-discovery from DaveConsensus. - consensusAddr, cErr := c.getConsensusAddress() - if cErr != nil { - return common.Address{}, fmt.Errorf( - "cannot determine InputBox address: no --inputbox flag and consensus lookup failed: %w", - cErr) +// resolveInputBoxAddress reads the InputBox selected by this application. +func (c *chainClient) resolveInputBoxAddress() (common.Address, error) { + app, err := iapplication.NewIApplicationCaller(c.appAddr, c.eth) + if err != nil { + return common.Address{}, fmt.Errorf("bind IApplication for InputBox discovery: %w", err) } + return readApplicationInputBox(app, c.callOpts) +} - cType, _, cErr := c.detectConsensus(consensusAddr) - if cErr != nil { - return common.Address{}, fmt.Errorf( - "cannot determine InputBox address: no --inputbox flag and consensus detection failed: %w", - cErr) +func readApplicationInputBox(caller applicationInputBoxCaller, opts *bind.CallOpts) (common.Address, error) { + inputBox, err := caller.GetInputBox(opts) + if err != nil { + return common.Address{}, fmt.Errorf("IApplication.GetInputBox: %w", err) } - - if cType == consensusDave { - daveCaller, dErr := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) - if dErr != nil { - return common.Address{}, fmt.Errorf("bind IDaveConsensus for InputBox discovery: %w", dErr) - } - inputBox, dErr := daveCaller.GetInputBox(c.callOpts) - if dErr != nil { - return common.Address{}, fmt.Errorf("IDaveConsensus.GetInputBox: %w", dErr) - } - return inputBox, nil + if inputBox == (common.Address{}) { + return common.Address{}, fmt.Errorf("IApplication.GetInputBox returned the zero address") } - - return common.Address{}, fmt.Errorf( - "cannot auto-discover InputBox address for %s consensus; "+ - "use --inputbox flag or set CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", cType) + return inputBox, nil } diff --git a/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go b/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go new file mode 100644 index 000000000..7de56723a --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go @@ -0,0 +1,40 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type applicationInputBoxCallerStub struct { + inputBox common.Address + err error +} + +func (s applicationInputBoxCallerStub) GetInputBox(*bind.CallOpts) (common.Address, error) { + return s.inputBox, s.err +} + +func TestReadApplicationInputBox(t *testing.T) { + want := common.HexToAddress("0x1234567890123456789012345678901234567890") + got, err := readApplicationInputBox(applicationInputBoxCallerStub{inputBox: want}, nil) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestReadApplicationInputBoxRejectsZeroAddress(t *testing.T) { + _, err := readApplicationInputBox(applicationInputBoxCallerStub{}, nil) + require.ErrorContains(t, err, "zero address") +} + +func TestReadApplicationInputBoxReturnsCallError(t *testing.T) { + wantErr := errors.New("call failed") + _, err := readApplicationInputBox(applicationInputBoxCallerStub{err: wantErr}, nil) + require.ErrorIs(t, err, wantErr) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/match.go b/cmd/cartesi-rollups-cli/root/contract/match.go index 70939ac11..419e863b3 100644 --- a/cmd/cartesi-rollups-cli/root/contract/match.go +++ b/cmd/cartesi-rollups-cli/root/contract/match.go @@ -20,7 +20,7 @@ import ( var matchCmd = &cobra.Command{ Use: "match ", - Short: "Inspect a specific match's bisection state", + Short: "Inspect a specific tournament match", Args: cobra.ExactArgs(2), //nolint:mnd RunE: runMatch, } @@ -54,29 +54,31 @@ func runMatch(cmd *cobra.Command, args []string) error { return fmt.Errorf("bind ITournament: %w", err) } - matchState, err := caller.GetMatch(cc.callOpts, [32]byte(matchIDHash)) + bisecting, err := caller.BisectingMatch(cc.callOpts, [32]byte(matchIDHash)) if err != nil { - return fmt.Errorf("GetMatch: %w", err) - } - - cycle, err := caller.GetMatchCycle(cc.callOpts, [32]byte(matchIDHash)) - if err != nil { - return fmt.Errorf("GetMatchCycle: %w", err) + return fmt.Errorf("BisectingMatch: %w", err) } // Discover commitment hashes for this match from MatchCreated events. - // We need the two commitment hashes to call CanWinMatchByTimeout and to show players. + // The timeout view takes the full match ID. The event also identifies the players. commitOne, commitTwo, lookupErr := cc.findMatchCommitments( tournamentAddr, deployBlock, [32]byte(matchIDHash)) if lookupErr != nil { - slog.Warn("failed to look up match commitments", "error", lookupErr) + return fmt.Errorf("look up MatchCreated commitments: %w", lookupErr) } - // Attempt CanWinMatchByTimeout if we have both commitments. - var canWinByTimeout bool - if commitOne != ([32]byte{}) && commitTwo != ([32]byte{}) { - canWinByTimeout, _ = caller.CanWinMatchByTimeout(cc.callOpts, - itournament.MatchId{CommitmentOne: commitOne, CommitmentTwo: commitTwo}) + timeout, err := caller.ClassifyMatchTimeout(cc.callOpts, + itournament.MatchId{CommitmentOne: commitOne, CommitmentTwo: commitTwo}) + if err != nil { + return fmt.Errorf("ClassifyMatchTimeout: %w", err) + } + if timeout.ActualPhase != bisecting.ActualPhase { + return fmt.Errorf( + "inconsistent match phase at pinned block %d: BisectingMatch=%s, ClassifyMatchTimeout=%s", + cc.blockNum, + matchPhaseName(bisecting.ActualPhase), + matchPhaseName(timeout.ActualPhase), + ) } // Build commitment registry for player address resolution. @@ -93,19 +95,48 @@ func runMatch(cmd *cobra.Command, args []string) error { } result := &MatchResult{ - MatchIDHash: formatHash([32]byte(matchIDHash)), - Tournament: formatAddr(tournamentAddr), - CommitmentOne: formatHash(commitOne), - CommitmentTwo: formatHash(commitTwo), - PlayerOneAddr: registry.resolve(commitOne), - PlayerTwoAddr: registry.resolve(commitTwo), - CurrentHeight: matchState.CurrentHeight, - RunningLeafPosition: matchState.RunningLeafPosition.String(), - MachineCycle: cycle.String(), - CanWinByTimeout: canWinByTimeout, - LeftNode: formatHash(matchState.LeftNode), - RightNode: formatHash(matchState.RightNode), - OtherParent: formatHash(matchState.OtherParent), + MatchIDHash: formatHash([32]byte(matchIDHash)), + Tournament: formatAddr(tournamentAddr), + CommitmentOne: formatHash(commitOne), + CommitmentTwo: formatHash(commitTwo), + PlayerOneAddr: registry.resolve(commitOne), + PlayerTwoAddr: registry.resolve(commitTwo), + ActualPhase: matchPhaseName(bisecting.ActualPhase), + TimeoutOutcome: matchTimeoutOutcomeName(timeout.Outcome), + DeferredCharge: timeout.DeferredCharge, + } + + switch bisecting.ActualPhase { + case matchPhaseUninitialized: + // No phase payload exists. + case matchPhaseBisecting: + if err := populateBisectingMatchResult(result, bisecting.Value); err != nil { + return err + } + case matchPhaseReadyToSeal: + ready, rErr := caller.ReadyToSealMatch(cc.callOpts, [32]byte(matchIDHash)) + if rErr != nil { + return fmt.Errorf("ReadyToSealMatch: %w", rErr) + } + if ready.ActualPhase != bisecting.ActualPhase { + return inconsistentMatchPhaseError(cc.blockNum, "ReadyToSealMatch", ready.ActualPhase, bisecting.ActualPhase) + } + if err := populateReadyToSealMatchResult(result, ready.Value); err != nil { + return err + } + case matchPhaseSealed: + sealed, sErr := caller.SealedMatch(cc.callOpts, [32]byte(matchIDHash)) + if sErr != nil { + return fmt.Errorf("SealedMatch: %w", sErr) + } + if sealed.ActualPhase != bisecting.ActualPhase { + return inconsistentMatchPhaseError(cc.blockNum, "SealedMatch", sealed.ActualPhase, bisecting.ActualPhase) + } + if err := populateSealedMatchResult(result, sealed.Value); err != nil { + return err + } + default: + return fmt.Errorf("unknown match phase %d", bisecting.ActualPhase) } if jsonParam { @@ -113,20 +144,89 @@ func runMatch(cmd *cobra.Command, args []string) error { } p := &printer{w: os.Stdout} + printMatchResult(p, result) + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} + +func populateBisectingMatchResult(result *MatchResult, value itournament.ITournamentBisectingMatchView) error { + if value.SegmentStartPosition == nil || value.SegmentStartCycle == nil { + return errors.New("BisectingMatch returned nil position or cycle in BISECTING phase") + } + currentHeight := value.CurrentHeight + result.CurrentHeight = ¤tHeight + result.SegmentStartPosition = value.SegmentStartPosition.String() + result.SegmentStartCycle = value.SegmentStartCycle.String() + result.RevealingParent = formatHash(value.RevealingParent) + result.WaitingLeft = formatHash(value.WaitingLeft) + result.WaitingRight = formatHash(value.WaitingRight) + result.Responder = commitmentSideName(value.Responder) + return nil +} + +func populateReadyToSealMatchResult(result *MatchResult, value itournament.ITournamentReadyToSealMatchView) error { + if value.SegmentStartPosition == nil || value.SegmentStartCycle == nil { + return errors.New("ReadyToSealMatch returned nil position or cycle in READY_TO_SEAL phase") + } + result.SegmentStartPosition = value.SegmentStartPosition.String() + result.SegmentStartCycle = value.SegmentStartCycle.String() + result.RevealingParent = formatHash(value.RevealingParent) + result.WaitingLeft = formatHash(value.WaitingLeft) + result.WaitingRight = formatHash(value.WaitingRight) + result.Responder = commitmentSideName(value.Responder) + return nil +} + +func populateSealedMatchResult(result *MatchResult, value itournament.ITournamentSealedMatchView) error { + if value.DivergencePosition == nil || value.DivergenceCycle == nil { + return errors.New("SealedMatch returned nil position or cycle in SEALED phase") + } + result.AgreeState = formatHash(value.AgreeState) + result.DivergencePosition = value.DivergencePosition.String() + result.DivergenceCycle = value.DivergenceCycle.String() + result.FinalStateOne = formatHash(value.FinalStateOne) + result.FinalStateTwo = formatHash(value.FinalStateTwo) + return nil +} + +func inconsistentMatchPhaseError(block uint64, view string, got, want uint8) error { + return fmt.Errorf( + "inconsistent match phase at pinned block %d: %s=%s, expected %s", + block, + view, + matchPhaseName(got), + matchPhaseName(want), + ) +} + +func printMatchResult(p *printer, result *MatchResult) { p.withSection(fmt.Sprintf("Match %s", result.MatchIDHash), func() { p.field("Tournament", result.Tournament) printMatchPlayer(p, "Player One", result.CommitmentOne, result.PlayerOneAddr) printMatchPlayer(p, "Player Two", result.CommitmentTwo, result.PlayerTwoAddr) - p.field("Current Height", fmt.Sprintf("%d", result.CurrentHeight)) - p.field("Running Leaf Pos", result.RunningLeafPosition) - p.field("Machine Cycle", result.MachineCycle) - p.field("Can Win by Timeout", fmt.Sprintf("%t", result.CanWinByTimeout)) - p.field("Left Node", result.LeftNode) - p.field("Right Node", result.RightNode) - p.field("Other Parent", result.OtherParent) + p.field("Actual Phase", result.ActualPhase) + p.field("Timeout Outcome", result.TimeoutOutcome) + p.field("Deferred Charge", fmt.Sprintf("%d blocks", result.DeferredCharge)) + + switch result.ActualPhase { + case matchPhaseName(matchPhaseBisecting), matchPhaseName(matchPhaseReadyToSeal): + if result.CurrentHeight != nil { + p.field("Current Height", fmt.Sprintf("%d", *result.CurrentHeight)) + } + p.field("Segment Start Position", result.SegmentStartPosition) + p.field("Segment Start Cycle", result.SegmentStartCycle) + p.field("Revealing Parent", result.RevealingParent) + p.field("Waiting Left", result.WaitingLeft) + p.field("Waiting Right", result.WaitingRight) + p.field("Responder", result.Responder) + case matchPhaseName(matchPhaseSealed): + p.field("Agree State", result.AgreeState) + p.field("Divergence Position", result.DivergencePosition) + p.field("Divergence Cycle", result.DivergenceCycle) + p.field("Final State One", result.FinalStateOne) + p.field("Final State Two", result.FinalStateTwo) + } }) - p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) - return nil } func printMatchPlayer(p *printer, label, commitment, addr string) { @@ -154,6 +254,7 @@ func (c *chainClient) findMatchCommitments( } var commitOne, commitTwo [32]byte + found := false oracle := func(ctx context.Context, block uint64) (*big.Int, error) { opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} @@ -183,6 +284,7 @@ func (c *chainClient) findMatchCommitments( if ev.MatchIdHash == matchIDHash { commitOne = ev.One commitTwo = ev.Two + found = true return errFound } } @@ -195,5 +297,13 @@ func (c *chainClient) findMatchCommitments( if err != nil && !errors.Is(err, errFound) { return [32]byte{}, [32]byte{}, fmt.Errorf("find match commitments: %w", err) } + if !found { + return [32]byte{}, [32]byte{}, fmt.Errorf( + "MatchCreated event %s was not found between blocks %d and %d", + formatHash(matchIDHash), + deployBlock, + c.blockNum, + ) + } return commitOne, commitTwo, nil } diff --git a/cmd/cartesi-rollups-cli/root/contract/match_test.go b/cmd/cartesi-rollups-cli/root/contract/match_test.go new file mode 100644 index 000000000..05f9724c7 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/match_test.go @@ -0,0 +1,78 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPopulateBisectingMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentBisectingMatchView{ + RevealingParent: [32]byte{0x01}, + WaitingLeft: [32]byte{0x02}, + WaitingRight: [32]byte{0x03}, + SegmentStartPosition: big.NewInt(4), + SegmentStartCycle: big.NewInt(5), + CurrentHeight: 6, + Responder: 1, + } + + require.NoError(t, populateBisectingMatchResult(result, value)) + require.NotNil(t, result.CurrentHeight) + assert.Equal(t, uint64(6), *result.CurrentHeight) + assert.Equal(t, "4", result.SegmentStartPosition) + assert.Equal(t, "5", result.SegmentStartCycle) + assert.Equal(t, formatHash(value.RevealingParent), result.RevealingParent) + assert.Equal(t, formatHash(value.WaitingLeft), result.WaitingLeft) + assert.Equal(t, formatHash(value.WaitingRight), result.WaitingRight) + assert.Equal(t, "TWO", result.Responder) +} + +func TestPopulateReadyToSealMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentReadyToSealMatchView{ + RevealingParent: [32]byte{0x01}, + WaitingLeft: [32]byte{0x02}, + WaitingRight: [32]byte{0x03}, + SegmentStartPosition: big.NewInt(7), + SegmentStartCycle: big.NewInt(8), + Responder: 0, + } + + require.NoError(t, populateReadyToSealMatchResult(result, value)) + assert.Nil(t, result.CurrentHeight) + assert.Equal(t, "7", result.SegmentStartPosition) + assert.Equal(t, "8", result.SegmentStartCycle) + assert.Equal(t, "ONE", result.Responder) +} + +func TestPopulateSealedMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentSealedMatchView{ + AgreeState: [32]byte{0x01}, + DivergencePosition: big.NewInt(9), + DivergenceCycle: big.NewInt(10), + FinalStateOne: [32]byte{0x02}, + FinalStateTwo: [32]byte{0x03}, + } + + require.NoError(t, populateSealedMatchResult(result, value)) + assert.Equal(t, formatHash(value.AgreeState), result.AgreeState) + assert.Equal(t, "9", result.DivergencePosition) + assert.Equal(t, "10", result.DivergenceCycle) + assert.Equal(t, formatHash(value.FinalStateOne), result.FinalStateOne) + assert.Equal(t, formatHash(value.FinalStateTwo), result.FinalStateTwo) +} + +func TestMatchPayloadRejectsNilPositions(t *testing.T) { + assert.Error(t, populateBisectingMatchResult(&MatchResult{}, itournament.ITournamentBisectingMatchView{})) + assert.Error(t, populateReadyToSealMatchResult(&MatchResult{}, itournament.ITournamentReadyToSealMatchView{})) + assert.Error(t, populateSealedMatchResult(&MatchResult{}, itournament.ITournamentSealedMatchView{})) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/sentries_test.go b/cmd/cartesi-rollups-cli/root/contract/sentries_test.go new file mode 100644 index 000000000..b91de37fc --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/sentries_test.go @@ -0,0 +1,257 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "os" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" +) + +const getSentryByIDMethod = "getSentryById" + +type sentryInspectionRPC struct{ mock.Mock } + +func (m *sentryInspectionRPC) GetCode(_ context.Context, address common.Address, block rpc.BlockNumber) hexutil.Bytes { + args := m.Called(address, block) + return args.Get(0).([]byte) +} + +func (m *sentryInspectionRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +type sentryInspectionFixture struct { + manager common.Address + sentries []common.Address + count *big.Int + failMethod string + failID uint64 +} + +func newSentryInspectionClient(t *testing.T, fixture sentryInspectionFixture) (*chainClient, common.Address) { + t.Helper() + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x100") + block := rpc.BlockNumber(1234) + backend := &sentryInspectionRPC{} + backend.On("GetCode", address, block).Return([]byte{0x01}).Once() + expectCall := func(name string, inputs, outputs []any) { + t.Helper() + input, packErr := contractABI.Pack(name, inputs...) + require.NoError(t, packErr) + var encoded []byte + var callErr error + if name == fixture.failMethod && (name != getSentryByIDMethod || inputs[0].(*big.Int).Uint64() == fixture.failID) { + callErr = errors.New("RPC read failed") + } else { + encoded, packErr = contractABI.Methods[name].Outputs.Pack(outputs...) + require.NoError(t, packErr) + } + backend.On("Call", address, input, block).Return(encoded, callErr).Once() + } + if fixture.count == nil { + fixture.count = big.NewInt(int64(len(fixture.sentries))) + } + for _, call := range []struct { + name string + values []any + }{ + {"canStageTournamentResult", []any{false, false, false, big.NewInt(1), [32]byte{}, [32]byte{}}}, + {"getCurrentSealedEpoch", []any{ + big.NewInt(1), big.NewInt(2), big.NewInt(3), common.HexToAddress("0x200"), false, big.NewInt(0), [32]byte{}, [32]byte{}, + }}, + {"getDeploymentBlockNumber", []any{big.NewInt(100)}}, + {"getInputBox", []any{common.HexToAddress("0x300")}}, + {"getTournamentFactory", []any{common.HexToAddress("0x400")}}, + {"getClaimStagingPeriod", []any{big.NewInt(300)}}, + {"getSentryManager", []any{fixture.manager}}, + {"getNumberOfSentries", []any{fixture.count}}, + } { + expectCall(call.name, nil, call.values) + if call.name == fixture.failMethod { + break + } + } + if fixture.failMethod == "" || fixture.failMethod == getSentryByIDMethod { + for index, sentry := range fixture.sentries { + id := uint64(index + 1) + expectCall(getSentryByIDMethod, []any{new(big.Int).SetUint64(id)}, []any{sentry}) + if sentry == (common.Address{}) || (fixture.failMethod == getSentryByIDMethod && fixture.failID == id) { + break + } + } + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + t.Cleanup(func() { backend.AssertExpectations(t) }) + return &chainClient{ + eth: client, + callOpts: &bind.CallOpts{Context: t.Context(), BlockNumber: big.NewInt(int64(block))}, + blockNum: uint64(block), + chainID: 31337, + }, address +} + +func TestQueryDaveSentriesAtPinnedBlock(t *testing.T) { + manager := common.HexToAddress("0x500") + first := common.HexToAddress("0x501") + second := common.HexToAddress("0x502") + rotated := common.HexToAddress("0x503") + for _, test := range []struct { + name string + manager common.Address + sentries []common.Address + }{ + {name: "no sentries"}, + {name: "two sentries", manager: manager, sentries: []common.Address{first, second}}, + {name: "rotated first slot", manager: manager, sentries: []common.Address{rotated, second}}, + } { + t.Run(test.name, func(t *testing.T) { + client, address := newSentryInspectionClient(t, sentryInspectionFixture{manager: test.manager, sentries: test.sentries}) + result, err := client.queryDave(address) + require.NoError(t, err) + require.Equal(t, test.manager.Hex(), result.SentryManager) + require.EqualValues(t, len(test.sentries), result.NumSentries) + require.NotNil(t, result.Sentries) + require.Len(t, result.Sentries, len(test.sentries)) + for index, sentry := range test.sentries { + require.Equal(t, SentryResult{ID: uint64(index + 1), Address: sentry.Hex()}, result.Sentries[index]) + } + encoded, err := json.Marshal(result) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &fields)) + require.JSONEq(t, fmt.Sprintf("%q", test.manager.Hex()), string(fields["sentry_manager"])) + require.Equal(t, fmt.Sprint(len(test.sentries)), string(fields["num_sentries"])) + if len(test.sentries) == 0 { + require.Equal(t, "[]", string(fields["sentries"])) + } else { + var sentries []SentryResult + require.NoError(t, json.Unmarshal(fields["sentries"], &sentries)) + require.Equal(t, result.Sentries, sentries) + } + var text bytes.Buffer + printConsensusSummary(&printer{w: &text}, consensusResult{result: result}) + require.Contains(t, text.String(), "Sentry Manager "+test.manager.Hex()) + require.Contains(t, text.String(), "Sentries "+fmt.Sprint(len(test.sentries))) + for index, sentry := range test.sentries { + require.Contains(t, text.String(), fmt.Sprintf(" Sentry #%d %s", index+1, sentry.Hex())) + } + }) + } +} + +func TestQueryDaveRejectsIncompleteSentryRoster(t *testing.T) { + for _, test := range []struct { + name string + fixture sentryInspectionFixture + wantErr string + }{ + {name: "manager read fails", fixture: sentryInspectionFixture{failMethod: "getSentryManager"}, wantErr: "GetSentryManager"}, + {name: "count read fails", fixture: sentryInspectionFixture{failMethod: "getNumberOfSentries"}, wantErr: "GetNumberOfSentries"}, + { + name: "second member read fails", + fixture: sentryInspectionFixture{ + sentries: []common.Address{common.HexToAddress("0x501"), common.HexToAddress("0x502")}, + failMethod: getSentryByIDMethod, failID: 2, + }, + wantErr: "GetSentryById(2)", + }, + { + name: "missing member", + fixture: sentryInspectionFixture{sentries: []common.Address{common.HexToAddress("0x501"), {}}}, + wantErr: "GetSentryById(2): registered sentry has zero address", + }, + { + name: "count exceeds inspection limit", + fixture: sentryInspectionFixture{count: big.NewInt(10001)}, + wantErr: "number of sentries 10001 exceeds inspection limit 10000", + }, + { + name: "count exceeds uint64", + fixture: sentryInspectionFixture{count: new(big.Int).Lsh(big.NewInt(1), 64)}, + wantErr: "number of sentries", + }, + } { + t.Run(test.name, func(t *testing.T) { + client, address := newSentryInspectionClient(t, test.fixture) + result, err := client.queryDave(address) + require.ErrorContains(t, err, test.wantErr) + require.Nil(t, result, "a failed member read must not expose a partial roster") + }) + } +} + +func TestPrintDaveIncludesSentryRoster(t *testing.T) { + previousJSON := jsonParam + t.Cleanup(func() { jsonParam = previousJSON }) + manager := common.HexToAddress("0x500") + sentry := common.HexToAddress("0x501") + for _, asJSON := range []bool{false, true} { + t.Run(fmt.Sprintf("json=%t", asJSON), func(t *testing.T) { + client, address := newSentryInspectionClient(t, sentryInspectionFixture{manager: manager, sentries: []common.Address{sentry}}) + jsonParam = asJSON + output := captureSentryInspectionOutput(t, func() { require.NoError(t, client.printDave(address)) }) + if asJSON { + var result DaveConsensusResult + require.NoError(t, json.Unmarshal([]byte(output), &result)) + require.Equal(t, manager.Hex(), result.SentryManager) + require.EqualValues(t, 1, result.NumSentries) + require.Equal(t, []SentryResult{{ID: 1, Address: sentry.Hex()}}, result.Sentries) + return + } + require.Contains(t, output, "Sentry Manager "+manager.Hex()) + require.Contains(t, output, "Sentries 1") + require.Contains(t, output, " Sentry #1 "+sentry.Hex()) + }) + } +} + +func captureSentryInspectionOutput(t *testing.T, fn func()) string { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "stdout") + require.NoError(t, err) + defer file.Close() + previous := os.Stdout + os.Stdout = file + defer func() { os.Stdout = previous }() + fn() + _, err = file.Seek(0, io.SeekStart) + require.NoError(t, err) + output, err := io.ReadAll(file) + require.NoError(t, err) + return string(output) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/summary.go b/cmd/cartesi-rollups-cli/root/contract/summary.go index a54924594..97b7b995f 100644 --- a/cmd/cartesi-rollups-cli/root/contract/summary.go +++ b/cmd/cartesi-rollups-cli/root/contract/summary.go @@ -18,6 +18,8 @@ import ( "golang.org/x/sync/errgroup" ) +const daveConsensusContractName = "DaveConsensus" + var summaryCmd = &cobra.Command{ Use: "summary ", Short: "Full diagnostic snapshot (composes app + consensus + inputbox + root tournament)", @@ -238,6 +240,8 @@ func printConsensusSummary(p *printer, cr consensusResult) { printTournamentFinished(p, r) p.field("Current Sealed Epoch", fmt.Sprintf("%d", r.CurrentEpochNumber)) p.field("Root Tournament", r.RootTournament) + printDaveSentries(p, r) + printDaveStaging(p, r) }) } } @@ -246,7 +250,7 @@ func printConsensusSummary(p *printer, cr consensusResult) { func (c *chainClient) queryAuthority( addr common.Address, contractVersion string, ) (*AuthorityConsensusResult, error) { - if err := c.ensureContract(addr, "Authority"); err != nil { + if err := c.ensureContract(addr, consensusAuthority.String()); err != nil { return nil, err } caller, err := iauthority.NewIAuthorityCaller(addr, c.eth) @@ -296,7 +300,7 @@ func (c *chainClient) queryAuthority( } return &AuthorityConsensusResult{ - Type: "Authority", + Type: consensusAuthority.String(), Address: formatAddr(addr), Owner: formatAddr(owner), EpochLength: epochLength, @@ -311,7 +315,7 @@ func (c *chainClient) queryAuthority( func (c *chainClient) queryQuorum( addr common.Address, contractVersion string, ) (*QuorumConsensusResult, error) { - if err := c.ensureContract(addr, "Quorum"); err != nil { + if err := c.ensureContract(addr, consensusQuorum.String()); err != nil { return nil, err } caller, err := iquorum.NewIQuorumCaller(addr, c.eth) @@ -382,7 +386,7 @@ func (c *chainClient) queryQuorum( } return &QuorumConsensusResult{ - Type: "Quorum", + Type: consensusQuorum.String(), Address: formatAddr(addr), NumValidators: numVal, QuorumThreshold: threshold, @@ -399,7 +403,7 @@ func (c *chainClient) queryQuorum( func (c *chainClient) queryDave( addr common.Address, ) (*DaveConsensusResult, error) { - if err := c.ensureContract(addr, "DaveConsensus"); err != nil { + if err := c.ensureContract(addr, daveConsensusContractName); err != nil { return nil, err } caller, err := idaveconsensus.NewIDaveConsensusCaller(addr, c.eth) @@ -407,9 +411,9 @@ func (c *chainClient) queryDave( return nil, fmt.Errorf("bind IDaveConsensus: %w", err) } - settleInfo, err := caller.CanSettle(c.callOpts) + stageInfo, err := caller.CanStageTournamentResult(c.callOpts) if err != nil { - return nil, fmt.Errorf("CanSettle: %w", err) + return nil, fmt.Errorf("CanStageTournamentResult: %w", err) } sealed, err := caller.GetCurrentSealedEpoch(c.callOpts) @@ -450,25 +454,85 @@ func (c *chainClient) queryDave( if err != nil { return nil, fmt.Errorf("GetTournamentFactory: %w", err) } + stagingPeriodRaw, err := caller.GetClaimStagingPeriod(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetClaimStagingPeriod: %w", err) + } + stagingPeriod, err := safeUint64(stagingPeriodRaw, "claim staging period") + if err != nil { + return nil, err + } result := &DaveConsensusResult{ - Type: "DaveConsensus", - Address: formatAddr(addr), - InputBox: formatAddr(inputBox), - Factory: formatAddr(factory), - DeploymentBlock: deployBlock, - IsFinished: settleInfo.IsFinished, - CurrentEpochNumber: epochNumber, - InputLowerBound: inputLower, - InputUpperBound: inputUpper, - RootTournament: formatAddr(sealed.Tournament), + Type: daveConsensusContractName, + Address: formatAddr(addr), + InputBox: formatAddr(inputBox), + Factory: formatAddr(factory), + DeploymentBlock: deployBlock, + ClaimStagingPeriod: stagingPeriod, + IsFinished: stageInfo.IsFinished, + IsTournamentFailed: stageInfo.IsTournamentFailed, + IsTournamentResultStaged: sealed.IsTournamentResultStaged, + CurrentEpochNumber: epochNumber, + InputLowerBound: inputLower, + InputUpperBound: inputUpper, + RootTournament: formatAddr(sealed.Tournament), + } + sentryManager, err := caller.GetSentryManager(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetSentryManager: %w", err) + } + numSentriesRaw, err := caller.GetNumberOfSentries(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetNumberOfSentries: %w", err) + } + numSentries, err := safeUint64(numSentriesRaw, "number of sentries") + if err != nil { + return nil, err + } + // Bound the work for untrusted contract addresses. Never print a partial roster. + const maxSentries = 10000 + if numSentries > maxSentries { + return nil, fmt.Errorf("number of sentries %d exceeds inspection limit %d", numSentries, maxSentries) + } + result.SentryManager = formatAddr(sentryManager) + result.NumSentries = numSentries + result.Sentries = make([]SentryResult, 0, numSentries) + for id := uint64(1); id <= numSentries; id++ { + sentry, err := caller.GetSentryById(c.callOpts, new(big.Int).SetUint64(id)) + if err != nil { + return nil, fmt.Errorf("GetSentryById(%d): %w", id, err) + } + if sentry == (common.Address{}) { + return nil, fmt.Errorf("GetSentryById(%d): registered sentry has zero address", id) + } + result.Sentries = append(result.Sentries, SentryResult{ID: id, Address: formatAddr(sentry)}) } if result.IsFinished { - hasWinner := settleInfo.WinnerCommitment != [32]byte{} + hasWinner := !stageInfo.IsTournamentFailed && stageInfo.WinnerCommitment != [32]byte{} result.HasWinner = &hasWinner if hasWinner { - result.WinnerCommitment = formatHash(settleInfo.WinnerCommitment) + result.WinnerCommitment = formatHash(stageInfo.WinnerCommitment) + result.WinnerPostEpochMachineHash = formatHash(stageInfo.WinnerPostEpochMachineStateHash) + } + } + if sealed.IsTournamentResultStaged { + stagingBlock, err := safeUint64(sealed.StagingBlockNumber, "staging block") + if err != nil { + return nil, err + } + result.StagingBlock = &stagingBlock + result.StagedMachineHash = formatHash(sealed.StagedPostEpochMachineStateHash) + result.StagedOutputsMerkleRoot = formatHash(sealed.StagedPostEpochOutputsMerkleRoot) + + acceptInfo, err := caller.CanAcceptStagedTournamentResult(c.callOpts) + if err != nil { + return nil, fmt.Errorf("CanAcceptStagedTournamentResult: %w", err) } + allSentriesAgree := acceptInfo.DoAllSentriesAgreeWithStagedTournamentResult + claimStagingPeriodOver := acceptInfo.IsClaimStagingPeriodOver + result.AllSentriesAgree = &allSentriesAgree + result.ClaimStagingPeriodOver = &claimStagingPeriodOver } return result, nil } diff --git a/cmd/cartesi-rollups-cli/root/contract/tournament.go b/cmd/cartesi-rollups-cli/root/contract/tournament.go index f16d2c7b7..fae045a67 100644 --- a/cmd/cartesi-rollups-cli/root/contract/tournament.go +++ b/cmd/cartesi-rollups-cli/root/contract/tournament.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/imultileveltournamentfactory" "github.com/cartesi/rollups-node/pkg/contracts/itournament" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum" @@ -299,19 +300,23 @@ func (c *chainClient) queryTournament(addr common.Address) (*TournamentResult, e return nil, fmt.Errorf("bind ITournament: %w", err) } - levelConsts, err := caller.TournamentLevelConstants(c.callOpts) + descriptor, err := caller.TournamentDescriptor(c.callOpts) if err != nil { - return nil, fmt.Errorf("TournamentLevelConstants: %w", err) + return nil, fmt.Errorf("TournamentDescriptor: %w", err) } - - closed, err := caller.IsClosed(c.callOpts) + if descriptor.BaseCycle == nil || descriptor.BaseCycle.Sign() < 0 { + return nil, errors.New("TournamentDescriptor: invalid base cycle") + } + standing, err := caller.TournamentStanding(c.callOpts) if err != nil { - return nil, fmt.Errorf("IsClosed: %w", err) + return nil, fmt.Errorf("TournamentStanding: %w", err) } - - finished, err := caller.IsFinished(c.callOpts) + maxLevel, err := c.tournamentMaxLevel() if err != nil { - return nil, fmt.Errorf("IsFinished: %w", err) + return nil, err + } + if descriptor.Level > maxLevel { + return nil, fmt.Errorf("tournament level %d exceeds maximum level %d", descriptor.Level, maxLevel) } bondWei, err := caller.BondValue(c.callOpts) @@ -365,77 +370,166 @@ func (c *chainClient) queryTournament(addr common.Address) (*TournamentResult, e } result := &TournamentResult{ - Address: formatAddr(addr), - Level: levelConsts.Level, - MaxLevel: levelConsts.MaxLevel, - Log2Step: levelConsts.Log2step, - Height: levelConsts.Height, - Closed: closed, - Finished: finished, - BondWei: bondWei.String(), - BondETH: weiToETH(bondWei), - CommitmentsJoined: commitments, - MatchesCreated: matchesCreated, - MatchesAdvanced: matchesAdvanced, - MatchesDeleted: matchesDeleted, - InnerTournaments: inner, - } - - // Query finish details if tournament is finished. - if finished { - isFinished, finBlock, tErr := caller.TimeFinished(c.callOpts) - if tErr == nil && isFinished { - result.FinishedAtBlock = &finBlock + Address: formatAddr(addr), + Level: descriptor.Level, + MaxLevel: maxLevel, + Log2Step: descriptor.Log2Stride, + Height: descriptor.Height, + Kind: tournamentKindName(descriptor.Kind), + InitialMachineHash: formatHash(descriptor.InitialHash), + BaseCycle: descriptor.BaseCycle.String(), + StartBlock: descriptor.StartInstant, + Allowance: descriptor.Allowance, + BondWei: bondWei.String(), + BondETH: weiToETH(bondWei), + CommitmentsJoined: commitments, + MatchesCreated: matchesCreated, + MatchesAdvanced: matchesAdvanced, + MatchesDeleted: matchesDeleted, + InnerTournaments: inner, + } + + err = populateTournamentStandingResult(result, standing, func(commitment [32]byte) ([32]byte, error) { + commitmentStanding, cErr := caller.CommitmentStanding(c.callOpts, commitment) + if cErr != nil { + return [32]byte{}, fmt.Errorf("CommitmentStanding: %w", cErr) } + return commitmentStanding.FinalState, nil + }) + if err != nil { + return nil, err + } - isRoot := levelConsts.Level == 0 - tFinished, hasWin, winner, finalState, tErr := c.tournamentResult(caller, isRoot) - if tErr == nil && tFinished { - result.HasWinner = &hasWin - if hasWin { - result.WinnerCommitment = formatHash(winner) - if finalState != [32]byte{} { - result.FinalMachineHash = formatHash(finalState) - } - } - } + return result, nil +} + +type commitmentFinalStateReader func(commitment [32]byte) ([32]byte, error) + +func populateTournamentStandingResult( + result *TournamentResult, + standing itournament.ITournamentTournamentStandingView, + readCommitmentFinalState commitmentFinalStateReader, +) error { + result.Standing = tournamentStandingName(standing.Standing) + result.Closed = !standing.AcceptsJoins + result.Finished = standing.FinishedAt != 0 + + if result.Level > 0 { + canEliminate := standing.Standing == tournamentStandingInnerEliminableNoWinner || + standing.Standing == tournamentStandingInnerEliminableWinnerExpired + result.CanBeEliminated = &canEliminate + } + if !result.Finished { + return nil } - // CanBeEliminated for non-root tournaments. - if levelConsts.Level > 0 { - canElim, cErr := caller.CanBeEliminated(c.callOpts) - if cErr == nil { - result.CanBeEliminated = &canElim + finishedAt := standing.FinishedAt + result.FinishedAtBlock = &finishedAt + hasWinner := standing.HasCandidate + result.HasWinner = &hasWinner + if standing.WinnerExpiresAt != 0 { + winnerExpiresAt := standing.WinnerExpiresAt + result.WinnerExpiresAt = &winnerExpiresAt + } + if !hasWinner { + return nil + } + + result.WinnerCommitment = formatHash(standing.Candidate) + finalState := standing.FinalState + if finalState == ([32]byte{}) && standing.Standing == tournamentStandingInnerEliminableWinnerExpired { + var err error + finalState, err = readCommitmentFinalState(standing.Candidate) + if err != nil { + return err } } + if finalState != ([32]byte{}) { + result.FinalMachineHash = formatHash(finalState) + } + return nil +} + +const ( + tournamentStandingMatchesActive uint8 = iota + tournamentStandingAwaitingClosure + tournamentStandingRootWinner + tournamentStandingRootFailed + tournamentStandingInnerWinner + tournamentStandingInnerEliminableNoWinner + tournamentStandingInnerEliminableWinnerExpired +) + +const ( + tournamentKindLeaf uint8 = iota + tournamentKindNonLeaf +) + +func tournamentStandingName(standing uint8) string { + names := [...]string{ + "MATCHES_ACTIVE", "AWAITING_CLOSURE", "ROOT_WINNER", "ROOT_FAILED", + "INNER_WINNER", "INNER_ELIMINABLE_NO_WINNER", "INNER_ELIMINABLE_WINNER_EXPIRED", + } + if int(standing) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", standing) + } + return names[standing] +} - return result, nil +func tournamentKindName(kind uint8) string { + switch kind { + case tournamentKindLeaf: + return "LEAF" + case tournamentKindNonLeaf: + return "NON_LEAF" + default: + return fmt.Sprintf("UNKNOWN(%d)", kind) + } } -// tournamentResult handles ArbitrationResult (root) or InnerTournamentWinner (non-root) -// with TournamentFailedNoWinner revert detection. -func (c *chainClient) tournamentResult( - caller *itournament.ITournamentCaller, - isRoot bool, -) (finished bool, hasWinner bool, winner [32]byte, finalState [32]byte, err error) { - if !isRoot { - isFinished, _, winnerCommitment, _, iErr := caller.InnerTournamentWinner(c.callOpts) - if iErr != nil { - return false, false, [32]byte{}, [32]byte{}, iErr +func tournamentLevelName(level uint64, kind uint8) string { + switch kind { + case tournamentKindLeaf: + return "leaf" + case tournamentKindNonLeaf: + if level == 0 { + return "root" } - return isFinished, isFinished && winnerCommitment != [32]byte{}, - winnerCommitment, [32]byte{}, nil + return "inner" + default: + return fmt.Sprintf("unknown-kind-%d", kind) } +} - result, err := caller.ArbitrationResult(c.callOpts) +func maxLevelFromCount(levelCount uint64) (uint64, error) { + if levelCount == 0 { + return 0, errors.New("tournament level count is zero") + } + return levelCount - 1, nil +} + +func (c *chainClient) tournamentMaxLevel() (uint64, error) { + consensusAddr, err := c.getConsensusAddress() if err != nil { - if ethutil.IsCustomError(err, itournament.ITournamentMetaData, "TournamentFailedNoWinner") { - return true, false, [32]byte{}, [32]byte{}, nil - } - return false, false, [32]byte{}, [32]byte{}, err + return 0, fmt.Errorf("get consensus address: %w", err) + } + dave, err := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) + if err != nil { + return 0, fmt.Errorf("bind IDaveConsensus: %w", err) + } + factoryAddr, err := dave.GetTournamentFactory(c.callOpts) + if err != nil { + return 0, fmt.Errorf("GetTournamentFactory: %w", err) } - hasWin := result.WinnerCommitment != [32]byte{} - return result.Finished, hasWin, result.WinnerCommitment, result.FinalState, nil + factory, err := imultileveltournamentfactory.NewIMultiLevelTournamentFactoryCaller(factoryAddr, c.eth) + if err != nil { + return 0, fmt.Errorf("bind IMultiLevelTournamentFactory: %w", err) + } + levelCount, err := factory.TournamentLevelCount(c.callOpts) + if err != nil { + return 0, fmt.Errorf("TournamentLevelCount: %w", err) + } + return maxLevelFromCount(levelCount) } // commitmentRegistry maps commitment hashes to submitter addresses. @@ -465,20 +559,23 @@ type rawCommitmentJoined struct { } type rawMatchCreated struct { - matchIDHash [32]byte - one [32]byte - two [32]byte - leftOfTwo [32]byte - blockNumber uint64 - txHash common.Hash + matchIDHash [32]byte + one [32]byte + two [32]byte + leftOfTwo [32]byte + eliminableAt uint64 + blockNumber uint64 + txHash common.Hash } type rawMatchAdvanced struct { - matchIDHash [32]byte - otherParent [32]byte - leftNode [32]byte - blockNumber uint64 - txHash common.Hash + matchIDHash [32]byte + otherParent [32]byte + leftNode [32]byte + segmentStartPosition *big.Int + eliminableAt uint64 + blockNumber uint64 + txHash common.Hash } type rawMatchDeleted struct { @@ -550,12 +647,13 @@ func (c *chainClient) fetchTournamentEvents( return false, pErr } events.matchesCreated = append(events.matchesCreated, rawMatchCreated{ - matchIDHash: ev.MatchIdHash, - one: ev.One, - two: ev.Two, - leftOfTwo: ev.LeftOfTwo, - blockNumber: log.BlockNumber, - txHash: log.TxHash, + matchIDHash: ev.MatchIdHash, + one: ev.One, + two: ev.Two, + leftOfTwo: ev.LeftOfTwo, + eliminableAt: ev.EliminableAt, + blockNumber: log.BlockNumber, + txHash: log.TxHash, }) return false, nil }, @@ -578,11 +676,13 @@ func (c *chainClient) fetchTournamentEvents( return false, pErr } events.matchesAdvanced = append(events.matchesAdvanced, rawMatchAdvanced{ - matchIDHash: ev.MatchIdHash, - otherParent: ev.OtherParent, - leftNode: ev.LeftNode, - blockNumber: log.BlockNumber, - txHash: log.TxHash, + matchIDHash: ev.MatchIdHash, + otherParent: ev.OtherParent, + leftNode: ev.LeftNode, + segmentStartPosition: ev.SegmentStartPosition, + eliminableAt: ev.EliminableAt, + blockNumber: log.BlockNumber, + txHash: log.TxHash, }) return false, nil }, @@ -917,6 +1017,7 @@ func formatMatchEvents( PlayerOneAddr: registry.resolve(r.one), PlayerTwoAddr: registry.resolve(r.two), LeftOfTwo: formatHash(r.leftOfTwo), + EliminableAt: r.eliminableAt, BlockNumber: r.blockNumber, TxHash: r.txHash.Hex(), } @@ -948,12 +1049,18 @@ func formatAdvanceEvents(raw []rawMatchAdvanced) []MatchAdvanceEvent { } out := make([]MatchAdvanceEvent, len(raw)) for i, r := range raw { + segmentStartPosition := "" + if r.segmentStartPosition != nil { + segmentStartPosition = r.segmentStartPosition.String() + } out[i] = MatchAdvanceEvent{ - MatchIDHash: formatHash(r.matchIDHash), - OtherParent: formatHash(r.otherParent), - LeftNode: formatHash(r.leftNode), - BlockNumber: r.blockNumber, - TxHash: r.txHash.Hex(), + MatchIDHash: formatHash(r.matchIDHash), + OtherParent: formatHash(r.otherParent), + LeftNode: formatHash(r.leftNode), + SegmentStartPosition: segmentStartPosition, + EliminableAt: r.eliminableAt, + BlockNumber: r.blockNumber, + TxHash: r.txHash.Hex(), } } return out @@ -972,6 +1079,14 @@ func printTournamentBasic(p *printer, r *TournamentResult) { p.withSection(fmt.Sprintf("%s Tournament %s (level %d/%d)", levelName, r.Address, r.Level, r.MaxLevel), func() { p.field("Status", tournamentStatus(r.Closed, r.Finished)) + p.field("Standing", r.Standing) + p.field("Kind", r.Kind) + p.field("Initial Machine Hash", r.InitialMachineHash) + p.field("Base Cycle", r.BaseCycle) + p.field("Log2 Stride", fmt.Sprintf("%d", r.Log2Step)) + p.field("Height", fmt.Sprintf("%d", r.Height)) + p.field("Start Block", fmt.Sprintf("%d", r.StartBlock)) + p.field("Allowance", fmt.Sprintf("%d blocks", r.Allowance)) if r.Finished && r.FinishedAtBlock != nil { p.field("Finished", fmt.Sprintf("yes (block %d)", *r.FinishedAtBlock)) } @@ -989,6 +1104,9 @@ func printTournamentBasic(p *printer, r *TournamentResult) { p.field("Winner", "NONE (all commitments eliminated)") } } + if r.WinnerExpiresAt != nil { + p.field("Winner Expires At", fmt.Sprintf("block %d (inclusive)", *r.WinnerExpiresAt)) + } p.field("Bond", r.BondETH) p.field("Commitments Joined", fmt.Sprintf("%d", r.CommitmentsJoined)) p.field("Matches Created", fmt.Sprintf("%d", r.MatchesCreated)) @@ -1029,6 +1147,7 @@ func printTournamentEvents(p *printer, r *TournamentResult) { twoInfo += fmt.Sprintf(" (%s)", m.PlayerTwoAddr) } p.field("Player Two", twoInfo) + p.field("Eliminable At", fmt.Sprintf("block %d (inclusive)", m.EliminableAt)) p.field("Block", fmt.Sprintf("%d", m.BlockNumber)) p.field("Tx", m.TxHash) if m.DeletionReason != "" { @@ -1046,6 +1165,8 @@ func printTournamentEvents(p *printer, r *TournamentResult) { p.withSection("Match Advances:", func() { for i, a := range r.Advances { p.withSection(fmt.Sprintf("[%d] Match %s", i+1, a.MatchIDHash), func() { + p.field("Segment Start Position", a.SegmentStartPosition) + p.field("Eliminable At", fmt.Sprintf("block %d (inclusive)", a.EliminableAt)) p.field("Block", fmt.Sprintf("%d", a.BlockNumber)) p.field("Tx", a.TxHash) }) diff --git a/cmd/cartesi-rollups-cli/root/contract/tournament_test.go b/cmd/cartesi-rollups-cli/root/contract/tournament_test.go index 3d4facd3d..921911bec 100644 --- a/cmd/cartesi-rollups-cli/root/contract/tournament_test.go +++ b/cmd/cartesi-rollups-cli/root/contract/tournament_test.go @@ -4,8 +4,11 @@ package contract import ( + "errors" + "math/big" "testing" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" ) @@ -80,12 +83,13 @@ func TestFormatMatchEvents(t *testing.T) { created := []rawMatchCreated{ { - matchIDHash: matchID, - one: commitOne, - two: commitTwo, - leftOfTwo: [32]byte{0x33}, - blockNumber: 100, - txHash: common.HexToHash("0xC1"), + matchIDHash: matchID, + one: commitOne, + two: commitTwo, + leftOfTwo: [32]byte{0x33}, + eliminableAt: 120, + blockNumber: 100, + txHash: common.HexToHash("0xC1"), }, { matchIDHash: matchID2, @@ -117,6 +121,7 @@ func TestFormatMatchEvents(t *testing.T) { assert.Equal(t, formatHash(matchID), m0.MatchIDHash) assert.Equal(t, "0x1111111111111111111111111111111111111111", m0.PlayerOneAddr) assert.Equal(t, "0x2222222222222222222222222222222222222222", m0.PlayerTwoAddr) + assert.Equal(t, uint64(120), m0.EliminableAt) assert.Equal(t, "TIMEOUT", m0.DeletionReason) assert.Equal(t, "ONE", m0.Winner) assert.NotNil(t, m0.DeletionBlock) @@ -184,11 +189,13 @@ func TestFormatAdvanceEvents(t *testing.T) { raw := []rawMatchAdvanced{ { - matchIDHash: [32]byte{0xAA}, - otherParent: [32]byte{0xBB}, - leftNode: [32]byte{0xCC}, - blockNumber: 100, - txHash: common.HexToHash("0xDD"), + matchIDHash: [32]byte{0xAA}, + otherParent: [32]byte{0xBB}, + leftNode: [32]byte{0xCC}, + segmentStartPosition: big.NewInt(42), + eliminableAt: 120, + blockNumber: 100, + txHash: common.HexToHash("0xDD"), }, { matchIDHash: [32]byte{0xEE}, @@ -204,8 +211,165 @@ func TestFormatAdvanceEvents(t *testing.T) { assert.Equal(t, formatHash(raw[0].matchIDHash), result[0].MatchIDHash) assert.Equal(t, formatHash(raw[0].otherParent), result[0].OtherParent) assert.Equal(t, formatHash(raw[0].leftNode), result[0].LeftNode) + assert.Equal(t, "42", result[0].SegmentStartPosition) + assert.Equal(t, uint64(120), result[0].EliminableAt) assert.Equal(t, uint64(100), result[0].BlockNumber) assert.Equal(t, raw[0].txHash.Hex(), result[0].TxHash) assert.Equal(t, uint64(200), result[1].BlockNumber) } + +func TestTournamentEnumNamesMatchSolidityOrder(t *testing.T) { + assert.Equal(t, "MATCHES_ACTIVE", tournamentStandingName(0)) + assert.Equal(t, "AWAITING_CLOSURE", tournamentStandingName(1)) + assert.Equal(t, "ROOT_WINNER", tournamentStandingName(2)) + assert.Equal(t, "ROOT_FAILED", tournamentStandingName(3)) + assert.Equal(t, "INNER_WINNER", tournamentStandingName(4)) + assert.Equal(t, "INNER_ELIMINABLE_NO_WINNER", tournamentStandingName(5)) + assert.Equal(t, "INNER_ELIMINABLE_WINNER_EXPIRED", tournamentStandingName(6)) + assert.Equal(t, "UNKNOWN(7)", tournamentStandingName(7)) + + assert.Equal(t, "LEAF", tournamentKindName(0)) + assert.Equal(t, "NON_LEAF", tournamentKindName(1)) + assert.Equal(t, "UNKNOWN(2)", tournamentKindName(2)) + assert.Equal(t, "root", tournamentLevelName(0, tournamentKindNonLeaf)) + assert.Equal(t, "inner", tournamentLevelName(1, tournamentKindNonLeaf)) + assert.Equal(t, "leaf", tournamentLevelName(2, tournamentKindLeaf)) +} + +func TestMaxLevelFromCount(t *testing.T) { + maxLevel, err := maxLevelFromCount(1) + assert.NoError(t, err) + assert.Equal(t, uint64(0), maxLevel) + + maxLevel, err = maxLevelFromCount(3) + assert.NoError(t, err) + assert.Equal(t, uint64(2), maxLevel) + + _, err = maxLevelFromCount(0) + assert.EqualError(t, err, "tournament level count is zero") +} + +func TestPopulateTournamentStandingResult(t *testing.T) { + candidate := [32]byte{0x11} + directFinalState := [32]byte{0x22} + recoveredFinalState := [32]byte{0x33} + + tests := []struct { + name string + level uint64 + standing itournament.ITournamentTournamentStandingView + wantClosed bool + wantFinished bool + wantFinishedAt *uint64 + wantHasWinner *bool + wantWinner string + wantFinalState string + wantWinnerExpiresAt *uint64 + wantCanBeEliminated *bool + wantFinalStateReadCount int + }{ + { + name: "active root", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingMatchesActive, AcceptsJoins: true, + }, + }, + { + name: "root winner", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingRootWinner, HasCandidate: true, + Candidate: candidate, FinalState: directFinalState, FinishedAt: 42, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(42), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(directFinalState), + }, + { + name: "failed root", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingRootFailed, FinishedAt: 43, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(43), + wantHasWinner: boolPtr(false), + }, + { + name: "inner winner", + level: 1, + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerWinner, HasCandidate: true, + Candidate: candidate, FinalState: directFinalState, FinishedAt: 44, WinnerExpiresAt: 60, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(44), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(directFinalState), + wantWinnerExpiresAt: uint64Ptr(60), + wantCanBeEliminated: boolPtr(false), + }, + { + name: "expired inner winner", + level: 1, + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerEliminableWinnerExpired, HasCandidate: true, + Candidate: candidate, FinishedAt: 45, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(45), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(recoveredFinalState), + wantCanBeEliminated: boolPtr(true), + wantFinalStateReadCount: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := &TournamentResult{Level: test.level} + readCount := 0 + err := populateTournamentStandingResult(result, test.standing, func(got [32]byte) ([32]byte, error) { + readCount++ + assert.Equal(t, candidate, got) + return recoveredFinalState, nil + }) + + assert.NoError(t, err) + assert.Equal(t, test.wantClosed, result.Closed) + assert.Equal(t, test.wantFinished, result.Finished) + assert.Equal(t, test.wantFinishedAt, result.FinishedAtBlock) + assert.Equal(t, test.wantHasWinner, result.HasWinner) + assert.Equal(t, test.wantWinner, result.WinnerCommitment) + assert.Equal(t, test.wantFinalState, result.FinalMachineHash) + assert.Equal(t, test.wantWinnerExpiresAt, result.WinnerExpiresAt) + assert.Equal(t, test.wantCanBeEliminated, result.CanBeEliminated) + assert.Equal(t, test.wantFinalStateReadCount, readCount) + }) + } +} + +func TestPopulateTournamentStandingResultReturnsFinalStateError(t *testing.T) { + wantErr := errors.New("read failed") + result := &TournamentResult{Level: 1} + err := populateTournamentStandingResult(result, itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, + Candidate: [32]byte{1}, + FinishedAt: 10, + }, func([32]byte) ([32]byte, error) { + return [32]byte{}, wantErr + }) + assert.ErrorIs(t, err, wantErr) +} + +func uint64Ptr(value uint64) *uint64 { return &value } + +func boolPtr(value bool) *bool { return &value } diff --git a/cmd/cartesi-rollups-cli/root/contract/types.go b/cmd/cartesi-rollups-cli/root/contract/types.go index 56f010865..1b2b664e8 100644 --- a/cmd/cartesi-rollups-cli/root/contract/types.go +++ b/cmd/cartesi-rollups-cli/root/contract/types.go @@ -14,7 +14,7 @@ type AppResult struct { ExecutedOutputs uint64 `json:"executed_outputs"` ConsensusAddress string `json:"consensus_address"` ConsensusType string `json:"consensus_type"` - DataAvailability string `json:"data_availability"` + InputBox string `json:"input_box"` IsForeclosed bool `json:"is_foreclosed"` Guardian string `json:"guardian"` WithdrawalOutputBuilder string `json:"withdrawal_output_builder"` @@ -54,18 +54,38 @@ type QuorumConsensusResult struct { // tournament). Epochs 0..CurrentEpochNumber-1 have been settled; epoch CurrentEpochNumber // is sealed but not yet settled. type DaveConsensusResult struct { - Type string `json:"type"` - Address string `json:"address"` - InputBox string `json:"inputbox"` - Factory string `json:"factory"` - DeploymentBlock uint64 `json:"deployment_block"` - IsFinished bool `json:"is_finished"` - HasWinner *bool `json:"has_winner,omitempty"` - WinnerCommitment string `json:"winner_commitment,omitempty"` - CurrentEpochNumber uint64 `json:"current_epoch_number"` - InputLowerBound uint64 `json:"input_lower_bound"` - InputUpperBound uint64 `json:"input_upper_bound"` - RootTournament string `json:"root_tournament"` + Type string `json:"type"` + Address string `json:"address"` + InputBox string `json:"inputbox"` + Factory string `json:"factory"` + DeploymentBlock uint64 `json:"deployment_block"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + IsFinished bool `json:"is_finished"` + IsTournamentFailed bool `json:"is_tournament_failed"` + IsTournamentResultStaged bool `json:"is_tournament_result_staged"` + HasWinner *bool `json:"has_winner,omitempty"` + WinnerCommitment string `json:"winner_commitment,omitempty"` + WinnerPostEpochMachineHash string `json:"winner_post_epoch_machine_hash,omitempty"` + CurrentEpochNumber uint64 `json:"current_epoch_number"` + InputLowerBound uint64 `json:"input_lower_bound"` + InputUpperBound uint64 `json:"input_upper_bound"` + RootTournament string `json:"root_tournament"` + StagingBlock *uint64 `json:"staging_block,omitempty"` + StagedMachineHash string `json:"staged_machine_hash,omitempty"` + StagedOutputsMerkleRoot string `json:"staged_outputs_merkle_root,omitempty"` + AllSentriesAgree *bool `json:"all_sentries_agree,omitempty"` + ClaimStagingPeriodOver *bool `json:"claim_staging_period_over,omitempty"` + + SentryManager string `json:"sentry_manager"` + NumSentries uint64 `json:"num_sentries"` + Sentries []SentryResult `json:"sentries"` +} + +// SentryResult identifies a sentry slot and its address at the queried block. +// IDs start at one and remain unchanged when the manager rotates a sentry. +type SentryResult struct { + ID uint64 `json:"id"` + Address string `json:"address"` } // InputBoxResult is the JSON output for InputBox state. @@ -91,6 +111,7 @@ type MatchEvent struct { PlayerOneAddr string `json:"player_one_addr,omitempty"` PlayerTwoAddr string `json:"player_two_addr,omitempty"` LeftOfTwo string `json:"left_of_two"` + EliminableAt uint64 `json:"eliminable_at"` BlockNumber uint64 `json:"block_number"` TxHash string `json:"tx_hash"` DeletionReason string `json:"deletion_reason,omitempty"` @@ -102,39 +123,48 @@ type MatchEvent struct { // MatchAdvanceEvent is a match advance (bisection step) event. type MatchAdvanceEvent struct { - MatchIDHash string `json:"match_id_hash"` - OtherParent string `json:"other_parent"` - LeftNode string `json:"left_node"` - BlockNumber uint64 `json:"block_number"` - TxHash string `json:"tx_hash"` + MatchIDHash string `json:"match_id_hash"` + OtherParent string `json:"other_parent"` + LeftNode string `json:"left_node"` + SegmentStartPosition string `json:"segment_start_position"` + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash string `json:"tx_hash"` } // TournamentResult is the JSON output for a tournament. type TournamentResult struct { - Address string `json:"address"` - Level uint64 `json:"level"` - MaxLevel uint64 `json:"max_level"` - Log2Step uint64 `json:"log2step"` - Height uint64 `json:"height"` - Closed bool `json:"closed"` - Finished bool `json:"finished"` - FinishedAtBlock *uint64 `json:"finished_at_block,omitempty"` - HasWinner *bool `json:"has_winner,omitempty"` - WinnerCommitment string `json:"winner_commitment,omitempty"` - WinnerAddress string `json:"winner_address,omitempty"` - FinalMachineHash string `json:"final_machine_hash,omitempty"` - BondWei string `json:"bond_wei"` - BondETH string `json:"bond_eth"` - CommitmentsJoined uint64 `json:"commitments_joined"` - MatchesCreated uint64 `json:"matches_created"` - MatchesAdvanced uint64 `json:"matches_advanced"` - MatchesDeleted uint64 `json:"matches_deleted"` - InnerTournaments uint64 `json:"inner_tournaments"` - CanBeEliminated *bool `json:"can_be_eliminated,omitempty"` - Commitments []CommitmentEvent `json:"commitments,omitempty"` - Matches []MatchEvent `json:"matches,omitempty"` - Advances []MatchAdvanceEvent `json:"advances,omitempty"` - Children []*TournamentResult `json:"children,omitempty"` + Address string `json:"address"` + Level uint64 `json:"level"` + MaxLevel uint64 `json:"max_level"` + Log2Step uint64 `json:"log2step"` + Height uint64 `json:"height"` + Kind string `json:"kind"` + InitialMachineHash string `json:"initial_machine_hash"` + BaseCycle string `json:"base_cycle"` + StartBlock uint64 `json:"start_block"` + Allowance uint64 `json:"allowance"` + Standing string `json:"standing"` + Closed bool `json:"closed"` + Finished bool `json:"finished"` + FinishedAtBlock *uint64 `json:"finished_at_block,omitempty"` + HasWinner *bool `json:"has_winner,omitempty"` + WinnerCommitment string `json:"winner_commitment,omitempty"` + WinnerAddress string `json:"winner_address,omitempty"` + FinalMachineHash string `json:"final_machine_hash,omitempty"` + BondWei string `json:"bond_wei"` + BondETH string `json:"bond_eth"` + CommitmentsJoined uint64 `json:"commitments_joined"` + MatchesCreated uint64 `json:"matches_created"` + MatchesAdvanced uint64 `json:"matches_advanced"` + MatchesDeleted uint64 `json:"matches_deleted"` + InnerTournaments uint64 `json:"inner_tournaments"` + CanBeEliminated *bool `json:"can_be_eliminated,omitempty"` + WinnerExpiresAt *uint64 `json:"winner_expires_at,omitempty"` + Commitments []CommitmentEvent `json:"commitments,omitempty"` + Matches []MatchEvent `json:"matches,omitempty"` + Advances []MatchAdvanceEvent `json:"advances,omitempty"` + Children []*TournamentResult `json:"children,omitempty"` } // ClaimEvent is a claim event in the epoch history. @@ -200,24 +230,36 @@ type CommitmentResult struct { Commitment string `json:"commitment"` Tournament string `json:"tournament"` TournamentLevel string `json:"tournament_level"` + Joined bool `json:"joined"` + Claimer string `json:"claimer,omitempty"` + ClockRunning bool `json:"clock_running"` ClockAllowance uint64 `json:"clock_allowance"` - ClockStartBlock uint64 `json:"clock_start_block"` + ClockDeadline uint64 `json:"clock_deadline"` + BlocksRemaining uint64 `json:"blocks_remaining"` FinalMachineHash string `json:"final_machine_hash"` } // MatchResult is the JSON output for a match's bisection state. type MatchResult struct { - MatchIDHash string `json:"match_id_hash"` - Tournament string `json:"tournament"` - CommitmentOne string `json:"commitment_one"` - CommitmentTwo string `json:"commitment_two"` - PlayerOneAddr string `json:"player_one_addr,omitempty"` - PlayerTwoAddr string `json:"player_two_addr,omitempty"` - CurrentHeight uint64 `json:"current_height"` - RunningLeafPosition string `json:"running_leaf_position"` - MachineCycle string `json:"machine_cycle"` - CanWinByTimeout bool `json:"can_win_by_timeout"` - LeftNode string `json:"left_node"` - RightNode string `json:"right_node"` - OtherParent string `json:"other_parent"` + MatchIDHash string `json:"match_id_hash"` + Tournament string `json:"tournament"` + CommitmentOne string `json:"commitment_one"` + CommitmentTwo string `json:"commitment_two"` + PlayerOneAddr string `json:"player_one_addr,omitempty"` + PlayerTwoAddr string `json:"player_two_addr,omitempty"` + ActualPhase string `json:"actual_phase"` + CurrentHeight *uint64 `json:"current_height,omitempty"` + SegmentStartPosition string `json:"segment_start_position,omitempty"` + SegmentStartCycle string `json:"segment_start_cycle,omitempty"` + TimeoutOutcome string `json:"timeout_outcome"` + DeferredCharge uint64 `json:"deferred_charge"` + RevealingParent string `json:"revealing_parent,omitempty"` + WaitingLeft string `json:"waiting_left,omitempty"` + WaitingRight string `json:"waiting_right,omitempty"` + Responder string `json:"responder,omitempty"` + AgreeState string `json:"agree_state,omitempty"` + DivergencePosition string `json:"divergence_position,omitempty"` + DivergenceCycle string `json:"divergence_cycle,omitempty"` + FinalStateOne string `json:"final_state_one,omitempty"` + FinalStateTwo string `json:"final_state_two,omitempty"` } diff --git a/cmd/cartesi-rollups-cli/root/deploy/application.go b/cmd/cartesi-rollups-cli/root/deploy/application.go index ec26b27f0..e9865e4cd 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/application.go +++ b/cmd/cartesi-rollups-cli/root/deploy/application.go @@ -25,13 +25,13 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" ) var ( applicationConsensusAddressParam string - applicationDataAvailabilityParam string applicationEnableParam bool applicationOwnerAddressParam string applicationRegisterParam bool @@ -54,6 +54,7 @@ var applicationCmd = &cobra.Command{ }, Example: applicationExamples, Run: runDeployApplication, + PreRunE: validateApplicationDeployment, Long: ` Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string @@ -75,6 +76,9 @@ const applicationExamples = ` # deploy an application contract with a PRT consensus, then register the application - cli deploy application echo-dapp applications/echo-dapp/ --prt +# deploy a PRT application with two sentry slots and a manager that can rotate their addresses + - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ --prt --claim-staging-period=300 --sentry-manager=0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA --sentries=0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB,0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC + # deploy but don't register into the database - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ --register=false @@ -93,8 +97,6 @@ func init() { "PRT Application factory address. Default value is retrieved from configuration.") applicationCmd.Flags().StringVarP(&applicationOwnerAddressParam, "application-owner", "o", "", "Application owner address. If not defined, it will be derived from the auth method.") - applicationCmd.Flags().StringVarP(&applicationDataAvailabilityParam, "data-availability", "d", "", - "Data availability string. Default is input box.") applicationCmd.Flags().StringVarP(&applicationTemplateHashParam, "template-hash", "H", "", "Template hash. If not provided, it will be read from the template path") applicationCmd.Flags().BoolVarP(&applicationRegisterParam, "register", "r", true, @@ -107,6 +109,7 @@ func init() { "Authority Owner address. If not defined, it will be derived from the auth method.") applicationCmd.Flags().BoolVarP(&deploymentTypePRT, "prt", "", false, "Deploy a PRT application.") + addPRTSentryFlags(applicationCmd) origHelpFunc := applicationCmd.HelpFunc() applicationCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -116,6 +119,29 @@ func init() { command.Flags().Lookup("verbose").Hidden = false origHelpFunc(command, strings) }) + cli.AddTransactionFlags(applicationCmd) +} + +func validateApplicationDeployment(cmd *cobra.Command, args []string) error { + register, err := cmd.Flags().GetBool("register") + if err != nil { + return err + } + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return err + } + if noWait && register { + return fmt.Errorf("--no-wait requires --register=false; registration needs a confirmed deployment") + } + if register && len(args) < 1 { + return fmt.Errorf("missing application name: positional argument [application-name] is required when --register=true") + } + if !cmd.Flags().Changed("template-hash") && len(args) < 2 { + return fmt.Errorf("missing template: provide either positional [template-path] or --template-hash") + } + _, _, err = parsePRTSentryConfig(cmd) + return err } func runDeployApplication(cmd *cobra.Command, args []string) { @@ -124,24 +150,12 @@ func runDeployApplication(cmd *cobra.Command, args []string) { ctx := cmd.Context() - // Validate required application name when registering - if applicationRegisterParam && len(args) < 1 { - err := cmd.Help() - cobra.CheckErr(err) - cobra.CheckErr(fmt.Errorf("missing application name: positional argument [application-name] is required when --register=true")) - } - // Validate that a template is provided either as positional [template-path] or via --template-hash - if cmd.Flags().Changed("template-hash") && len(args) < 2 { - err := cmd.Help() - cobra.CheckErr(err) - cobra.CheckErr(fmt.Errorf("missing template: provide either positional [template-path] or --template-hash")) - } - ethEndpoint, err := config.GetBlockchainHttpEndpoint() cobra.CheckErr(err) client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) + defer client.Close() chainID, err := client.ChainID(ctx) cobra.CheckErr(err) @@ -205,7 +219,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { if executionParametersFileParam == "-" { filePath = os.Stdin.Name() } - contents, err := os.ReadFile(filePath) //nolint:gosec // The CLI user explicitly supplies this path. + contents, err := os.ReadFile(filePath) cobra.CheckErr(err) decoder := json.NewDecoder(strings.NewReader(string(contents))) @@ -239,7 +253,16 @@ func runDeployApplication(cmd *cobra.Command, args []string) { if verboseParam || !asJSONParam { fmt.Fprint(os.Stderr, "deploying...") } - _, result, err := deployment.Deploy(ctx, client, ethutil.NewStaticTransactOptsFactory(txOpts)) + var tx *types.Transaction + var receipt *types.Receipt + applicationAddress, result, err := deployment.DeployWithTransaction(ctx, client, ethutil.NewStaticTransactOptsFactory(txOpts), + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) // The revert surface spans the variant's factory plus the constructors it // invokes; selectors are content-matched, so passing every factory ABI is // harmless and covers all three deployment variants. @@ -250,10 +273,13 @@ func runDeployApplication(cmd *cobra.Command, args []string) { iquorumfactory.IQuorumFactoryMetaData, idaveappfactory.IDaveAppFactoryMetaData, )) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, applicationAddress)) + return + } if verboseParam || !asJSONParam { fmt.Fprint(os.Stderr, "success\n") - fmt.Fprint(os.Stderr, result) } // TODO(mpolitzer): can this be more concise? @@ -266,7 +292,6 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.TemplateHash = res.Deployment.TemplateHash application.EpochLength = res.Deployment.EpochLength application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod - application.DataAvailability = res.Deployment.DataAvailability application.IInputBoxBlock = res.Deployment.IInputBoxBlock application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) @@ -277,7 +302,6 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.TemplateHash = res.Deployment.TemplateHash application.EpochLength = res.Deployment.EpochLength application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod - application.DataAvailability = res.Deployment.DataAvailability application.IInputBoxBlock = res.Deployment.IInputBoxBlock if res.Deployment.ConsensusType != "" { application.ConsensusType = model.Consensus(res.Deployment.ConsensusType) @@ -290,7 +314,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { application.IInputBoxAddress = res.InputBoxAddress application.TemplateHash = res.Deployment.TemplateHash application.EpochLength = res.Deployment.EpochLength - application.DataAvailability = res.DataAvailability + application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod application.IInputBoxBlock = res.IInputBoxBlock application.ConsensusType = model.Consensus_PRT application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) @@ -337,9 +361,17 @@ func runDeployApplication(cmd *cobra.Command, args []string) { } if asJSONParam { - report, err := json.MarshalIndent(&application, "", " ") + report := struct { + model.Application + Transaction cli.TransactionResult `json:"transaction"` + }{Application: application, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprint(cmd.OutOrStdout(), result) cobra.CheckErr(err) - fmt.Println(string(report)) } } @@ -363,13 +395,8 @@ func buildSelfhostedApplicationDeployment( return nil, fmt.Errorf("error on parameter selfhosted-factory: %w", err) } - if !cmd.Flags().Changed("application-owner") { - request.ApplicationOwnerAddress = txOpts.From - } else { - request.ApplicationOwnerAddress, err = parseHexAddress(applicationOwnerAddressParam) - } - if err != nil { - return nil, fmt.Errorf("error on parameter application-owner: %w", err) + if cmd.Flags().Changed("application-owner") { + return nil, fmt.Errorf("application-owner is not supported for self-hosted deployments; application ownership is renounced") } if !cmd.Flags().Changed("authority-owner") { @@ -394,29 +421,9 @@ func buildSelfhostedApplicationDeployment( return nil, fmt.Errorf("error on parameter template-hash: %w", err) } - var dataAvailabilityErr error - if !cmd.Flags().Changed("data-availability") { - inputBoxAddress, err := config.GetContractsInputBoxAddress() - if err != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", err) - } - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = - ethutil.DefaultDA(client, inputBoxAddress) - } else { - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = - ethutil.CustomDA(client, applicationDataAvailabilityParam) - } - if dataAvailabilityErr != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", dataAvailabilityErr) - } - - // ensure there is a contract deployed at the input box address - code, err := client.CodeAt(ctx, request.InputBoxAddress, nil) + request.InputBoxAddress, request.IInputBoxBlock, err = deploymentInputBox(ctx, client) if err != nil { - return nil, fmt.Errorf("failed to probe input box address for contract: %v", err) - } - if len(code) == 0 { - return nil, fmt.Errorf("error input box address has no code: %v", request.InputBoxAddress) + return nil, err } request.Salt, err = ethutil.ParseSalt(saltParam) @@ -479,29 +486,9 @@ func buildApplicationOnlyDeployment( return nil, fmt.Errorf("error on parameter application-owner: %w", err) } - var dataAvailabilityErr error - if !cmd.Flags().Changed("data-availability") { - inputBoxAddress, err := config.GetContractsInputBoxAddress() - if err != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", err) - } - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = - ethutil.DefaultDA(client, inputBoxAddress) - } else { - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = - ethutil.CustomDA(client, applicationDataAvailabilityParam) - } - if dataAvailabilityErr != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", dataAvailabilityErr) - } - - // ensure there is a contract deployed at the input box address - code, err := client.CodeAt(ctx, request.InputBoxAddress, nil) + request.InputBoxAddress, request.IInputBoxBlock, err = deploymentInputBox(ctx, client) if err != nil { - return nil, fmt.Errorf("failed to probe input box address for contract: %v", err) - } - if len(code) == 0 { - return nil, fmt.Errorf("error input box address has no code: %v", request.InputBoxAddress) + return nil, err } request.Salt, err = ethutil.ParseSalt(saltParam) @@ -518,7 +505,7 @@ func buildApplicationOnlyDeployment( var consensusType model.Consensus request.Consensus, request.EpochLength, request.ClaimStagingPeriod, consensusType, err = - customConsensus(client, applicationConsensusAddressParam) + customConsensus(ctx, client, applicationConsensusAddressParam) if err != nil { return nil, fmt.Errorf("error on parameter consensus: %w", err) } @@ -527,6 +514,30 @@ func buildApplicationOnlyDeployment( return request, nil } +func deploymentInputBox(ctx context.Context, client *ethclient.Client) (common.Address, uint64, error) { + inputBoxAddress, err := config.GetContractsInputBoxAddress() + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to get input box address: %w", err) + } + + code, err := client.CodeAt(ctx, inputBoxAddress, nil) + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to probe input box address for contract: %w", err) + } + if len(code) == 0 { + return common.Address{}, 0, fmt.Errorf("input box address has no code: %v", inputBoxAddress) + } + + inputBoxBlock, err := ethutil.GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to get input box deployment block: %w", err) + } + if !inputBoxBlock.IsUint64() { + return common.Address{}, 0, fmt.Errorf("input box deployment block does not fit uint64: %v", inputBoxBlock) + } + return inputBoxAddress, inputBoxBlock.Uint64(), nil +} + func buildPrtApplicationDeployment( cmd *cobra.Command, args []string, @@ -536,6 +547,10 @@ func buildPrtApplicationDeployment( ) { var err error request := ðutil.PRTApplicationDeployment{} + request.SentryManager, request.Sentries, err = parsePRTSentryConfig(cmd) + if err != nil { + return nil, err + } if !cmd.Flags().Changed("prt-factory") { request.FactoryAddress, err = config.GetContractsDaveAppFactoryAddress() } else { @@ -568,6 +583,7 @@ func buildPrtApplicationDeployment( return nil, err } + request.ClaimStagingPeriod = claimStagingPeriodParam request.Verbose = verboseParam return request, nil } @@ -577,7 +593,9 @@ func parseHexHash(hash string) (common.Hash, error) { return out, out.UnmarshalText([]byte(hash)) } -func customConsensus(client *ethclient.Client, consensusString string) (common.Address, uint64, uint64, model.Consensus, error) { +func customConsensus( + ctx context.Context, client *ethclient.Client, consensusString string, +) (common.Address, uint64, uint64, model.Consensus, error) { consensusAddress, err := parseHexAddress(consensusString) if err != nil { return common.Address{}, 0, 0, "", err @@ -588,14 +606,15 @@ func customConsensus(client *ethclient.Client, consensusString string) (common.A return common.Address{}, 0, 0, "", err } - epochLengthBig, err := consensus.GetEpochLength(nil) + callOpts := &bind.CallOpts{Context: ctx} + epochLengthBig, err := consensus.GetEpochLength(callOpts) if err != nil { - return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus epoch length: %v", err) + return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus epoch length: %w", err) } - claimStagingPeriodBig, err := consensus.GetClaimStagingPeriod(nil) + claimStagingPeriodBig, err := consensus.GetClaimStagingPeriod(callOpts) if err != nil { - return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus claim staging period: %v", err) + return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus claim staging period: %w", err) } consensusType := model.Consensus_Authority @@ -603,7 +622,7 @@ func customConsensus(client *ethclient.Client, consensusString string) (common.A if err != nil { return common.Address{}, 0, 0, "", err } - numOfValidators, err := quorum.NumOfValidators(nil) + numOfValidators, err := quorum.NumOfValidators(callOpts) if err == nil { if numOfValidators.Sign() == 0 { return common.Address{}, 0, 0, "", fmt.Errorf("quorum consensus reports zero validators") diff --git a/cmd/cartesi-rollups-cli/root/deploy/application_test.go b/cmd/cartesi-rollups-cli/root/deploy/application_test.go new file mode 100644 index 000000000..cbc09d0b2 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/application_test.go @@ -0,0 +1,81 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestBuildSelfhostedDeploymentRejectsApplicationOwner(t *testing.T) { + t.Setenv("CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS", "0x1000000000000000000000000000000000000001") + for _, ownerProvided := range []bool{false, true} { + name := "omitted" + if ownerProvided { + name = "explicit" + } + t.Run(name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("application-owner", "", "") + if ownerProvided { + require.NoError(t, cmd.Flags().Set("application-owner", "0x2000000000000000000000000000000000000002")) + } + _, err := buildSelfhostedApplicationDeployment(t.Context(), cmd, nil, nil, &bind.TransactOpts{}) + if ownerProvided { + require.ErrorContains(t, err, "ownership is renounced") + } else { + // Without an owner flag, validation reaches the next required argument. + require.ErrorContains(t, err, "template-hash") + } + }) + } +} + +func TestApplicationCommandDoesNotExposeDataAvailabilityFlag(t *testing.T) { + require.Nil(t, applicationCmd.Flags().Lookup("data-availability")) +} + +func TestApplicationDeploymentValidatesBeforeRunning(t *testing.T) { + for _, test := range []struct { + name string + register bool + noWait bool + templateHash bool + args []string + wantError string + }{ + {name: "registration requires receipt", register: true, noWait: true, wantError: "--no-wait requires --register=false"}, + {name: "registration requires name", register: true, templateHash: true, wantError: "missing application name"}, + {name: "template required", wantError: "missing template"}, + {name: "template hash only", templateHash: true}, + {name: "no wait with template hash", noWait: true, templateHash: true}, + {name: "registered template hash", register: true, templateHash: true, args: []string{"example"}}, + {name: "positional template", register: true, args: []string{"example", "applications/example"}}, + } { + t.Run(test.name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("register", test.register, "") + cmd.Flags().String("template-hash", "", "") + cli.AddTransactionFlags(cmd) + if test.noWait { + require.NoError(t, cmd.Flags().Set("no-wait", "true")) + } + if test.templateHash { + require.NoError(t, cmd.Flags().Set("template-hash", "0x01")) + } + // Invoke the actual command hook. The test must fail if the argument + // validation is removed from the command's pre-run path. + err := applicationCmd.PreRunE(cmd, test.args) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/authority.go b/cmd/cartesi-rollups-cli/root/deploy/authority.go index 8a00f22e6..416ac2dc7 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/authority.go +++ b/cmd/cartesi-rollups-cli/root/deploy/authority.go @@ -4,6 +4,7 @@ package deploy import ( + "context" "encoding/json" "fmt" "os" @@ -14,6 +15,7 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" ) @@ -57,6 +59,7 @@ func init() { // the parameter for the authority contract being deployed. origHelpFunc(command, strings) }) + cli.AddTransactionFlags(authorityCmd) } func runDeployAuthority(cmd *cobra.Command, _ []string) { @@ -69,6 +72,7 @@ func runDeployAuthority(cmd *cobra.Command, _ []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) + defer client.Close() chainID, err := client.ChainID(ctx) cobra.CheckErr(err) @@ -105,21 +109,39 @@ func runDeployAuthority(cmd *cobra.Command, _ []string) { if verboseParam || !asJSONParam { fmt.Fprintf(os.Stderr, "deploying authority...") } - deployment.Address, err = deployment.Deploy(ctx, client, txOpts) + var tx *types.Transaction + var receipt *types.Receipt + deployment.Address, err = deployment.DeployWithTransaction(ctx, client, txOpts, + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) cobra.CheckErr(cli.DecorateRevert(err, iauthorityfactory.IAuthorityFactoryMetaData)) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, deployment.Address)) + return + } // report if verboseParam || !asJSONParam { fmt.Fprintf(os.Stderr, "success\n") - fmt.Fprintln(os.Stderr, "\tconsensus address: ", deployment.Address) - fmt.Fprintln(os.Stderr, "\tepoch length: ", deployment.EpochLength) } if asJSONParam { - report, err := json.MarshalIndent(&deployment, "", " ") - cobra.CheckErr(err) // deployed, but fail to print - - fmt.Println(string(report)) + report := struct { + *ethutil.AuthorityDeployment + Transaction cli.TransactionResult `json:"transaction"` + }{AuthorityDeployment: deployment, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Consensus address: %s\nEpoch length: %d\n", deployment.Address, deployment.EpochLength) + cobra.CheckErr(err) } } diff --git a/cmd/cartesi-rollups-cli/root/deploy/broadcast.go b/cmd/cartesi-rollups-cli/root/deploy/broadcast.go new file mode 100644 index 000000000..11e258503 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/broadcast.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/internal/cli" +) + +func writeDeploymentBroadcast(cmd *cobra.Command, tx *types.Transaction, predictedAddress common.Address) error { + asJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + result := struct { + cli.TransactionResult + PredictedAddress common.Address `json:"predicted_address"` + }{TransactionResult: cli.NewTransactionResult(tx, nil), PredictedAddress: predictedAddress} + if asJSON { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + err = encoder.Encode(result) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Transaction broadcast: %s\nPredicted address (deployment not confirmed): %s\n", + result.TransactionHash, predictedAddress.Hex()) + } + if err != nil { + return fmt.Errorf("write deployment broadcast result: %w", err) + } + return nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go b/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go new file mode 100644 index 000000000..e9dc7027b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go @@ -0,0 +1,225 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iselfhostedapplicationfactory" +) + +const deploymentCLIProcessEnv = "CARTESI_TEST_DEPLOYMENT_CLI_PROCESS" + +func TestDeploymentBroadcastPlainOutput(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("json", false, "") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + tx := types.NewTx(&types.LegacyTx{}) + address := common.HexToAddress("0x1234") + require.NoError(t, writeDeploymentBroadcast(cmd, tx, address)) + require.Equal(t, "Transaction broadcast: "+tx.Hash().Hex()+ + "\nPredicted address (deployment not confirmed): "+address.Hex()+"\n", stdout.String()) + require.Empty(t, stderr.String()) +} + +// Run the actual command in a child process. Existing deployment handlers use +// cobra.CheckErr, and this also checks Cobra's default stdout/stderr selection. +func TestDeploymentCLIProcess(t *testing.T) { + if os.Getenv(deploymentCLIProcessEnv) != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + Cmd.SetArgs(os.Args[i+1:]) + break + } + } + if err := Cmd.ExecuteContext(t.Context()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func TestDeploymentCLIReportsPredictedAddress(t *testing.T) { + testExecutable, err := os.Executable() + require.NoError(t, err) + factoryAddress := common.HexToAddress("0x1000") + inputBoxAddress := common.HexToAddress("0x2000") + predictedAddress := common.HexToAddress("0x3000") + secondaryAddress := common.HexToAddress("0x4000") + applicationArgs := []string{"application", "--register=false", "--template-hash=" + (common.Hash{}).Hex()} + for _, test := range []struct { + name string + args []string + }{ + {name: "authority", args: []string{"authority", "--authority-factory", factoryAddress.Hex()}}, + {name: "quorum", args: []string{"quorum", "--quorum-factory", factoryAddress.Hex(), "--validator", testValidatorAddress}}, + {name: "selfhosted", args: append(append([]string{}, applicationArgs...), "--factory", factoryAddress.Hex())}, + {name: "application", args: append(append([]string{}, applicationArgs...), "--factory", factoryAddress.Hex(), + "--consensus", inputBoxAddress.Hex())}, + {name: "prt", args: append(append([]string{}, applicationArgs...), "--prt", "--prt-factory", factoryAddress.Hex())}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &deploymentCLIBackend{ + responses: map[string]hexutil.Bytes{}, + sent: make(chan *types.Transaction, 2), predictedAddress: predictedAddress, secondaryAddress: secondaryAddress, + } + for _, response := range []struct { + metadata *bind.MetaData + method string + outputs []any + }{ + {iauthorityfactory.IAuthorityFactoryMetaData, "calculateAuthorityAddress", []any{predictedAddress}}, + {iquorumfactory.IQuorumFactoryMetaData, "calculateQuorumAddress", []any{predictedAddress}}, + {iapplicationfactory.IApplicationFactoryMetaData, "calculateApplicationAddress", []any{predictedAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "calculateAddresses", + []any{predictedAddress, secondaryAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "getApplicationFactory", []any{factoryAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "getAuthorityFactory", []any{factoryAddress}}, + {idaveappfactory.IDaveAppFactoryMetaData, "calculateDaveAppAddress", []any{predictedAddress, secondaryAddress}}, + {iinputbox.IInputBoxMetaData, "getDeploymentBlockNumber", []any{big.NewInt(1)}}, + {iconsensus.IConsensusMetaData, "getEpochLength", []any{big.NewInt(10)}}, + {iconsensus.IConsensusMetaData, "getClaimStagingPeriod", []any{big.NewInt(0)}}, + {iquorum.IQuorumMetaData, "numOfValidators", []any{big.NewInt(1)}}, + } { + parsed, err := response.metadata.GetAbi() + require.NoError(t, err) + method := parsed.Methods[response.method] + data, err := method.Outputs.Pack(response.outputs...) + require.NoError(t, err) + backend.responses[string(method.ID)] = data + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", backend)) + defer rpcServer.Stop() + server := httptest.NewServer(rpcServer) + defer server.Close() + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(deploymentCLIProcessEnv, "1") + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + t.Setenv(config.CONTRACTS_INPUT_BOX_ADDRESS, inputBoxAddress.Hex()) + t.Setenv(config.DATABASE_CONNECTION, "invalid") + args := append([]string{"-test.run=^TestDeploymentCLIProcess$", "--"}, test.args...) + args = append(args, "--no-wait", "--json") + process := exec.CommandContext(t.Context(), testExecutable, args...) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + require.NoError(t, process.Run(), stderr.String()) + require.Len(t, backend.sent, 1) + tx := <-backend.sent + require.Equal(t, factoryAddress, *tx.To()) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, map[string]string{ + "transaction_hash": tx.Hash().Hex(), "status": "broadcast", "predicted_address": predictedAddress.Hex(), + }, result) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.Zero(t, backend.receiptReads.Load()) + require.Zero(t, backend.postBroadcastReads.Load(), "no deployment verification before mining") + }) + } +} + +type deploymentCLIBackend struct { + responses map[string]hexutil.Bytes + sent chan *types.Transaction + predictedAddress common.Address + secondaryAddress common.Address + broadcast atomic.Bool + receiptReads atomic.Int32 + postBroadcastReads atomic.Int32 +} + +func (*deploymentCLIBackend) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*deploymentCLIBackend) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: 1_000_000} +} + +func (*deploymentCLIBackend) GasPrice(context.Context) *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1)) +} + +func (*deploymentCLIBackend) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (*deploymentCLIBackend) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + return 500_000 +} + +func (b *deploymentCLIBackend) GetCode(_ context.Context, address common.Address, _ string) hexutil.Bytes { + if b.broadcast.Load() { + b.postBroadcastReads.Add(1) + } + if address == b.predictedAddress || address == b.secondaryAddress { + return hexutil.Bytes{} + } + return hexutil.Bytes{0x01} +} + +func (b *deploymentCLIBackend) Call(_ context.Context, call struct { + Input hexutil.Bytes `json:"input"` +}, _ string) (hexutil.Bytes, error) { + if b.broadcast.Load() { + b.postBroadcastReads.Add(1) + } + if len(call.Input) >= 4 { + if response, found := b.responses[string(call.Input[:4])]; found { + return response, nil + } + } + return nil, fmt.Errorf("unexpected contract call: %x", call.Input) +} + +func (b *deploymentCLIBackend) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + b.sent <- &tx + b.broadcast.Store(true) + return tx.Hash(), nil +} + +func (b *deploymentCLIBackend) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + b.receiptReads.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(10), Logs: []*types.Log{}} +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/deploy.go b/cmd/cartesi-rollups-cli/root/deploy/deploy.go index 1d575ef3a..1b4d6fbca 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/deploy.go +++ b/cmd/cartesi-rollups-cli/root/deploy/deploy.go @@ -31,7 +31,7 @@ func init() { "Epoch length") cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("epoch-length")) Cmd.PersistentFlags().Uint64Var(&claimStagingPeriodParam, "claim-staging-period", 0, - "Number of blocks between a claim being submitted and accepted (Authority/Quorum only)") + "Number of blocks between claim staging and acceptance. Zero permits immediate acceptance; choose the delay for your deployment.") Cmd.PersistentFlags().StringVar(&withdrawalConfigParam, "withdrawal-config", "", "Inline JSON object describing the WithdrawalConfig "+ "(see docs/withdrawal-config-guide.md). Omit to deploy without foreclosure.") diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum.go b/cmd/cartesi-rollups-cli/root/deploy/quorum.go index 185cae245..35f21191c 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/quorum.go +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum.go @@ -4,6 +4,7 @@ package deploy import ( + "context" "encoding/json" "fmt" "os" @@ -12,7 +13,9 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" ) @@ -56,6 +59,7 @@ func init() { command.Flags().Lookup("verbose").Hidden = false origHelpFunc(command, strings) }) + cli.AddTransactionFlags(quorumCmd) } func runDeployQuorum(cmd *cobra.Command, _ []string) { @@ -68,6 +72,7 @@ func runDeployQuorum(cmd *cobra.Command, _ []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) + defer client.Close() chainID, err := client.ChainID(ctx) cobra.CheckErr(err) @@ -101,20 +106,39 @@ func runDeployQuorum(cmd *cobra.Command, _ []string) { if verboseParam || !asJSONParam { fmt.Fprintf(os.Stderr, "deploying quorum...") } - deployment.Address, err = deployment.Deploy(ctx, client, txOpts) + var tx *types.Transaction + var receipt *types.Receipt + deployment.Address, err = deployment.DeployWithTransaction(ctx, client, txOpts, + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) cobra.CheckErr(cli.DecorateRevert(err, iquorumfactory.IQuorumFactoryMetaData)) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, deployment.Address)) + return + } if verboseParam || !asJSONParam { fmt.Fprintf(os.Stderr, "success\n") - fmt.Fprintln(os.Stderr, "\tconsensus address: ", deployment.Address) - fmt.Fprintln(os.Stderr, "\tepoch length: ", deployment.EpochLength) - fmt.Fprintln(os.Stderr, "\tclaim staging period: ", deployment.ClaimStagingPeriod) } if asJSONParam { - report, err := json.MarshalIndent(&deployment, "", " ") + report := struct { + *ethutil.QuorumDeployment + Transaction cli.TransactionResult `json:"transaction"` + }{QuorumDeployment: deployment, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Consensus address: %s\nEpoch length: %d\nClaim staging period: %d\n", + deployment.Address, deployment.EpochLength, deployment.ClaimStagingPeriod) cobra.CheckErr(err) - fmt.Println(string(report)) } } diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go b/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go index 4692172c6..fc3aa6493 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go @@ -10,15 +10,17 @@ import ( "github.com/stretchr/testify/require" ) +const testValidatorAddress = "0x1111111111111111111111111111111111111111" + func TestParseValidatorAddresses_ValidRepeatedFlags(t *testing.T) { got, err := parseValidatorAddresses([]string{ - "0x1111111111111111111111111111111111111111", + testValidatorAddress, "0x2222222222222222222222222222222222222222", }) require.NoError(t, err) require.Equal(t, []common.Address{ - common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress(testValidatorAddress), common.HexToAddress("0x2222222222222222222222222222222222222222"), }, got) } @@ -46,8 +48,8 @@ func TestParseValidatorAddresses_RejectsZeroAddress(t *testing.T) { func TestParseValidatorAddresses_RejectsDuplicates(t *testing.T) { _, err := parseValidatorAddresses([]string{ - "0x1111111111111111111111111111111111111111", - "0x1111111111111111111111111111111111111111", + testValidatorAddress, + testValidatorAddress, }) require.Error(t, err) diff --git a/cmd/cartesi-rollups-cli/root/deploy/sentries.go b/cmd/cartesi-rollups-cli/root/deploy/sentries.go new file mode 100644 index 000000000..56054e407 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/sentries.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func addPRTSentryFlags(cmd *cobra.Command) { + cmd.Flags().String("sentry-manager", common.Address{}.Hex(), + "PRT only: immutable sentry manager address. Zero disables address rotation, not sentry claims.") + cmd.Flags().StringSlice("sentries", nil, + "PRT only: initial sentry addresses in slot order (IDs start at 1). Repeat or comma-separate. Slot count cannot change.") +} + +func parsePRTSentryConfig(cmd *cobra.Command) (common.Address, []common.Address, error) { + manager := common.Address{} + sentries := []common.Address{} + if !cmd.Flags().Changed("sentry-manager") && !cmd.Flags().Changed("sentries") { + return manager, sentries, nil + } + prt, err := cmd.Flags().GetBool("prt") + if err != nil { + return manager, nil, err + } + if !prt { + return manager, nil, fmt.Errorf("--sentry-manager and --sentries require --prt") + } + managerText, err := cmd.Flags().GetString("sentry-manager") + if err != nil { + return manager, nil, err + } + manager, err = parseHexAddress(managerText) + if err != nil { + return manager, nil, fmt.Errorf("invalid --sentry-manager: %w", err) + } + addresses, err := cmd.Flags().GetStringSlice("sentries") + if err != nil { + return manager, nil, err + } + for i, text := range addresses { + address, err := parseHexAddress(text) + if err != nil { + return manager, nil, fmt.Errorf("invalid --sentries entry %d: %w", i+1, err) + } + sentries = append(sentries, address) + } + if err := ethutil.ValidateSentryAddresses(sentries); err != nil { + return manager, nil, fmt.Errorf("invalid --sentries: %w", err) + } + return manager, sentries, nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go b/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go new file mode 100644 index 000000000..c3be1a4a7 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go @@ -0,0 +1,105 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/cli" +) + +const ( + prtFlag = "--prt" + managerFlag = "--sentry-manager" + sentriesFlag = "--sentries" + requiresPRT = "require --prt" +) + +func TestApplicationDeploymentSentryFlags(t *testing.T) { + manager := common.HexToAddress("0x10").Hex() + sentryOne := common.HexToAddress("0xab").Hex() + sentryTwo := common.HexToAddress("0xcd").Hex() + for _, name := range []string{"sentry-manager", "sentries"} { + require.NotNil(t, applicationCmd.Flags().Lookup(name), "the deployed command must expose %s", name) + } + for _, test := range []struct { + name string + flags []string + manager common.Address + sentries []common.Address + wantError string + }{ + {name: "non PRT defaults"}, + {name: "PRT defaults", flags: []string{prtFlag}}, + {name: "manager only", flags: []string{prtFlag, managerFlag, manager}, manager: common.HexToAddress(manager)}, + {name: "fixed sentries", flags: []string{prtFlag, sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryOne)}}, + {name: "explicit zero manager", flags: []string{prtFlag, managerFlag, common.Address{}.Hex(), sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryOne)}}, + {name: "ordered list", flags: []string{prtFlag, managerFlag, manager, sentriesFlag, sentryTwo + "," + sentryOne}, + manager: common.HexToAddress(manager), + sentries: []common.Address{common.HexToAddress(sentryTwo), common.HexToAddress(sentryOne)}}, + {name: "repeated flag", flags: []string{prtFlag, sentriesFlag, sentryTwo, sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryTwo), common.HexToAddress(sentryOne)}}, + {name: "empty list", flags: []string{prtFlag, "--sentries="}}, + {name: "manager without PRT", flags: []string{managerFlag, manager}, wantError: requiresPRT}, + {name: "sentries without PRT", flags: []string{sentriesFlag, sentryOne}, wantError: requiresPRT}, + {name: "explicit empty without PRT", flags: []string{"--sentries="}, wantError: requiresPRT}, + {name: "malformed manager", flags: []string{prtFlag, managerFlag, "0x01"}, wantError: "invalid --sentry-manager"}, + {name: "malformed sentry", flags: []string{prtFlag, sentriesFlag, "not-an-address"}, wantError: "invalid --sentries entry 1"}, + {name: "zero sentry", flags: []string{prtFlag, sentriesFlag, common.Address{}.Hex()}, wantError: "must not be the zero address"}, + {name: "duplicate sentry", flags: []string{prtFlag, sentriesFlag, sentryOne, sentriesFlag, strings.ToLower(sentryOne)}, + wantError: "duplicates address"}, + {name: "empty entry", flags: []string{prtFlag, sentriesFlag, sentryOne + ","}, wantError: "invalid --sentries entry 2"}, + } { + t.Run(test.name, func(t *testing.T) { + cmd := newSentryDeploymentTestCommand(t, test.flags) + // Exercise the command's real pre-run hook, not only the parser. + err := applicationCmd.PreRunE(cmd, nil) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + return + } + require.NoError(t, err) + gotManager, gotSentries, err := parsePRTSentryConfig(cmd) + require.NoError(t, err) + require.Equal(t, test.manager, gotManager) + require.Equal(t, append([]common.Address{}, test.sentries...), gotSentries) + }) + } +} + +func newSentryDeploymentTestCommand(t *testing.T, args []string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.Flags().Bool("prt", false, "") + cmd.Flags().Bool("register", false, "") + cmd.Flags().String("template-hash", "", "") + addPRTSentryFlags(cmd) + cli.AddTransactionFlags(cmd) + require.NoError(t, cmd.Flags().Set("template-hash", common.HexToHash("0x01").Hex())) + require.NoError(t, cmd.ParseFlags(args)) + return cmd +} + +func TestBuildPRTDeploymentPreservesSentryConfig(t *testing.T) { + t.Setenv("CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS", common.HexToAddress("0x100").Hex()) + previousHash := applicationTemplateHashParam + t.Cleanup(func() { applicationTemplateHashParam = previousHash }) + applicationTemplateHashParam = common.HexToHash("0x01").Hex() + manager := common.HexToAddress("0x10") + sentryOne, sentryTwo := common.HexToAddress("0x30"), common.HexToAddress("0x20") + cmd := newSentryDeploymentTestCommand(t, []string{ + prtFlag, managerFlag, manager.Hex(), sentriesFlag, sentryOne.Hex() + "," + sentryTwo.Hex(), + }) + request, err := buildPrtApplicationDeployment(cmd, nil) + require.NoError(t, err) + require.Equal(t, manager, request.SentryManager) + require.Equal(t, []common.Address{sentryOne, sentryTwo}, request.Sentries) +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit.go b/cmd/cartesi-rollups-cli/root/deposit/deposit.go index 5b08cf460..c446ff10a 100644 --- a/cmd/cartesi-rollups-cli/root/deposit/deposit.go +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit.go @@ -4,10 +4,10 @@ package deposit import ( + "bytes" "encoding/json" "fmt" "math/big" - "os" "strings" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" @@ -34,17 +34,22 @@ var Cmd = &cobra.Command{ var erc20Cmd = &cobra.Command{ Use: "erc20 [app-name-or-address]", - Short: "Deposit ERC-20 tokens through the ERC20Portal", + Short: "Deposit ERC-20 tokens through the Erc20Portal", Example: erc20Examples, Args: cobra.ExactArgs(1), - Run: runERC20, + RunE: runERC20, + PreRunE: validateERC20Flags, Long: ` -Calls ERC20Portal.depositERC20Tokens(token, app, amount, execData). +Calls Erc20Portal.depositErc20Tokens(token, app, amount, execData). The command does not approve token spending unless --approve is supplied. Without --approve, the signer must already have enough allowance for the portal. +--approve cannot be combined with --no-wait. The approval must succeed +before the deposit transaction is prepared. Submit approval separately +if the deposit must return without waiting for mining. + Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint @@ -52,10 +57,10 @@ Supported Environment Variables: CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, } -const erc20Examples = `# Deposit 100 units of a token through the ERC20Portal: +const erc20Examples = `# Deposit 100 units of a token through the Erc20Portal: cartesi-rollups-cli deposit erc20 echo-dapp \ - --portal 0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1 \ - --token 0x88A2120B7068E78692C8fd12E751d610B6377E4d \ + --portal 0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419 \ + --token 0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81 \ --amount 100 # Approve the portal first, then deposit: @@ -74,13 +79,14 @@ var ( func init() { Cmd.AddCommand(erc20Cmd) - erc20Cmd.Flags().StringVar(&portalParam, "portal", "", "ERC20Portal contract address") + erc20Cmd.Flags().StringVar(&portalParam, "portal", "", "Erc20Portal contract address") erc20Cmd.Flags().StringVar(&tokenParam, "token", "", "ERC-20 token contract address") erc20Cmd.Flags().StringVar(&amountParam, "amount", "", "Token amount to deposit (decimal or 0x-prefixed)") erc20Cmd.Flags().StringVar(&execDataParam, "exec-data", "0x", "Extra execution-layer data") erc20Cmd.Flags().BoolVar(&approveParam, "approve", false, "Approve the portal for --amount before depositing") erc20Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") erc20Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(erc20Cmd) cobra.CheckErr(erc20Cmd.MarkFlagRequired("portal")) cobra.CheckErr(erc20Cmd.MarkFlagRequired("token")) cobra.CheckErr(erc20Cmd.MarkFlagRequired("amount")) @@ -94,31 +100,65 @@ func init() { }) } -func runERC20(cmd *cobra.Command, args []string) { +func validateERC20Flags(cmd *cobra.Command, _ []string) error { + approve, err := cmd.Flags().GetBool("approve") + if err != nil { + return err + } + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return err + } + if approve && noWait { + return fmt.Errorf("--approve cannot be combined with --no-wait: the deposit requires a successful approval") + } + return nil +} + +func runERC20(cmd *cobra.Command, args []string) error { ctx := cmd.Context() appAddr, err := util.ResolveApplicationAddress(ctx, args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } portalAddr, err := parseAddress("portal", portalParam) - cobra.CheckErr(err) + if err != nil { + return err + } tokenAddr, err := parseAddress("token", tokenParam) - cobra.CheckErr(err) + if err != nil { + return err + } amount, err := parseAmount(amountParam) - cobra.CheckErr(err) + if err != nil { + return err + } execData, err := hexutil.Decode(execDataParam) - cobra.CheckErr(err) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOptsFactory, err := auth.GetTransactOptsFactory(ctx, chainID) - cobra.CheckErr(err) + if err != nil { + return err + } if !skipConfirmation { - fmt.Printf("Preparing ERC-20 deposit\n"+ + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing ERC-20 deposit\n"+ " signer: %s\n"+ " application: %s\n"+ " portal: %s\n"+ @@ -126,79 +166,112 @@ func runERC20(cmd *cobra.Command, args []string) { " amount: %s\n"+ " approve: %t\n", txOptsFactory.From(), appAddr, portalAddr, tokenAddr, amount.String(), approveParam) - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) + if err != nil { + return err + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err } } var approveHash *common.Hash if approveParam { token, err := ierc20metadata.NewIERC20Metadata(tokenAddr, client) - cobra.CheckErr(err) + if err != nil { + return err + } approveOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) - cobra.CheckErr(err) - tx, err := token.Approve(approveOpts, portalAddr, amount) - cobra.CheckErr(cli.DecorateRevert(err, - ierc20metadata.IERC20MetadataMetaData, - ierc20errors.IERC20ErrorsMetaData, - )) - receipt, err := bind.WaitMined(ctx, client, tx) - cobra.CheckErr(err) - cobra.CheckErr(checkReceiptStatus(receipt, "approve")) - hash := receipt.TxHash + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, approveOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return token.Approve(opts, portalAddr, amount) + }) + if err != nil { + return cli.DecorateRevert(err, ierc20metadata.IERC20MetadataMetaData, ierc20errors.IERC20ErrorsMetaData) + } + approved := false + for _, log := range receipt.Logs { + if log == nil || log.Address != tokenAddr || len(log.Data) != common.HashLength { + continue + } + event, err := token.ParseApproval(*log) + if err == nil && event.Owner == approveOpts.From && event.Spender == portalAddr && event.Value.Cmp(amount) == 0 { + approved = true + break + } + } + if !approved { + return fmt.Errorf("transaction %s mined, but its receipt has no matching Approval event", tx.Hash()) + } + hash := tx.Hash() approveHash = &hash } - portal, err := ierc20portal.NewIERC20Portal(portalAddr, client) - cobra.CheckErr(err) + portal, err := ierc20portal.NewIErc20Portal(portalAddr, client) + if err != nil { + return err + } depositOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) - cobra.CheckErr(err) - tx, err := portal.DepositERC20Tokens(depositOpts, tokenAddr, appAddr, amount, execData) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, depositOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return portal.DepositErc20Tokens(opts, tokenAddr, appAddr, amount, execData) + }) // The revert can come from three layers: the portal itself - // (ERC20TransferFailed), the token's transferFrom (ERC-6093 errors such - // as ERC20InsufficientBalance/Allowance), or the forwarded + // (Erc20TransferFailed or a balance-delta error), the token's transferFrom + // (ERC-6093 errors such as ERC20InsufficientBalance/Allowance), or the forwarded // InputBox.addInput (InputTooLarge and the application foreclosure-probe // family). - cobra.CheckErr(cli.DecorateRevert(err, - ierc20portal.IERC20PortalMetaData, - ierc20errors.IERC20ErrorsMetaData, - iinputbox.IInputBoxMetaData, - iapplication.IApplicationMetaData, - )) - receipt, err := bind.WaitMined(ctx, client, tx) - cobra.CheckErr(err) - cobra.CheckErr(checkReceiptStatus(receipt, "depositERC20Tokens")) + if err != nil { + return cli.DecorateRevert(err, + ierc20portal.IErc20PortalMetaData, + ierc20errors.IERC20ErrorsMetaData, + iinputbox.IInputBoxMetaData, + iapplication.IApplicationMetaData, + ) + } + if receipt != nil { + // Erc20Portal uses abi.encodePacked(token, sender, amount, execData). + payload := bytes.Join([][]byte{tokenAddr.Bytes(), depositOpts.From.Bytes(), common.LeftPadBytes(amount.Bytes(), common.HashLength), + execData}, nil) + if err := verifyDepositReceipt(ctx, client, receipt, appAddr, portalAddr, payload); err != nil { + return fmt.Errorf("transaction %s mined, but its deposit could not be confirmed: %w", tx.Hash(), err) + } + } if asJSONParam { result := struct { + cli.TransactionResult ApplicationAddress common.Address `json:"application_address"` PortalAddress common.Address `json:"portal_address"` TokenAddress common.Address `json:"token_address"` Amount string `json:"amount"` ApproveTxHash *common.Hash `json:"approve_transaction_hash,omitempty"` - TransactionHash common.Hash `json:"transaction_hash"` - BlockNumber string `json:"block_number"` }{ + TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddress: appAddr, PortalAddress: portalAddr, TokenAddress: tokenAddr, Amount: amount.String(), ApproveTxHash: approveHash, - TransactionHash: receipt.TxHash, - BlockNumber: fmt.Sprintf("0x%x", receipt.BlockNumber.Uint64()), } - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - if approveHash != nil { - fmt.Printf("approve tx-hash: %s\n", approveHash.Hex()) + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + if approveHash != nil { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "approve tx-hash: %s\n", approveHash.Hex()); err != nil { + return err } - fmt.Printf("deposit tx-hash: %s blockNumber: %d\n", receipt.TxHash, receipt.BlockNumber.Uint64()) } + return cli.WriteTransactionResult(cmd, tx, receipt) } func parseAddress(name string, value string) (common.Address, error) { @@ -226,15 +299,9 @@ func parseAmount(value string) (*big.Int, error) { if amount.Sign() <= 0 { return nil, fmt.Errorf("amount must be positive") } - return amount, nil -} - -func checkReceiptStatus(receipt *types.Receipt, action string) error { - if receipt == nil { - return fmt.Errorf("%s transaction has no receipt", action) - } - if receipt.Status != types.ReceiptStatusSuccessful { - return fmt.Errorf("%s transaction failed: %s", action, receipt.TxHash) + const uint256Bits = 256 + if amount.BitLen() > uint256Bits { + return nil, fmt.Errorf("amount must fit in uint256") } - return nil + return amount, nil } diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go b/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go new file mode 100644 index 000000000..d917146f2 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go @@ -0,0 +1,267 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20portal" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testPortal = "0x0000000000000000000000000000000000000002" + testToken = "0x0000000000000000000000000000000000000003" + testInputBox = "0x0000000000000000000000000000000000000004" + testGasLimit = 90000 + approveFlag = "--approve" +) + +// depositRPC exercises the generated token and portal bindings. The service +// counts receipt reads so a no-wait command cannot pass by waiting on an +// immediately successful receipt. +type depositRPC struct { + sent chan *types.Transaction + nonce atomic.Uint64 + receipts atomic.Int32 + estimates atomic.Int32 + chainReads atomic.Int32 + failed bool + transactions sync.Map + approvalLogs []*types.Log + depositLogs []*types.Log + inputBoxReads atomic.Int32 + inputBoxBlock atomic.Value + inputBoxResponse hexutil.Bytes +} + +func (r *depositRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // Ethereum RPC method eth_chainId. + r.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(31337)), nil +} + +func (*depositRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*depositRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (*depositRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{0x01}, nil +} + +func (r *depositRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return hexutil.Uint64(r.nonce.Load()), nil +} + +func (r *depositRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, nil +} + +func (r *depositRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + r.transactions.Store(tx.Hash(), &tx) + r.nonce.Add(1) + return tx.Hash(), nil +} + +func (r *depositRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + logs := r.depositLogs + if value, ok := r.transactions.Load(hash); ok && *value.(*types.Transaction).To() == common.HexToAddress(testToken) { + logs = r.approvalLogs + } + if logs == nil { + logs = []*types.Log{} + } + return &types.Receipt{TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit}, nil +} + +func (r *depositRPC) Call(_ context.Context, args map[string]json.RawMessage, block string) (hexutil.Bytes, error) { + r.inputBoxReads.Add(1) + r.inputBoxBlock.Store(block) + var target common.Address + var data hexutil.Bytes + if err := json.Unmarshal(args["to"], &target); err != nil { + return nil, err + } + if err := json.Unmarshal(args["input"], &data); err != nil { + return nil, err + } + parsed, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return nil, err + } + if target != common.HexToAddress(testApplication) || !bytes.Equal(data, parsed.Methods["getInputBox"].ID) { + return nil, fmt.Errorf("unexpected contract call") + } + return r.inputBoxResponse, nil +} + +func setupDepositRPC(t *testing.T, service *depositRPC) { + t.Helper() + erc20Cmd.SetContext(t.Context()) + previousSilenceUsage := Cmd.SilenceUsage + Cmd.SilenceUsage = true + t.Cleanup(func() { Cmd.SilenceUsage = previousSilenceUsage }) + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + t.Setenv(config.DATABASE_CONNECTION, "invalid-database") + signer := crypto.PubkeyToAddress(key.PublicKey) + service.approvalLogs = []*types.Log{approvalLog(t, signer, common.HexToAddress(testPortal), big.NewInt(100))} + service.depositLogs = []*types.Log{depositInputLog(t, signer)} + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + service.inputBoxResponse, err = appABI.Methods["getInputBox"].Outputs.Pack(common.HexToAddress(testInputBox)) + require.NoError(t, err) + erc20Cmd.Flags().VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) +} + +func approvalLog(t *testing.T, owner, spender common.Address, value *big.Int) *types.Log { + t.Helper() + parsed, err := ierc20metadata.IERC20MetadataMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["Approval"] + data, err := event.Inputs.NonIndexed().Pack(value) + require.NoError(t, err) + return &types.Log{Address: common.HexToAddress(testToken), Data: data, + Topics: []common.Hash{event.ID, common.BytesToHash(owner.Bytes()), common.BytesToHash(spender.Bytes())}} +} + +func depositInputLog(t *testing.T, signer common.Address) *types.Log { + t.Helper() + inputABI, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + payload := bytes.Join([][]byte{common.HexToAddress(testToken).Bytes(), signer.Bytes(), + common.LeftPadBytes(big.NewInt(100).Bytes(), common.HashLength)}, nil) + input, err := inputABI.Pack("EvmAdvance", big.NewInt(31337), common.HexToAddress(testApplication), + common.HexToAddress(testPortal), big.NewInt(10), big.NewInt(20), big.NewInt(0), big.NewInt(7), payload) + require.NoError(t, err) + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["InputAdded"] + data, err := event.Inputs.NonIndexed().Pack(input) + require.NoError(t, err) + return &types.Log{Address: common.HexToAddress(testInputBox), Data: data, + Topics: []common.Hash{event.ID, common.BytesToHash(common.HexToAddress(testApplication).Bytes()), common.BigToHash(big.NewInt(7))}} +} + +func TestDepositTransactionPolicy(t *testing.T) { + for _, tt := range []struct { + name string + extra []string + failed bool + wantErr string + wantTransactions int + wantReceipts int32 + }{ + {name: "default waits", wantTransactions: 1, wantReceipts: 1}, + {name: "no wait", extra: []string{"--no-wait"}, wantTransactions: 1}, + {name: "approval then deposit", extra: []string{approveFlag}, wantTransactions: 2, wantReceipts: 2}, + {name: "approval failure stops deposit", extra: []string{approveFlag}, failed: true, + wantErr: "failed in block", wantTransactions: 1, wantReceipts: 1}, + {name: "deposit failure", failed: true, wantErr: "failed in block", wantTransactions: 1, wantReceipts: 1}, + {name: "reject approval with no wait", extra: []string{approveFlag, "--no-wait"}, + wantErr: "--approve cannot be combined with --no-wait"}, + {name: "reject invalid timeout", extra: []string{"--wait-timeout", "0s"}, wantErr: "wait-timeout must be positive"}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &depositRPC{sent: make(chan *types.Transaction, 2), failed: tt.failed} + setupDepositRPC(t, service) + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + erc20Cmd.SetContext(t.Context()) + args := append([]string{"erc20", testApplication, "--portal", testPortal, "--token", testToken, + "--amount", "100", "--yes", "--json"}, tt.extra...) + Cmd.SetArgs(args) + err := Cmd.ExecuteContext(t.Context()) + if tt.wantErr == "" { + require.NoError(t, err, stderr.String()) + } + require.Len(t, service.sent, tt.wantTransactions) + require.Equal(t, tt.wantReceipts, service.receipts.Load()) + require.EqualValues(t, tt.wantTransactions, service.estimates.Load()) + if tt.wantTransactions == 0 { + require.Zero(t, service.chainReads.Load()) + } + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + return + } + require.NoError(t, err) + if tt.wantTransactions == 2 { + approval := <-service.sent + require.Equal(t, common.HexToAddress(testToken), *approval.To()) + tokenABI, err := ierc20metadata.IERC20MetadataMetaData.GetAbi() + require.NoError(t, err) + require.Equal(t, tokenABI.Methods["approve"].ID, approval.Data()[:4]) + } + deposit := <-service.sent + require.Equal(t, common.HexToAddress(testPortal), *deposit.To()) + portalABI, err := ierc20portal.IErc20PortalMetaData.GetAbi() + require.NoError(t, err) + require.Equal(t, portalABI.Methods["depositErc20Tokens"].ID, deposit.Data()[:4]) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, deposit.Hash().Hex(), result["transaction_hash"]) + if tt.wantReceipts == 0 { + require.Zero(t, service.inputBoxReads.Load()) + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.EqualValues(t, 1, service.inputBoxReads.Load()) + require.Equal(t, "0xa", service.inputBoxBlock.Load()) + require.Equal(t, "mined", result["status"]) + require.Equal(t, "0xa", result["block_number"]) + } + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/receipt.go b/cmd/cartesi-rollups-cli/root/deposit/receipt.go new file mode 100644 index 000000000..2858f357b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/receipt.go @@ -0,0 +1,71 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +// verifyDepositReceipt checks the input generated by the portal, not token +// Transfer events, which can also occur during other calls in the transaction. +func verifyDepositReceipt( + ctx context.Context, + client *ethclient.Client, + receipt *types.Receipt, + application, portal common.Address, + payload []byte, +) error { + app, err := iapplication.NewIApplication(application, client) + if err != nil { + return err + } + inputBoxAddress, err := app.GetInputBox(&bind.CallOpts{Context: ctx, BlockNumber: receipt.BlockNumber}) + if err != nil { + return fmt.Errorf("read application InputBox at receipt block: %w", err) + } + if inputBoxAddress == (common.Address{}) { + return fmt.Errorf("application returned a zero InputBox address") + } + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) + if err != nil { + return err + } + inputABI, err := inputs.InputsMetaData.GetAbi() + if err != nil { + return err + } + advance := inputABI.Methods["EvmAdvance"] + for _, log := range receipt.Logs { + if log == nil || log.Address != inputBoxAddress { + continue + } + event, err := inputBox.ParseInputAdded(*log) + if err != nil || event.AppContract != application || !bytes.HasPrefix(event.Input, advance.ID) { + continue + } + args := make(map[string]any) + if err := advance.Inputs.UnpackIntoMap(args, event.Input[len(advance.ID):]); err != nil { + continue + } + inputPayload, ok := args["payload"].([]byte) + index, indexOK := args["index"].(*big.Int) + if ok && indexOK && index.Cmp(event.Index) == 0 && args["appContract"] == application && args["msgSender"] == portal && + bytes.Equal(inputPayload, payload) { + return nil + } + } + return fmt.Errorf("receipt has no matching deposit InputAdded event") +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go b/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go new file mode 100644 index 000000000..e7dd31cfe --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go @@ -0,0 +1,218 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20portal" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +func TestDepositReceiptEvidence(t *testing.T) { + for _, tt := range []struct { + name string + change func(*testing.T, *depositRPC) + valid bool + }{ + {name: "matching input", valid: true}, + {name: "no events", change: func(_ *testing.T, r *depositRPC) { r.depositLogs = nil }}, + {name: "wrong input box", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Address = common.Address{} }}, + {name: "wrong outer application", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[1] = common.Hash{} }}, + {name: "wrong outer index", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[2] = common.Hash{} }}, + {name: "wrong event", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[0] = common.Hash{} }}, + {name: "malformed event", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Data = []byte{1} }}, + {name: "zero input box", change: func(_ *testing.T, r *depositRPC) { r.inputBoxResponse = make([]byte, common.HashLength) }}, + {name: "bad input box response", change: func(_ *testing.T, r *depositRPC) { r.inputBoxResponse = []byte{1} }}, + {name: "wrong input selector", change: func(t *testing.T, r *depositRPC) { + changeDepositInput(t, r.depositLogs[0], func(input []byte) []byte { input[0] ^= 1; return input }) + }}, + {name: "malformed input", change: func(t *testing.T, r *depositRPC) { + changeDepositInput(t, r.depositLogs[0], func(input []byte) []byte { return input[:5] }) + }}, + {name: "wrong inner application", change: changeAdvanceField(1, common.Address{})}, + {name: "wrong portal sender", change: changeAdvanceField(2, common.Address{})}, + {name: "wrong inner index", change: changeAdvanceField(6, big.NewInt(8))}, + {name: "wrong token", change: changePayloadByte(0)}, + {name: "wrong depositor", change: changePayloadByte(20)}, + {name: "wrong amount", change: changePayloadByte(71)}, + {name: "wrong execution data", change: func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[7] = append(args[7].([]byte), 1) }) + }}, + {name: "matching event after unrelated event", valid: true, change: func(_ *testing.T, r *depositRPC) { + wrong := *r.depositLogs[0] + wrong.Data = []byte{1} + r.depositLogs = append([]*types.Log{&wrong}, r.depositLogs...) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + if tt.change != nil { + tt.change(t, rpc) + } + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t) + require.Len(t, rpc.sent, 1) + tx := <-rpc.sent + require.Zero(t, rpc.estimates.Load()) + require.EqualValues(t, 1, rpc.receipts.Load()) + require.Equal(t, "0xa", rpc.inputBoxBlock.Load()) + if tt.valid { + require.NoError(t, err) + require.Contains(t, output, `"status": "mined"`) + } else { + require.ErrorContains(t, err, "deposit could not be confirmed") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, output, `"status": "mined"`) + } + }) + } +} + +func TestApprovalReceiptEvidence(t *testing.T) { + for _, tt := range []struct { + name string + change func(*depositRPC) + valid bool + }{ + {name: "matching approval", valid: true}, + {name: "no events", change: func(r *depositRPC) { r.approvalLogs = nil }}, + {name: "wrong token", change: func(r *depositRPC) { r.approvalLogs[0].Address = common.Address{} }}, + {name: "wrong event", change: func(r *depositRPC) { r.approvalLogs[0].Topics[0] = common.Hash{} }}, + {name: "wrong owner", change: func(r *depositRPC) { r.approvalLogs[0].Topics[1] = common.Hash{} }}, + {name: "wrong spender", change: func(r *depositRPC) { r.approvalLogs[0].Topics[2] = common.Hash{} }}, + {name: "wrong amount", change: func(r *depositRPC) { r.approvalLogs[0].Data[31]++ }}, + {name: "missing amount", change: func(r *depositRPC) { r.approvalLogs[0].Data = nil }}, + {name: "malformed data", change: func(r *depositRPC) { r.approvalLogs[0].Data = []byte{1} }}, + {name: "matching event after unrelated event", valid: true, change: func(r *depositRPC) { + wrong := *r.approvalLogs[0] + wrong.Data = nil + r.approvalLogs = append([]*types.Log{&wrong}, r.approvalLogs...) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + if tt.change != nil { + tt.change(rpc) + } + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t, approveFlag) + require.Zero(t, rpc.estimates.Load()) + if tt.valid { + require.NoError(t, err) + require.Len(t, rpc.sent, 2) + require.Contains(t, output, `"approve_transaction_hash"`) + } else { + require.Len(t, rpc.sent, 1, "a deposit must not follow an unconfirmed approval") + require.ErrorContains(t, err, (<-rpc.sent).Hash().Hex()) + require.ErrorContains(t, err, "no matching Approval event") + require.NotContains(t, output, `"status": "mined"`) + require.Zero(t, rpc.inputBoxReads.Load()) + } + }) + } +} + +func TestDepositNoWaitSkipsReceiptEvidence(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + rpc.depositLogs = nil + rpc.inputBoxResponse = nil + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t, "--no-wait") + require.NoError(t, err) + require.Contains(t, output, `"status": "broadcast"`) + require.Zero(t, rpc.receipts.Load()) + require.Zero(t, rpc.estimates.Load()) + require.Zero(t, rpc.inputBoxReads.Load()) +} + +func TestDepositReceiptWithExecutionData(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + execData := []byte{0xde, 0xad, 0xbe, 0xef} + changeAdvance(t, rpc.depositLogs[0], func(args []any) { args[7] = append(args[7].([]byte), execData...) }) + output, err := runDeposit(t, "--exec-data", "0xdeadbeef") + require.NoError(t, err) + require.Contains(t, output, `"status": "mined"`) + require.Len(t, rpc.sent, 1) + portalABI, err := ierc20portal.IErc20PortalMetaData.GetAbi() + require.NoError(t, err) + method := portalABI.Methods["depositErc20Tokens"] + tx := <-rpc.sent + require.Equal(t, method.ID, tx.Data()[:len(method.ID)]) + args, err := method.Inputs.Unpack(tx.Data()[len(method.ID):]) + require.NoError(t, err) + require.Equal(t, execData, args[3]) +} + +func TestParseAmountUint256(t *testing.T) { + limit := new(big.Int).Lsh(big.NewInt(1), 256) + maxAmount := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1)) + amount, err := parseAmount(maxAmount.String()) + require.NoError(t, err) + require.Equal(t, maxAmount, amount) + _, err = parseAmount(limit.String()) + require.ErrorContains(t, err, "uint256") +} + +func runDeposit(t *testing.T, extra ...string) (string, error) { + t.Helper() + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + args := append([]string{"erc20", testApplication, "--portal", testPortal, "--token", testToken, + "--amount", "100", "--yes", "--json"}, extra...) + Cmd.SetArgs(args) + err := Cmd.ExecuteContext(t.Context()) + return stdout.String(), err +} + +func changeDepositInput(t *testing.T, log *types.Log, change func([]byte) []byte) { + t.Helper() + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + args := parsed.Events["InputAdded"].Inputs.NonIndexed() + values, err := args.Unpack(log.Data) + require.NoError(t, err) + log.Data, err = args.Pack(change(values[0].([]byte))) + require.NoError(t, err) +} + +func changeAdvance(t *testing.T, log *types.Log, change func([]any)) { + t.Helper() + parsed, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + method := parsed.Methods["EvmAdvance"] + changeDepositInput(t, log, func(input []byte) []byte { + args, err := method.Inputs.Unpack(input[len(method.ID):]) + require.NoError(t, err) + change(args) + input, err = parsed.Pack("EvmAdvance", args...) + require.NoError(t, err) + return input + }) +} + +func changeAdvanceField(field int, value any) func(*testing.T, *depositRPC) { + return func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[field] = value }) + } +} + +func changePayloadByte(offset int) func(*testing.T, *depositRPC) { + return func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[7].([]byte)[offset] ^= 1 }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/execute/execute.go b/cmd/cartesi-rollups-cli/root/execute/execute.go index f9891c60c..f0c35d958 100644 --- a/cmd/cartesi-rollups-cli/root/execute/execute.go +++ b/cmd/cartesi-rollups-cli/root/execute/execute.go @@ -4,135 +4,262 @@ package execute import ( + "bytes" + "context" "encoding/json" + "errors" "fmt" + "io" "os" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/repository/factory" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" - "github.com/cartesi/rollups-node/pkg/ethutil" ) -var Cmd = &cobra.Command{ - Use: "execute [app-name-or-address] [output-index]", - Short: "Executes a voucher", - Example: examples, - Args: cobra.ExactArgs(2), //nolint:mnd - Run: run, - Long: ` -Supported Environment Variables: - CARTESI_DATABASE_CONNECTION Database connection string - CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint`, -} +var Cmd = newCommand() -const examples = `# Executes voucher/output with index 5: -cartesi-rollups-cli execute echo-dapp 5 +type options struct { + proofFile string + skipConfirmation bool +} -# Executes voucher/output with index 3 using application address: -cartesi-rollups-cli execute 0x1234567890123456789012345678901234567890 3 +func newCommand() *cobra.Command { + var opts options + cmd := &cobra.Command{ + Use: "execute [app-name-or-address] [output-index]", + Short: "Execute a proven application output", + Example: examples, + Args: cobra.ExactArgs(2), //nolint:mnd // Application and output index. + RunE: opts.run, + PreRunE: func(command *cobra.Command, _ []string) error { + if command.Flags().Changed("proof-file") && opts.proofFile == "" { + return fmt.Errorf("--proof-file cannot be empty") + } + return nil + }, + Long: `Execute the output at the application-wide output index. -# Execute without confirmation prompt: -cartesi-rollups-cli execute echo-dapp 5 --yes` +Without --proof-file, read the output and its proof from the local database. +With --proof-file, read a JSON object with raw_data and output_hashes_siblings. +raw_data must contain the complete 0x-prefixed output bytes, not decoded payload. +output_hashes_siblings must contain the ordered 0x-prefixed 32-byte proof hashes. +The output index remains the second positional argument; do not put it in the file. -var ( - skipConfirmation bool - asJSONParam bool -) +An application address plus --proof-file needs no database or node JSON-RPC API. +An application name still needs the database to resolve its address. -func init() { - Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") - Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Required for local output or application-name lookup + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum HTTP endpoint`, + } + cmd.Flags().StringVar(&opts.proofFile, "proof-file", "", "JSON file with raw_data and output_hashes_siblings") + cmd.Flags().BoolVarP(&opts.skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().Bool("json", false, "Print result as JSON") + cli.AddTransactionFlags(cmd) - origHelpFunc := Cmd.HelpFunc() - Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { - command.Flags().Lookup("verbose").Hidden = false - command.Flags().Lookup("database-connection").Hidden = false - command.Flags().Lookup("blockchain-http-endpoint").Hidden = false - origHelpFunc(command, strings) + originalHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + for _, name := range []string{"verbose", "database-connection", "blockchain-http-endpoint"} { + if flag := command.Flags().Lookup(name); flag != nil { + flag.Hidden = false + } + } + originalHelp(command, args) }) + return cmd } -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() +const examples = `# Execute output 5 with its proof from the local database: +cartesi-rollups-cli execute echo-dapp 5 - nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) +# Export a proof from the node JSON-RPC API: +cartesi-rollups-cli read outputs echo-dapp 5 --jsonrpc | jq '.data | {raw_data, output_hashes_siblings}' > output-proof.json - outputIndex, err := config.ToUint64FromDecimalOrHexString(args[1]) - cobra.CheckErr(err) +# Execute the exported output without a database or node JSON-RPC connection: +cartesi-rollups-cli execute 0x1234567890123456789012345678901234567890 5 --proof-file output-proof.json --yes` - dsn, err := config.GetDatabaseConnection() - cobra.CheckErr(err) +type executionInput struct { + applicationAddress common.Address + output []byte + proof iapplication.OutputValidityProof +} +func (o *options) run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + outputIndex, err := config.ToUint64FromDecimalOrHexString(args[1]) + if err != nil { + return err + } + input, err := resolveExecution(ctx, nameOrAddress, outputIndex, o.proofFile) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } + client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + if err != nil { + return err + } + defer client.Close() + chainID, err := client.ChainID(ctx) + if err != nil { + return err + } + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return err + } + if !o.skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to execute application %v (%v) output index %v with account %v\n", + nameOrAddress, input.applicationAddress, outputIndex, txOpts.From) + if err != nil { + return err + } + confirmed, err := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + appContract, err := iapplication.NewIApplication(input.applicationAddress, client) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.ExecuteOutput(opts, input.output, input.proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) + } + if receipt != nil { + executed := false + for _, log := range receipt.Logs { + if log == nil || log.Address != input.applicationAddress { + continue + } + event, err := appContract.ParseOutputExecuted(*log) + if err == nil && event.OutputIndex == outputIndex && bytes.Equal(event.Output, input.output) { + executed = true + break + } + } + if !executed { + return fmt.Errorf("transaction %s mined, but its receipt has no matching OutputExecuted event", tx.Hash()) + } + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} +func resolveExecution(ctx context.Context, nameOrAddress string, outputIndex uint64, proofFile string) (executionInput, error) { + input := executionInput{proof: iapplication.OutputValidityProof{OutputIndex: outputIndex}} + if proofFile != "" { + output, siblings, err := loadOutputProof(proofFile) + if err != nil { + return executionInput{}, err + } + address, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return executionInput{}, err + } + input.applicationAddress = address + input.output = output + input.proof.OutputHashesSiblings = siblings + return input, nil + } + dsn, err := config.GetDatabaseConnection() + if err != nil { + return executionInput{}, err + } repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) - cobra.CheckErr(err) + if err != nil { + return executionInput{}, err + } defer repo.Close() - output, err := repo.GetOutput(ctx, nameOrAddress, outputIndex) - cobra.CheckErr(err) - + if err != nil { + return executionInput{}, err + } if output == nil { - fmt.Fprintf(os.Stderr, "The output with index %d was not found in the database\n", outputIndex) - repo.Close() - os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + return executionInput{}, fmt.Errorf("output with index %d was not found in the database", outputIndex) } - - app, err := repo.GetApplication(ctx, nameOrAddress) - cobra.CheckErr(err) - if len(output.OutputHashesSiblings) == 0 { - fmt.Fprintf(os.Stderr, "The output with index %d has no associated proof yet\n", outputIndex) - os.Exit(1) + return executionInput{}, fmt.Errorf("output with index %d has no associated proof yet", outputIndex) } + app, err := repo.GetApplication(ctx, nameOrAddress) + if err != nil { + return executionInput{}, err + } + if app == nil { + return executionInput{}, fmt.Errorf("application %q not found", nameOrAddress) + } + input.applicationAddress = app.IApplicationAddress + input.output = output.RawData + input.proof.OutputHashesSiblings = make([][32]byte, len(output.OutputHashesSiblings)) + for i, hash := range output.OutputHashesSiblings { + input.proof.OutputHashesSiblings[i] = hash + } + return input, nil +} - client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) - - chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) - - txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) - - if !skipConfirmation { - fmt.Printf("Preparing to execute application %v (%v) output index %v with account %v\n", - app.Name, app.IApplicationAddress, outputIndex, txOpts.From) - - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) - if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) +func loadOutputProof(path string) ([]byte, [][32]byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("read output proof file: %w", err) + } + var data struct { + RawData string `json:"raw_data"` + OutputHashesSiblings []string `json:"output_hashes_siblings"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&data); err != nil { + return nil, nil, fmt.Errorf("parse output proof file: %w", err) + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err != nil { + return nil, nil, fmt.Errorf("parse output proof file trailing data: %w", err) } + return nil, nil, fmt.Errorf("output proof file must contain exactly one JSON object") } - - txHash, err := ethutil.ExecuteOutput( - ctx, - client, - ethutil.NewStaticTransactOptsFactory(txOpts), - app.IApplicationAddress, - outputIndex, - output.RawData, - output.OutputHashesSiblings, - ) - cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - - if asJSONParam { - result := cli.ExecuteResult{ - TransactionHash: txHash.Hex(), + output, err := hexutil.Decode(data.RawData) + if err != nil { + return nil, nil, fmt.Errorf("invalid raw_data: %w", err) + } + if len(output) == 0 { + return nil, nil, fmt.Errorf("raw_data cannot be empty") + } + if len(data.OutputHashesSiblings) == 0 { + return nil, nil, fmt.Errorf("output_hashes_siblings cannot be empty") + } + siblings := make([][32]byte, len(data.OutputHashesSiblings)) + for i, encoded := range data.OutputHashesSiblings { + decoded, err := hexutil.Decode(encoded) + if err != nil { + return nil, nil, fmt.Errorf("invalid output_hashes_siblings[%d]: %w", i, err) + } + if len(decoded) != common.HashLength { + return nil, nil, fmt.Errorf("output_hashes_siblings[%d] must be %d bytes, got %d", i, common.HashLength, len(decoded)) } - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("Voucher executed tx-hash: %v\n", txHash) + copy(siblings[i][:], decoded) } + return output, siblings, nil } diff --git a/cmd/cartesi-rollups-cli/root/execute/execute_test.go b/cmd/cartesi-rollups-cli/root/execute/execute_test.go new file mode 100644 index 000000000..9fe89763b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/execute/execute_test.go @@ -0,0 +1,412 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package execute + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/outputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testChainID = 31337 + testGasLimit = 90000 + proofFileFlag = "--proof-file" + jsonFlag = "--json" + yesFlag = "--yes" +) + +func writeProofFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "output-proof.json") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func proofDocument(t *testing.T, output []byte, siblings []common.Hash) string { + t.Helper() + data, err := json.Marshal(struct { + RawData string `json:"raw_data"` + OutputHashesSiblings []common.Hash `json:"output_hashes_siblings"` + }{hexutil.Encode(output), siblings}) + require.NoError(t, err) + return string(data) +} + +func TestLoadOutputProofPreservesBytesAndOrder(t *testing.T) { + t.Parallel() + output := []byte{0, 1, 2, 3, 0xfe, 0xff} + siblings := []common.Hash{common.HexToHash("0x1234"), common.HexToHash("0xabcd")} + path := writeProofFile(t, " \n"+proofDocument(t, output, siblings)+"\n\t") + gotOutput, gotSiblings, err := loadOutputProof(path) + require.NoError(t, err) + require.Equal(t, output, gotOutput) + require.Equal(t, [][32]byte{siblings[0], siblings[1]}, gotSiblings) +} + +func TestLoadOutputProofRejectsInvalidFiles(t *testing.T) { + t.Parallel() + hash := common.HexToHash("0x01").Hex() + for name, contents := range map[string]string{ + "empty file": "", + "null object": "null", + "array object": "[]", + "missing fields": "{}", + "missing output": `{"output_hashes_siblings":["` + hash + `"]}`, + "empty output": `{"raw_data":"0x","output_hashes_siblings":["` + hash + `"]}`, + "null output": `{"raw_data":null,"output_hashes_siblings":["` + hash + `"]}`, + "missing output prefix": `{"raw_data":"aabb","output_hashes_siblings":["` + hash + `"]}`, + "invalid output hex": `{"raw_data":"0xgg","output_hashes_siblings":["` + hash + `"]}`, + "odd output hex": `{"raw_data":"0xabc","output_hashes_siblings":["` + hash + `"]}`, + "missing siblings": `{"raw_data":"0xaa"}`, + "null siblings": `{"raw_data":"0xaa","output_hashes_siblings":null}`, + "empty siblings": `{"raw_data":"0xaa","output_hashes_siblings":[]}`, + "null sibling": `{"raw_data":"0xaa","output_hashes_siblings":[null]}`, + "non-string sibling": `{"raw_data":"0xaa","output_hashes_siblings":[1]}`, + "empty sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0x"]}`, + "missing sibling prefix": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash[2:] + `"]}`, + "short sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0xab"]}`, + "long sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0x` + strings.Repeat("ab", 33) + `"]}`, + "invalid sibling hex": `{"raw_data":"0xaa","output_hashes_siblings":["0x` + strings.Repeat("gg", 32) + `"]}`, + "unknown index": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"],"output_index":7}`, + "trailing object": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}{}`, + "trailing null": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}null`, + "trailing garbage": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}garbage`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + output, siblings, err := loadOutputProof(writeProofFile(t, contents)) + require.Error(t, err) + require.Nil(t, output) + require.Nil(t, siblings) + }) + } + _, _, err := loadOutputProof(filepath.Join(t.TempDir(), "missing.json")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// executeRPC is a local Ethereum JSON-RPC server for the real generated binding. +// It does not implement a node JSON-RPC API or connect to a blockchain. +type executeRPC struct { + sent chan *types.Transaction + chainReads atomic.Int32 + estimates atomic.Int32 + receipts atomic.Int32 + failed bool + logs []*types.Log +} + +func (r *executeRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // Ethereum RPC method eth_chainId. + r.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(testChainID)), nil +} + +func (*executeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*executeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (*executeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (*executeRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return 0, nil +} + +func (r *executeRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, nil +} + +func (r *executeRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), nil +} + +func (r *executeRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + return &types.Receipt{TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: r.logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit}, nil +} + +func outputExecutedLog(t *testing.T, application common.Address, index uint64, output []byte) *types.Log { + t.Helper() + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["OutputExecuted"] + data, err := event.Inputs.NonIndexed().Pack(output) + require.NoError(t, err) + return &types.Log{ + Address: application, + Topics: []common.Hash{event.ID, common.BigToHash(new(big.Int).SetUint64(index))}, + Data: data, + } +} + +func setupExecuteRPC(t *testing.T, service *executeRPC) common.Address { + t.Helper() + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + // This value fails configuration parsing. A successful address/file command + // therefore proves that it did not read the database setting or open a DB. + t.Setenv(config.DATABASE_CONNECTION, "://invalid-database") + return crypto.PubkeyToAddress(key.PublicKey) +} + +func TestCommandExecutesProofFileWithoutDatabase(t *testing.T) { + outputABI, err := outputs.OutputsMetaData.GetAbi() + require.NoError(t, err) + output, err := outputABI.Pack("Voucher", common.HexToAddress("0x02"), big.NewInt(3), []byte{0xaa, 0xbb}) + require.NoError(t, err) + // The CLI must submit complete proven bytes, without decoding and rebuilding + // the output or dropping any committed suffix. + output = append(output, 0xcc, 0xdd) + siblings := []common.Hash{common.HexToHash("0x11"), common.HexToHash("0x22")} + file := writeProofFile(t, proofDocument(t, output, siblings)) + for _, tt := range []struct { + name string + manualGas bool + noWait bool + failed bool + }{ + {name: "estimate and wait"}, + {name: "manual gas and wait", manualGas: true}, + {name: "manual gas failed receipt", manualGas: true, failed: true}, + {name: "estimated no wait", noWait: true}, + {name: "manual gas no wait", manualGas: true, noWait: true}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &executeRPC{ + sent: make(chan *types.Transaction, 1), failed: tt.failed, + logs: []*types.Log{outputExecutedLog(t, common.HexToAddress(testApplication), 7, output)}, + } + signer := setupExecuteRPC(t, service) + if tt.manualGas { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "0x7", proofFileFlag, file, yesFlag, jsonFlag} + if tt.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1, "command error: %v", err) + tx := <-service.sent + if tt.failed { + require.ErrorContains(t, err, "receipt status 0") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + } else { + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if tt.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + } + } + if tt.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if tt.manualGas { + require.Zero(t, service.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + } else { + require.EqualValues(t, 1, service.estimates.Load()) + } + require.Equal(t, common.HexToAddress(testApplication), *tx.To()) + from, err := types.Sender(types.LatestSignerForChainID(big.NewInt(testChainID)), tx) + require.NoError(t, err) + require.Equal(t, signer, from) + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + method, err := appABI.MethodById(tx.Data()[:4]) + require.NoError(t, err) + require.Equal(t, "executeOutput", method.Name) + callArgs, err := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, err) + require.Equal(t, output, callArgs[0]) + proof := abi.ConvertType(callArgs[1], new(iapplication.OutputValidityProof)).(*iapplication.OutputValidityProof) + require.Equal(t, uint64(7), proof.OutputIndex) + require.Equal(t, [][32]byte{siblings[0], siblings[1]}, proof.OutputHashesSiblings) + }) + } +} + +func TestCommandRequiresMatchingOutputExecuted(t *testing.T) { + application := common.HexToAddress(testApplication) + output := []byte{0xaa, 0xbb, 0xcc} + file := writeProofFile(t, proofDocument(t, output, []common.Hash{common.HexToHash("0x11")})) + valid := outputExecutedLog(t, application, 7, output) + wrongEmitter := outputExecutedLog(t, common.HexToAddress("0x02"), 7, output) + wrongIndex := outputExecutedLog(t, application, 8, output) + wrongBytes := outputExecutedLog(t, application, 7, []byte{0xaa, 0xbb}) + malformed := &types.Log{Address: application, Topics: valid.Topics, Data: []byte{1}} + missingIndex := &types.Log{Address: application, Topics: valid.Topics[:1], Data: valid.Data} + unrelated := &types.Log{Address: application, Topics: []common.Hash{common.HexToHash("0x03")}} + for _, test := range []struct { + name string + logs []*types.Log + noWait bool + wantError bool + }{ + {name: "empty receipt", logs: []*types.Log{}, wantError: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantError: true}, + {name: "wrong index", logs: []*types.Log{wrongIndex}, wantError: true}, + {name: "wrong bytes", logs: []*types.Log{wrongBytes}, wantError: true}, + {name: "malformed data", logs: []*types.Log{malformed}, wantError: true}, + {name: "missing index topic", logs: []*types.Log{missingIndex}, wantError: true}, + {name: "unrelated event", logs: []*types.Log{unrelated}, wantError: true}, + {name: "nil log", logs: []*types.Log{nil}, wantError: true}, + {name: "later matching event", + logs: []*types.Log{wrongEmitter, wrongIndex, wrongBytes, malformed, missingIndex, unrelated, nil, valid}}, + {name: "no wait does not require event", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1), logs: test.logs} + setupExecuteRPC(t, service) + // A manual limit permits broadcast even when estimation would have + // rejected a wrong destination. Receipt evidence must still be checked. + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + cmd := newCommand() + cmd.SilenceUsage = true // Match the root CLI's error-output policy. + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", proofFileFlag, file, yesFlag, jsonFlag} + if test.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Zero(t, service.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantError { + require.ErrorContains(t, err, "no matching OutputExecuted event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if test.noWait { + require.Equal(t, "broadcast", result["status"]) + } else { + require.Equal(t, "mined", result["status"]) + } + }) + } +} + +func TestCommandRejectsInvalidProofBeforeEthereum(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + for _, application := range []string{testApplication, "echo-dapp"} { + t.Run(application, func(t *testing.T) { + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{application, "7", proofFileFlag, writeProofFile(t, `{"raw_data":"0xaa","unknown":1}`)}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "unknown field") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) + }) + } +} + +func TestCommandWithoutFileStillRequiresDatabase(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testApplication, "7", yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), config.DATABASE_CONNECTION) + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} + +func TestCommandRejectsEmptyProofPathBeforeLookup(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testApplication, "7", proofFileFlag, ""}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "--proof-file cannot be empty") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} + +func TestCommandProofFileNameStillRequiresDatabase(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + file := writeProofFile(t, proofDocument(t, []byte{0xaa}, []common.Hash{common.HexToHash("0x01")})) + cmd.SetArgs([]string{"echo-dapp", "7", proofFileFlag, file, yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "resolving application") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} diff --git a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go index cd63eb2fd..c71632267 100644 --- a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go +++ b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go @@ -6,10 +6,10 @@ package foreclose import ( "encoding/json" "fmt" - "os" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" @@ -24,7 +24,7 @@ var Cmd = &cobra.Command{ Short: "Foreclose an application (guardian-only)", Example: examples, Args: cobra.ExactArgs(1), - Run: run, + RunE: run, Long: ` Calls IApplication.foreclose() on the application contract. The transaction must be signed by the guardian wallet configured at deploy time, otherwise it @@ -61,6 +61,7 @@ var ( func init() { Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -71,72 +72,92 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) - cobra.CheckErr(err) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) + if err != nil { + return err + } appContract, err := iapplication.NewIApplication(appAddr, client) - cobra.CheckErr(err) + if err != nil { + return err + } // Surface the guardian / signer mismatch early as a hint, instead of letting // the on-chain revert produce an opaque "NotGuardian" error. guardian, err := appContract.GetGuardian(&bind.CallOpts{Context: ctx}) - cobra.CheckErr(err) + if err != nil { + return err + } if guardian != txOpts.From { - fmt.Fprintf(os.Stderr, + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: signer %s does not match the application guardian %s — foreclose() will revert with NotGuardian\n", txOpts.From, guardian) + if err != nil { + return err + } } if !skipConfirmation { - fmt.Printf("Preparing to foreclose application %v with signer %v\n", + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to foreclose application %v with signer %v\n", appAddr, txOpts.From) + if err != nil { + return err + } - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err } } - tx, err := appContract.Foreclose(txOpts) - // go-ethereum's binding returns (signedTx, sendErr) when signing - // succeeded but the broadcast/response read failed — the tx may already - // be in the mempool. Surface the hash on stderr so the operator can find - // it even when CheckErr below aborts. - if tx != nil { - fmt.Fprintf(os.Stderr, "broadcast attempt sent — tx hash %s\n", tx.Hash().Hex()) + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.Foreclose(opts) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) } - cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - txHash := tx.Hash() if asJSONParam { result := struct { - TransactionHash string `json:"transaction_hash"` + cli.TransactionResult ApplicationAddr common.Address `json:"application_address"` - }{TransactionHash: txHash.Hex(), ApplicationAddr: appAddr} - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("Foreclose tx-hash: %v\n", txHash) + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) } + return cli.WriteTransactionResult(cmd, tx, receipt) } diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go index 05a995b9a..d47f240c5 100644 --- a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go @@ -9,8 +9,10 @@ import ( "fmt" "os" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" @@ -25,7 +27,7 @@ var Cmd = &cobra.Command{ Short: "Anchor the accounts-drive Merkle root on a foreclosed application", Example: examples, Args: cobra.ExactArgs(1), - Run: run, + RunE: run, Long: ` Calls IApplication.proveAccountsDriveMerkleRoot(accountsDriveMerkleRoot, proof). This must be done ONCE per foreclosed application before any user can call @@ -72,6 +74,7 @@ func init() { cobra.CheckErr(Cmd.MarkFlagRequired("proof-file")) Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -82,67 +85,107 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } root, proof, err := loadProof(proofFileParam) - cobra.CheckErr(err) + if err != nil { + return err + } appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) - cobra.CheckErr(err) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) + if err != nil { + return err + } appContract, err := iapplication.NewIApplication(appAddr, client) - cobra.CheckErr(err) + if err != nil { + return err + } if !skipConfirmation { - fmt.Printf("Preparing to prove the accounts-drive Merkle root for application %v\n"+ + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to prove the accounts-drive Merkle root for application %v\n"+ " signer: %v\n"+ " root: 0x%x\n"+ " proof size: %d siblings\n", appAddr, txOpts.From, root, len(proof)) - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) + if err != nil { + return err + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err } } - tx, err := appContract.ProveAccountsDriveMerkleRoot(txOpts, root, proof) - // go-ethereum's binding returns (signedTx, sendErr) when signing - // succeeded but the broadcast/response read failed — the tx may already - // be in the mempool. Surface the hash on stderr so the operator can find - // it even when CheckErr below aborts. - if tx != nil { - fmt.Fprintf(os.Stderr, "broadcast attempt sent — tx hash %s\n", tx.Hash().Hex()) + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.ProveAccountsDriveMerkleRoot(opts, root, proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) + } + if receipt != nil && !hasMatchingDriveRootEvent(appContract, receipt, appAddr, root) { + return fmt.Errorf("transaction %s mined, but its receipt has no matching AccountsDriveMerkleRootProved event", tx.Hash()) } - cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - txHash := tx.Hash() if asJSONParam { result := struct { - TransactionHash string `json:"transaction_hash"` + cli.TransactionResult ApplicationAddr common.Address `json:"application_address"` - }{TransactionHash: txHash.Hex(), ApplicationAddr: appAddr} - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("prove-drive-root tx-hash: %v\n", txHash) + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} + +func hasMatchingDriveRootEvent( + application *iapplication.IApplication, + receipt *types.Receipt, + address common.Address, + root [32]byte, +) bool { + for _, log := range receipt.Logs { + // The event has one non-indexed bytes32 field. Reject missing data even + // for a zero root; the binding otherwise leaves that field zero-valued. + if log == nil || log.Address != address || len(log.Data) != common.HashLength { + continue + } + event, err := application.ParseAccountsDriveMerkleRootProved(*log) + if err == nil && event.AccountsDriveMerkleRoot == root { + return true + } } + return false } func loadProof(path string) ([32]byte, [][32]byte, error) { diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go b/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go new file mode 100644 index 000000000..a63be077f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go @@ -0,0 +1,177 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package provedriveroot + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +func TestCommandRequiresMatchingDriveRootEvent(t *testing.T) { + address := common.HexToAddress("0x1234") + root := common.HexToHash("0x42") + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["AccountsDriveMerkleRootProved"] + matching := &types.Log{Address: address, Topics: []common.Hash{event.ID}, Data: root.Bytes()} + wrongEmitter := &types.Log{Address: common.HexToAddress("0x5678"), Topics: matching.Topics, Data: matching.Data} + wrongRoot := &types.Log{Address: address, Topics: matching.Topics, Data: common.HexToHash("0x43").Bytes()} + wrongTopic := &types.Log{Address: address, Topics: []common.Hash{common.HexToHash("0x01")}, Data: matching.Data} + malformed := &types.Log{Address: address, Topics: matching.Topics, Data: []byte{0x42}} + emptyData := &types.Log{Address: address, Topics: matching.Topics} + extraData := &types.Log{Address: address, Topics: matching.Topics, Data: append(root.Bytes(), root.Bytes()...)} + for _, test := range []struct { + name string + logs []*types.Log + estimated bool + noWait bool + zeroRoot bool + wantErr bool + }{ + {name: "matching manual gas", logs: []*types.Log{matching}}, + {name: "matching estimated gas", logs: []*types.Log{matching}, estimated: true}, + {name: "empty receipt at EOA", logs: []*types.Log{}, wantErr: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantErr: true}, + {name: "wrong root", logs: []*types.Log{wrongRoot}, wantErr: true}, + {name: "wrong event", logs: []*types.Log{wrongTopic}, wantErr: true}, + {name: "malformed data", logs: []*types.Log{malformed}, wantErr: true}, + {name: "empty data for zero root", logs: []*types.Log{emptyData}, zeroRoot: true, wantErr: true}, + {name: "extra data", logs: []*types.Log{extraData}, wantErr: true}, + {name: "later match", logs: []*types.Log{nil, wrongEmitter, wrongRoot, wrongTopic, malformed, matching}}, + {name: "no wait with empty receipt", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &driveRootRPC{logs: test.logs, sent: make(chan *types.Transaction, 1), emptyCode: len(test.logs) == 0} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", service)) + defer server.Stop() + httpServer := httptest.NewServer(server) + defer httpServer.Close() + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, httpServer.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.DATABASE_CONNECTION, "invalid") + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + if test.estimated { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + } + Cmd.Flags().VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) + Cmd.SilenceUsage = true + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + proofRoot := root + if test.zeroRoot { + proofRoot = common.Hash{} + } + proof, err := json.Marshal(proveDriveRootJSON{AccountsDriveMerkleRoot: proofRoot.Hex(), Proof: []string{root.Hex()}}) + require.NoError(t, err) + args := []string{address.Hex(), "--proof-file", writeProofFile(t, string(proof)), "--yes", "--json"} + if test.noWait { + args = append(args, "--no-wait") + } + Cmd.SetArgs(args) + err = Cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.EqualValues(t, 123456, tx.Gas()) + if test.estimated { + require.EqualValues(t, 1, service.estimates.Load()) + } else { + require.Zero(t, service.estimates.Load()) + } + require.Contains(t, stderr.String(), tx.Hash().Hex()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantErr { + require.ErrorContains(t, err, "no matching AccountsDriveMerkleRootProved event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + status := "mined" + if test.noWait { + status = "broadcast" + } + require.Equal(t, status, result["status"]) + }) + } +} + +type driveRootRPC struct { + logs []*types.Log + sent chan *types.Transaction + emptyCode bool + estimates atomic.Int32 + receipts atomic.Int32 +} + +func (*driveRootRPC) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*driveRootRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: 1_000_000} +} + +func (*driveRootRPC) GasPrice(context.Context) *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1)) +} + +func (r *driveRootRPC) GetCode(context.Context, common.Address, string) hexutil.Bytes { + if r.emptyCode { + return hexutil.Bytes{} + } + return hexutil.Bytes{0x01} +} + +func (*driveRootRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (r *driveRootRPC) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + r.estimates.Add(1) + return 123456 +} + +func (r *driveRootRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), nil +} + +func (r *driveRootRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + r.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(10), Logs: r.logs} +} diff --git a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go index d1d2b712c..e17c6dc33 100644 --- a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go +++ b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go @@ -5,6 +5,7 @@ package matchadvances import ( "bytes" + "context" "encoding/json" "fmt" "os" @@ -17,19 +18,31 @@ import ( "github.com/spf13/cobra" ) +const ( + listArgCount = 4 + getArgCount = 6 +) + var Cmd = &cobra.Command{ - Use: "match_advances [parent]", + Use: "match_advances [transaction hash log index]", Short: "Reads match advances", Example: examples, - Args: cobra.RangeArgs(4, 5), //nolint: mnd - Run: run, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != listArgCount && len(args) != getArgCount { + return fmt.Errorf("requires %d arguments to list a match's advances, or %d with transaction hash and log index to read one", + listArgCount, getArgCount) + } + return nil + }, + Run: run, Long: ` Arguments: application name or address decimal or hex encoded hex encoded hex encoded - [parent] hex encoded + [transaction hash] hex encoded; provide together with log index + [log index] decimal or hex encoded block log index Supported Environment Variables: CARTESI_JSONRPC_API_URL JSON-RPC API URL @@ -37,10 +50,10 @@ Supported Environment Variables: } //nolint:lll // Long CLI examples are kept copy-pasteable. -const examples = `# Read specific match advanced: -cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 +const examples = `# Read one match advance by transaction hash and block log index: +cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 0xa24a91c7ce97fb16b2f679875966dc50f84747fd54e006763ed0dd702c260370 3 -# Read all match advances: +# Read all advances for one match (includes each event's transaction hash and log index): cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 # Read all match advances with pagination: @@ -89,30 +102,7 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) defer readServ.Close() - var result json.RawMessage - if len(args) >= 5 { //nolint:mnd // Five positional arguments select the get operation. - var params api.GetMatchAdvanceParams - params.Application = args[0] - params.EpochIndex, err = config.AsHexString(args[1]) - cobra.CheckErr(err) - params.TournamentAddress = args[2] - params.IDHash = args[3] - params.Parent = args[4] - - result, err = readServ.GetMatchAdvanced(ctx, params) - } else { - var params api.ListMatchAdvancesParams - params.Application = args[0] - params.EpochIndex, err = config.AsHexString(args[1]) - cobra.CheckErr(err) - params.TournamentAddress = args[2] - params.IDHash = args[3] - params.Limit = limit - params.Offset = offset - params.Descending = descending - - result, err = readServ.ListMatchAdvances(ctx, params) - } + result, err := readMatchAdvances(ctx, readServ, args) cobra.CheckErr(err) var out bytes.Buffer @@ -123,3 +113,24 @@ func run(cmd *cobra.Command, args []string) { _, err = out.WriteTo(os.Stdout) cobra.CheckErr(err) } + +func readMatchAdvances(ctx context.Context, readServ service.ReadService, args []string) (json.RawMessage, error) { + epochIndex, err := config.AsHexString(args[1]) + if err != nil { + return nil, err + } + if len(args) == getArgCount { + logIndex, err := config.AsHexString(args[getArgCount-1]) + if err != nil { + return nil, err + } + return readServ.GetMatchAdvanced(ctx, api.GetMatchAdvanceParams{ + Application: args[0], EpochIndex: epochIndex, TournamentAddress: args[2], IDHash: args[3], + TxHash: args[listArgCount], LogIndex: logIndex, + }) + } + return readServ.ListMatchAdvances(ctx, api.ListMatchAdvancesParams{ + Application: args[0], EpochIndex: epochIndex, TournamentAddress: args[2], IDHash: args[3], + Limit: limit, Offset: offset, Descending: descending, + }) +} diff --git a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go new file mode 100644 index 000000000..26de105f8 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go @@ -0,0 +1,126 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package matchadvances + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestMatchAdvancesCommandRequiresScopeAndCompleteEventIdentity(t *testing.T) { + for count := range 8 { + err := Cmd.Args(Cmd, make([]string, count)) + if count == 4 || count == 6 { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } + require.Contains(t, Cmd.Use, "transaction hash log index") + require.NotContains(t, Cmd.Use, "parent") + require.NotContains(t, Cmd.Example, "[parent]") +} + +func TestMatchAdvancesGetExampleHasValidEventIdentity(t *testing.T) { + _, example, found := strings.Cut(Cmd.Example, "cartesi-rollups-cli read match_advances ") + require.True(t, found) + line, _, _ := strings.Cut(example, "\n") + args := strings.Fields(line) + require.Len(t, args, getArgCount) + require.NoError(t, Cmd.Args(Cmd, args)) + _, err := config.ToHashFromString(args[listArgCount]) + require.NoError(t, err) + _, err = config.AsHexString(args[getArgCount-1]) + require.NoError(t, err) +} + +type matchAdvanceReader struct { + service.ReadService + get func(context.Context, api.GetMatchAdvanceParams) (json.RawMessage, error) + list func(context.Context, api.ListMatchAdvancesParams) (json.RawMessage, error) +} + +func (reader *matchAdvanceReader) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { + return reader.get(ctx, params) +} + +func (reader *matchAdvanceReader) ListMatchAdvances(ctx context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) { + return reader.list(ctx, params) +} + +func matchAdvanceCommandArgs() []string { + return []string{"echo-dapp", "16", common.HexToAddress("0xabc").Hex(), common.HexToHash("0xdef").Hex()} +} + +func TestMatchAdvancesCommandMapsGetEventIdentity(t *testing.T) { + for _, logIndex := range []string{"0", "0X2A"} { + t.Run(logIndex, func(t *testing.T) { + args := append(matchAdvanceCommandArgs(), common.HexToHash("0x123").Hex(), logIndex) + require.NoError(t, Cmd.Args(Cmd, args)) + expectedIndex := "0x0" + if logIndex == "0X2A" { + expectedIndex = "0x2a" + } + called := false + reader := &matchAdvanceReader{get: func(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { + called = true + require.Equal(t, t.Context(), ctx) + require.Equal(t, api.GetMatchAdvanceParams{Application: args[0], EpochIndex: "0x10", + TournamentAddress: args[2], IDHash: args[3], TxHash: args[4], LogIndex: expectedIndex}, params) + return json.RawMessage(`{"data":{}}`), nil + }} + result, err := readMatchAdvances(t.Context(), reader, args) + require.NoError(t, err) + require.True(t, called) + require.JSONEq(t, `{"data":{}}`, string(result)) + }) + } +} + +func TestMatchAdvancesCommandKeepsListPaginationFlags(t *testing.T) { + previousLimit, previousOffset, previousDescending := limit, offset, descending + t.Cleanup(func() { limit, offset, descending = previousLimit, previousOffset, previousDescending }) + require.NoError(t, Cmd.ParseFlags([]string{"--limit", "3", "--offset", "2", "--descending"})) + args := matchAdvanceCommandArgs() + require.NoError(t, Cmd.Args(Cmd, args)) + require.NoError(t, Cmd.PreRunE(Cmd, args)) + called := false + reader := &matchAdvanceReader{list: func(_ context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) { + called = true + require.Equal(t, api.ListMatchAdvancesParams{Application: args[0], EpochIndex: "0x10", + TournamentAddress: args[2], IDHash: args[3], Limit: 3, Offset: 2, Descending: true}, params) + return json.RawMessage(`{"data":[]}`), nil + }} + _, err := readMatchAdvances(t.Context(), reader, args) + require.NoError(t, err) + require.True(t, called) + limit = 0 + require.NoError(t, Cmd.PreRunE(Cmd, args)) + require.Equal(t, uint64(jsonrpc.LIST_ITEM_LIMIT), limit) + limit = jsonrpc.LIST_ITEM_LIMIT + 1 + require.Error(t, Cmd.PreRunE(Cmd, args)) +} + +func TestMatchAdvancesCommandRejectsInvalidNumericIndices(t *testing.T) { + for _, epochIndex := range []string{"-1", "0x10000000000000000"} { + args := matchAdvanceCommandArgs() + args[1] = epochIndex + _, err := readMatchAdvances(t.Context(), nil, args) + require.Error(t, err) + } + for _, logIndex := range []string{"-1", "0x10000000000000000"} { + args := append(matchAdvanceCommandArgs(), common.HexToHash("0x123").Hex(), logIndex) + _, err := readMatchAdvances(t.Context(), nil, args) + require.Error(t, err) + } +} diff --git a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go index 1a96b2099..c4c93c2a2 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go +++ b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go @@ -355,8 +355,11 @@ func (s *JsonrpcReadService) GetMatchAdvanced(ctx context.Context, params api.Ge if _, err := config.ToHashFromString(params.IDHash); err != nil { return nil, fmt.Errorf("invalid ID hash: %w", err) } - if _, err := config.ToHashFromString(params.Parent); err != nil { - return nil, fmt.Errorf("invalid parent: %w", err) + if _, err := config.ToHashFromString(params.TxHash); err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + if _, err := config.ToIndexFromString(params.LogIndex); err != nil { + return nil, fmt.Errorf("invalid log index: %w", err) } var resp json.RawMessage diff --git a/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go b/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go new file mode 100644 index 000000000..b84453fb0 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go @@ -0,0 +1,188 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/jsonrpc/client" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const invalidMatchAdvanceLogIndex = "invalid log index" + +func validMatchAdvanceParams() api.GetMatchAdvanceParams { + return api.GetMatchAdvanceParams{Application: "echo-dapp", EpochIndex: "0x10", + TournamentAddress: common.HexToAddress("0xabc").Hex(), IDHash: common.HexToHash("0xdef").Hex(), + TxHash: common.HexToHash("0x123").Hex(), LogIndex: "0x0"} +} + +func TestMatchAdvanceBackendsRejectInvalidIdentityBeforeIO(t *testing.T) { + for _, test := range []struct { + name string + change func(*api.GetMatchAdvanceParams) + message string + }{ + {"application", func(p *api.GetMatchAdvanceParams) { p.Application = "" }, "invalid application"}, + {"epoch", func(p *api.GetMatchAdvanceParams) { p.EpochIndex = "" }, "invalid epoch index"}, + {"tournament", func(p *api.GetMatchAdvanceParams) { p.TournamentAddress = "" }, "invalid tournament address"}, + {"match", func(p *api.GetMatchAdvanceParams) { p.IDHash = "" }, "invalid ID hash"}, + {"transaction absent", func(p *api.GetMatchAdvanceParams) { p.TxHash = "" }, "invalid transaction hash"}, + {"transaction short", func(p *api.GetMatchAdvanceParams) { p.TxHash = "0x123" }, "invalid transaction hash"}, + {"log index absent", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "" }, invalidMatchAdvanceLogIndex}, + {"log index decimal", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "12" }, invalidMatchAdvanceLogIndex}, + {"log index negative", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "-1" }, invalidMatchAdvanceLogIndex}, + {"log index overflow", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "0x10000000000000000" }, invalidMatchAdvanceLogIndex}, + } { + for _, backend := range []struct { + name string + service ReadService + }{ + {"repository", &RepositoryReadService{}}, {"jsonrpc", &JsonrpcReadService{}}, + } { + t.Run(backend.name+"/"+test.name, func(t *testing.T) { + params := validMatchAdvanceParams() + test.change(¶ms) + // Both dependencies are nil: reaching I/O before validation fails this test. + _, err := backend.service.GetMatchAdvanced(t.Context(), params) + require.ErrorContains(t, err, test.message) + }) + } + } +} + +type matchAdvanceRepository struct { + repository.Repository + get func(context.Context, string, uint64, string, string, common.Hash, uint64) (*model.MatchAdvanced, error) + list func(context.Context, string, uint64, string, string, repository.Pagination, bool) ([]*model.MatchAdvanced, uint64, error) +} + +func (r *matchAdvanceRepository) GetMatchAdvanced(ctx context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, logIndex uint64) (*model.MatchAdvanced, error) { + return r.get(ctx, app, epoch, tournament, id, tx, logIndex) +} + +func (r *matchAdvanceRepository) ListMatchAdvances(ctx context.Context, app string, epoch uint64, tournament, id string, + pagination repository.Pagination, descending bool) ([]*model.MatchAdvanced, uint64, error) { + return r.list(ctx, app, epoch, tournament, id, pagination, descending) +} + +func TestRepositoryMatchAdvanceUsesScopedEventIdentity(t *testing.T) { + params := validMatchAdvanceParams() + called := false + repo := &matchAdvanceRepository{get: func(ctx context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, logIndex uint64) (*model.MatchAdvanced, error) { + called = true + require.Equal(t, t.Context(), ctx) + require.Equal(t, params.Application, app) + require.Equal(t, uint64(16), epoch) + require.Equal(t, params.TournamentAddress, tournament) + require.Equal(t, params.IDHash, id) + require.Equal(t, common.HexToHash(params.TxHash), tx) + require.Zero(t, logIndex, "block log index zero is valid") + return &model.MatchAdvanced{TxHash: tx, LogIndex: logIndex}, nil + }} + s := &RepositoryReadService{Repository: repo} + result, err := s.GetMatchAdvanced(t.Context(), params) + require.NoError(t, err) + require.True(t, called) + var response api.SingleResponse[*model.MatchAdvanced] + require.NoError(t, json.Unmarshal(result, &response)) + require.Equal(t, common.HexToHash(params.TxHash), response.Data.TxHash) + require.Zero(t, response.Data.LogIndex) +} + +func TestRepositoryMatchAdvanceListKeepsMatchScopeAndPagination(t *testing.T) { + identity := validMatchAdvanceParams() + params := api.ListMatchAdvancesParams{Application: identity.Application, EpochIndex: identity.EpochIndex, + TournamentAddress: identity.TournamentAddress, IDHash: identity.IDHash, Limit: 3, Offset: 2, Descending: true} + called := false + repo := &matchAdvanceRepository{list: func(_ context.Context, app string, epoch uint64, tournament, id string, + pagination repository.Pagination, descending bool) ([]*model.MatchAdvanced, uint64, error) { + called = true + require.Equal(t, params.Application, app) + require.Equal(t, uint64(16), epoch) + require.Equal(t, params.TournamentAddress, tournament) + require.Equal(t, params.IDHash, id) + require.Equal(t, repository.Pagination{Limit: 3, Offset: 2}, pagination) + require.True(t, descending) + return []*model.MatchAdvanced{{LogIndex: 4}}, 7, nil + }} + result, err := (&RepositoryReadService{Repository: repo}).ListMatchAdvances(t.Context(), params) + require.NoError(t, err) + require.True(t, called) + var response api.ListResponse[*model.MatchAdvanced] + require.NoError(t, json.Unmarshal(result, &response)) + require.Equal(t, uint64(7), response.Pagination.TotalCount) + require.Equal(t, uint64(3), response.Pagination.Limit) + require.Equal(t, uint64(2), response.Pagination.Offset) +} + +type matchAdvanceTransport func(*http.Request) (*http.Response, error) + +func (transport matchAdvanceTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestJSONRPCMatchAdvanceSendsScopedParams(t *testing.T) { + identity := validMatchAdvanceParams() + for _, get := range []bool{false, true} { + name := "list" + method := "cartesi_listMatchAdvances" + if get { + name, method = "get", "cartesi_getMatchAdvance" + } + t.Run(name, func(t *testing.T) { + called := false + transport := matchAdvanceTransport(func(request *http.Request) (*http.Response, error) { + called = true + var wire struct { + Method string + Params map[string]any + } + require.NoError(t, json.NewDecoder(request.Body).Decode(&wire)) + require.NoError(t, request.Body.Close()) + require.Equal(t, method, wire.Method) + require.Equal(t, identity.Application, wire.Params["application"]) + require.Equal(t, identity.EpochIndex, wire.Params["epoch_index"]) + require.Equal(t, identity.TournamentAddress, wire.Params["tournament_address"]) + require.Equal(t, identity.IDHash, wire.Params["id_hash"]) + require.NotContains(t, wire.Params, "parent") + if get { + require.Equal(t, identity.TxHash, wire.Params["tx_hash"]) + require.Equal(t, identity.LogIndex, wire.Params["log_index"]) + } else { + require.NotContains(t, wire.Params, "tx_hash") + require.NotContains(t, wire.Params, "log_index") + require.Equal(t, float64(3), wire.Params["limit"]) + require.Equal(t, float64(2), wire.Params["offset"]) + require.Equal(t, true, wire.Params["descending"]) + } + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"data":[]}}`))}, nil + }) + s := &JsonrpcReadService{Client: &client.Client{URL: "http://example.invalid/rpc", + HTTPClient: &http.Client{Transport: transport}}} + var err error + if get { + _, err = s.GetMatchAdvanced(t.Context(), identity) + } else { + _, err = s.ListMatchAdvances(t.Context(), api.ListMatchAdvancesParams{Application: identity.Application, + EpochIndex: identity.EpochIndex, TournamentAddress: identity.TournamentAddress, IDHash: identity.IDHash, + Limit: 3, Offset: 2, Descending: true}) + } + require.NoError(t, err) + require.True(t, called) + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/read/service/repository.go b/cmd/cartesi-rollups-cli/root/read/service/repository.go index 01a59fc70..4912e5d8e 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/repository.go +++ b/cmd/cartesi-rollups-cli/root/read/service/repository.go @@ -806,11 +806,16 @@ func (s *RepositoryReadService) GetMatchAdvanced(ctx context.Context, params api if _, err := config.ToHashFromString(params.IDHash); err != nil { return nil, fmt.Errorf("invalid ID hash: %w", err) } - if _, err := config.ToHashFromString(params.Parent); err != nil { - return nil, fmt.Errorf("invalid parent: %w", err) + txHash, err := config.ToHashFromString(params.TxHash) + if err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + logIndex, err := config.ToIndexFromString(params.LogIndex) + if err != nil { + return nil, fmt.Errorf("invalid log index: %w", err) } - data, err := repo.GetMatchAdvanced(ctx, application, epochIndex, params.TournamentAddress, params.IDHash, params.Parent) + data, err := repo.GetMatchAdvanced(ctx, application, epochIndex, params.TournamentAddress, params.IDHash, txHash, logIndex) if err != nil { return nil, err } diff --git a/cmd/cartesi-rollups-cli/root/refund/refund.go b/cmd/cartesi-rollups-cli/root/refund/refund.go new file mode 100644 index 000000000..5844be787 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/refund/refund.go @@ -0,0 +1,230 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package refund submits refunds for deposits in a foreclosed application. +package refund + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "os" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +var Cmd = newCommand() + +type options struct { + inputFile string + skipConfirmation bool + asJSON bool +} + +func newCommand() *cobra.Command { + var opts options + cmd := &cobra.Command{ + Use: "refund ", + Short: "Refund an unfinalized deposit from a foreclosed application", + Args: cobra.ExactArgs(2), //nolint:mnd // Application and input index. + RunE: opts.run, + Long: `Calls IApplication.issueRefund(inputIndex, input). The guardian must have +already foreclosed the application, and consensus must not have finalized +the deposit input. Local ACCEPTED or REJECTED status does not determine +whether the input is finalized. + +Any funded signer can pay the gas. The canonical refund builder sends the +funds to the original depositor recorded by the portal. The gas payer does +not have to be the depositor or guardian and cannot choose a new recipient. +No machine, output, accounts-drive, or account proof is required. + +The application argument accepts a name (resolved through the local database) +or an Ethereum address (no database access). The input index is application-wide, +not epoch-relative, and accepts decimal or 0x-prefixed hexadecimal uint256 values. + +--input-file must contain the complete original InputAdded.input bytes as +0x-prefixed hexadecimal text, with optional surrounding whitespace. Use the +node's data.raw_data field, not decoded_data.payload, the portal payload, +deposit transaction calldata, or JSON. + +The command waits for a successful mined receipt by default. With --no-wait, +it returns after broadcast without confirmation of payment. In that case, +check the receipt, RefundIssued event, and wasRefundForInputIssued on chain. + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Required only for an application name + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum HTTP endpoint + CARTESI_AUTH_KIND Signer authentication method + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX Derived mnemonic account index`, + Example: `# Export the complete input through the node JSON-RPC API: +cartesi-rollups-cli read inputs echo-dapp 7 --jsonrpc | jq -er '.data.raw_data' > deposit-input.hex + +# Refund after the guardian has foreclosed the application: +cartesi-rollups-cli refund echo-dapp 7 --input-file deposit-input.hex --yes --json`, + } + cmd.Flags().StringVar(&opts.inputFile, "input-file", "", "File containing the complete 0x-prefixed InputAdded.input hex") + cobra.CheckErr(cmd.MarkFlagRequired("input-file")) + cmd.Flags().BoolVarP(&opts.skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Print the transaction hash, application address, and input index as JSON") + cli.AddTransactionFlags(cmd) + origHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + for _, name := range []string{"database-connection", "blockchain-http-endpoint", "gas-limit"} { + if flag := command.Flags().Lookup(name); flag != nil { + flag.Hidden = false + } + } + origHelp(command, args) + }) + return cmd +} + +func (o *options) run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + inputIndex, err := parseInputIndex(args[1]) + if err != nil { + return err + } + input, err := loadInput(o.inputFile) + if err != nil { + return err + } + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } + endpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } + client, err := ethclient.DialContext(ctx, endpoint.Raw()) + if err != nil { + return fmt.Errorf("connect to Ethereum: %w", err) + } + defer client.Close() + chainID, err := client.ChainID(ctx) + if err != nil { + return fmt.Errorf("read chain ID: %w", err) + } + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return fmt.Errorf("prepare refund signer: %w", err) + } + appContract, err := iapplication.NewIApplication(appAddr, client) + if err != nil { + return fmt.Errorf("bind application: %w", err) + } + if !o.skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to refund input %s from application %s\n"+ + " gas payer: %s\n"+ + "The canonical refund builder pays the original depositor; the gas payer cannot change the recipient.\n", + inputIndex, appAddr, txOpts.From) + if err != nil { + return err + } + confirmed, err := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.IssueRefund(opts, inputIndex, input) + }) + if err != nil { + return fmt.Errorf("refund: %w", cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + } + if receipt != nil && !hasMatchingRefundEvent(appContract, receipt, appAddr, inputIndex, input) { + return fmt.Errorf("transaction %s mined, but its receipt has no matching RefundIssued event for input %s", tx.Hash(), inputIndex) + } + if o.asJSON { + result := struct { + cli.TransactionResult + ApplicationAddr common.Address `json:"application_address"` + InputIndex string `json:"input_index"` + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr, InputIndex: inputIndex.String()} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + return fmt.Errorf("write refund result: %w", err) + } + } else { + return cli.WriteTransactionResult(cmd, tx, receipt) + } + return nil +} + +func hasMatchingRefundEvent( + application *iapplication.IApplication, + receipt *types.Receipt, + address common.Address, + inputIndex *big.Int, + input []byte, +) bool { + for _, log := range receipt.Logs { + if log == nil || log.Address != address { + continue + } + event, err := application.ParseRefundIssued(*log) + if err == nil && event.InputIndex.Cmp(inputIndex) == 0 && bytes.Equal(event.Input, input) { + return true + } + } + return false +} + +func parseInputIndex(raw string) (*big.Int, error) { + const uint256Bits = 256 + base, digits := 10, raw + if strings.HasPrefix(raw, "0x") { + base, digits = 16, raw[2:] + } + if digits == "" || strings.HasPrefix(digits, "+") || strings.HasPrefix(digits, "-") { + return nil, fmt.Errorf("invalid input index: expected a decimal or 0x-prefixed uint256") + } + index, ok := new(big.Int).SetString(digits, base) + if !ok || index.Sign() < 0 || index.BitLen() > uint256Bits { + return nil, fmt.Errorf("invalid input index: expected a decimal or 0x-prefixed uint256") + } + return index, nil +} + +func loadInput(path string) ([]byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read input file: %w", err) + } + encoded := strings.TrimSpace(string(raw)) + if !strings.HasPrefix(encoded, "0x") { + return nil, fmt.Errorf("invalid input file: expected 0x-prefixed hexadecimal InputAdded.input bytes") + } + input, err := hexutil.Decode(encoded) + if err != nil { + return nil, fmt.Errorf("invalid input file: %w", err) + } + if len(input) == 0 { + return nil, fmt.Errorf("invalid input file: input bytes cannot be empty") + } + return input, nil +} diff --git a/cmd/cartesi-rollups-cli/root/refund/refund_test.go b/cmd/cartesi-rollups-cli/root/refund/refund_test.go new file mode 100644 index 000000000..fa54691cd --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/refund/refund_test.go @@ -0,0 +1,497 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package refund + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testChainID = 31337 + testGasLimit = 90000 + inputFileFlag = "--input-file" + yesFlag = "--yes" + jsonFlag = "--json" + + maxUint256Decimal = "115792089237316195423570985008687907853269984665640564039457584007913129639935" +) + +func TestParseInputIndex(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"0", "0"}, + {"0x0", "0"}, + {"007", "7"}, + {"0xFF", "255"}, + {"18446744073709551616", "18446744073709551616"}, + {maxUint256Decimal, maxUint256Decimal}, + {"0x" + strings.Repeat("f", 64), maxUint256Decimal}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + index, err := parseInputIndex(tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, index.String()) + }) + } + for _, input := range []string{ + "", "-1", "+1", "-0", "0x", "0x-1", "0x+1", "0xgg", "0b10", "1_0", " 7", "7 ", + "0x1" + strings.Repeat("0", 64), + "115792089237316195423570985008687907853269984665640564039457584007913129639936", + } { + t.Run("reject_"+input, func(t *testing.T) { + index, err := parseInputIndex(input) + require.ErrorContains(t, err, "invalid input index") + require.Nil(t, index) + }) + } +} + +func writeInputFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "input.hex") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func originalInput(t *testing.T) []byte { + t.Helper() + inputABI, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + input, err := inputABI.Pack("EvmAdvance", big.NewInt(testChainID), common.HexToAddress(testApplication), + common.HexToAddress("0x2"), big.NewInt(10), big.NewInt(20), big.NewInt(0), big.NewInt(7), []byte{0xaa, 0xbb}) + require.NoError(t, err) + return input +} + +func TestLoadInput(t *testing.T) { + input := originalInput(t) + loaded, err := loadInput(writeInputFile(t, " \t\n"+hexutil.Encode(input)+"\r\n ")) + require.NoError(t, err) + require.Equal(t, input, loaded) + for _, contents := range []string{"", " \n", "0x", "aabb", "0Xaa", "0xabc", "0xgg", "0xaa bb", `"0xaa"`, `{}`} { + t.Run("reject_"+contents, func(t *testing.T) { + loaded, err := loadInput(writeInputFile(t, contents)) + require.ErrorContains(t, err, "invalid input file") + require.Nil(t, loaded) + }) + } + _, err = loadInput(filepath.Join(t.TempDir(), "missing.hex")) + require.ErrorContains(t, err, "read input file") + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestCommandRejectsArgumentsBeforeEthereum(t *testing.T) { + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, "invalid-endpoint") + validFile := writeInputFile(t, hexutil.Encode(originalInput(t))) + tests := []struct { + name string + args []string + want string + }{ + {"missing arguments", nil, "accepts 2 arg(s)"}, + {"missing file flag", []string{testApplication, "7"}, `required flag(s) "input-file" not set`}, + {"invalid application", []string{"0x1234", "7", inputFileFlag, validFile}, "invalid Ethereum address"}, + {"invalid index", []string{testApplication, "bad", inputFileFlag, validFile}, "invalid input index"}, + {"missing file", []string{testApplication, "7", inputFileFlag, filepath.Join(t.TempDir(), "missing")}, "read input file"}, + {"invalid file", []string{testApplication, "7", inputFileFlag, writeInputFile(t, "{}")}, "invalid input file"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(append([]string{}, tt.args...)) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), tt.want) + }) + } +} + +// refundRPC serves only the Ethereum methods used by the real generated binding. +// It never connects to a chain or sends a transaction outside this test server. +type refundRPC struct { + estimateErr error + sendErr error + sent chan *types.Transaction + estimates atomic.Int32 + receipts atomic.Int32 + failed bool + pending bool + logs []*types.Log + emptyCode bool +} + +func (*refundRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(testChainID)), nil +} + +func (*refundRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*refundRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (r *refundRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + if r.emptyCode { + return hexutil.Bytes{}, nil + } + return hexutil.Bytes{0x01}, nil +} + +func (r *refundRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, r.estimateErr +} + +func (r *refundRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + if r.pending { + return nil, nil + } + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + return &types.Receipt{ + TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: r.logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit, + }, nil +} + +func (*refundRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return 0, nil +} + +func (r *refundRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), r.sendErr +} + +type refundRevert struct { + data string +} + +func (*refundRevert) Error() string { return "execution reverted" } +func (*refundRevert) ErrorCode() int { return 3 } +func (e *refundRevert) ErrorData() any { return e.data } + +func setupRPC(t *testing.T, service *refundRPC) common.Address { + t.Helper() + if service.logs == nil { + service.logs = []*types.Log{refundEventLog(t, common.HexToAddress(testApplication), big.NewInt(7), originalInput(t))} + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + return crypto.PubkeyToAddress(key.PublicKey) +} + +func refundEventLog(t *testing.T, address common.Address, index *big.Int, input []byte) *types.Log { + t.Helper() + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["RefundIssued"] + data, err := event.Inputs.NonIndexed().Pack(input, []byte{0xab}) + require.NoError(t, err) + return &types.Log{Address: address, Topics: []common.Hash{event.ID, common.BigToHash(index)}, Data: data} +} + +func TestCommandRequiresMatchingRefundEvent(t *testing.T) { + address := common.HexToAddress(testApplication) + input := originalInput(t) + matching := refundEventLog(t, address, big.NewInt(7), input) + wrongEmitter := refundEventLog(t, common.HexToAddress("0x1234"), big.NewInt(7), input) + wrongIndex := refundEventLog(t, address, big.NewInt(8), input) + wrongInput := refundEventLog(t, address, big.NewInt(7), []byte{0x12}) + malformed := &types.Log{Address: address, Topics: matching.Topics, Data: []byte{0x01}} + wrongTopic := &types.Log{Address: address, Topics: []common.Hash{common.HexToHash("0x12")}, Data: matching.Data} + missingIndex := &types.Log{Address: address, Topics: matching.Topics[:1], Data: matching.Data} + for _, test := range []struct { + name string + logs []*types.Log + noWait bool + wantErr bool + }{ + {name: "matching", logs: []*types.Log{matching}}, + {name: "empty receipt at EOA", logs: []*types.Log{}, wantErr: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantErr: true}, + {name: "wrong index", logs: []*types.Log{wrongIndex}, wantErr: true}, + {name: "wrong input", logs: []*types.Log{wrongInput}, wantErr: true}, + {name: "wrong event", logs: []*types.Log{wrongTopic}, wantErr: true}, + {name: "malformed", logs: []*types.Log{malformed}, wantErr: true}, + {name: "missing index", logs: []*types.Log{missingIndex}, wantErr: true}, + {name: "later match", logs: []*types.Log{nil, wrongEmitter, wrongIndex, wrongInput, malformed, wrongTopic, missingIndex, matching}}, + {name: "no wait with empty receipt", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), logs: test.logs, emptyCode: len(test.logs) == 0} + setupRPC(t, service) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + t.Setenv(config.DATABASE_CONNECTION, "invalid") + cmd := newCommand() + cmd.SilenceUsage = true + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(input)), yesFlag, jsonFlag} + if test.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.EqualValues(t, 123456, tx.Gas()) + require.Zero(t, service.estimates.Load()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantErr { + require.ErrorContains(t, err, "no matching RefundIssued event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + status := "mined" + if test.noWait { + status = "broadcast" + } + require.Equal(t, status, result["status"]) + }) + } +} + +func TestCommandSignsOriginalInput(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1)} + signer := setupRPC(t, service) + input := originalInput(t) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "0x7", inputFileFlag, writeInputFile(t, hexutil.Encode(input)), yesFlag, jsonFlag}) + require.NoError(t, cmd.ExecuteContext(t.Context())) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Equal(t, common.HexToAddress(testApplication), *tx.To()) + require.Zero(t, tx.Value().Sign()) + require.Equal(t, uint64(testGasLimit), tx.Gas()) + from, err := types.Sender(types.LatestSignerForChainID(big.NewInt(testChainID)), tx) + require.NoError(t, err) + require.Equal(t, signer, from) + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + method, err := appABI.MethodById(tx.Data()[:4]) + require.NoError(t, err) + require.Equal(t, "issueRefund", method.Name) + args, err := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, err) + require.Equal(t, big.NewInt(7), args[0]) + require.Equal(t, input, args[1]) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, map[string]string{ + "transaction_hash": tx.Hash().Hex(), "application_address": testApplication, "input_index": "7", + "status": "mined", "block_number": "0xa", + }, result) + require.EqualValues(t, 1, service.estimates.Load()) + require.EqualValues(t, 1, service.receipts.Load()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) +} + +func TestCommandReceiptPolicy(t *testing.T) { + for _, tt := range []struct { + name string + manualGas bool + noWait bool + failed bool + pending bool + wantErr string + }{ + {name: "estimated mined"}, + {name: "manual gas mined", manualGas: true}, + {name: "manual gas receipt failure", manualGas: true, failed: true, wantErr: "failed in block"}, + {name: "estimated receipt failure", failed: true, wantErr: "failed in block"}, + {name: "estimated broadcast only", noWait: true, pending: true}, + {name: "manual gas broadcast only", manualGas: true, noWait: true, pending: true}, + {name: "pending timeout", pending: true, wantErr: "outcome unknown"}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), failed: tt.failed, pending: tt.pending} + setupRPC(t, service) + if tt.manualGas { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag, jsonFlag} + if tt.noWait { + args = append(args, "--no-wait") + } + if tt.pending && !tt.noWait { + args = append(args, "--wait-timeout", "20ms") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + if tt.manualGas { + require.EqualValues(t, 123456, tx.Gas()) + require.Zero(t, service.estimates.Load()) + } else { + require.EqualValues(t, testGasLimit, tx.Gas()) + require.EqualValues(t, 1, service.estimates.Load()) + } + if tt.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.Positive(t, service.receipts.Load()) + } + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + if tt.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + } + }) + } +} + +// This subprocess keeps Cobra's default writers. SetOut would hide a regression +// from OutOrStdout back to Printf, because Cobra Printf defaults to stderr. +func TestCommandDefaultOutputProcess(t *testing.T) { + if os.Getenv("CARTESI_TEST_REFUND_OUTPUT_PROCESS") != "1" { + return + } + cmd := newCommand() + for i, arg := range os.Args { + if arg == "--" { + cmd.SetArgs(os.Args[i+1:]) + break + } + } + if err := cmd.ExecuteContext(t.Context()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func TestCommandPlainResultUsesDefaultStdout(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1)} + setupRPC(t, service) + t.Setenv("CARTESI_TEST_REFUND_OUTPUT_PROCESS", "1") + //nolint:gosec // Runs this test binary with fixed arguments and a local fixture path, without a shell. + process := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestCommandDefaultOutputProcess$", "--", + testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + require.NoError(t, process.Run(), stderr.String()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Equal(t, "Transaction mined: "+tx.Hash().Hex()+"\n", stdout.String()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.NotContains(t, stderr.String(), "Transaction mined:") +} + +func TestCommandReportsRefundReverts(t *testing.T) { + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + for _, name := range []string{"NotForeclosed", "CannotRefundFinalizedInput", "RefundAlreadyIssued"} { + t.Run(name, func(t *testing.T) { + contractError := appABI.Errors[name] + data := append([]byte(nil), contractError.ID[:4]...) + if len(contractError.Inputs) > 0 { + args, err := contractError.Inputs.Pack(big.NewInt(7)) + require.NoError(t, err) + data = append(data, args...) + } + service := &refundRPC{sent: make(chan *types.Transaction, 1), estimateErr: &refundRevert{data: hexutil.Encode(data)}} + setupRPC(t, service) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "decoded revert: "+name) + require.Empty(t, service.sent) + require.NotContains(t, stdout.String(), "Refund tx-hash") + }) + } +} + +func TestCommandKeepsHashOnBroadcastError(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), sendErr: errors.New("response unavailable")} + setupRPC(t, service) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag, jsonFlag}) + err := cmd.ExecuteContext(t.Context()) + require.ErrorContains(t, err, "response unavailable") + require.Len(t, service.sent, 1) + tx := <-service.sent + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) +} diff --git a/cmd/cartesi-rollups-cli/root/root.go b/cmd/cartesi-rollups-cli/root/root.go index 58ec16a07..0258eb11b 100644 --- a/cmd/cartesi-rollups-cli/root/root.go +++ b/cmd/cartesi-rollups-cli/root/root.go @@ -14,6 +14,7 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/inspect" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/provedriveroot" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/refund" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/send" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/validate" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/withdraw" @@ -25,9 +26,10 @@ import ( ) var Cmd = &cobra.Command{ - Use: "cartesi-rollups-cli", - Short: "Command line interface for the Cartesi Rollups Node", - Version: version.BuildVersion, + Use: "cartesi-rollups-cli", + Short: "Command line interface for the Cartesi Rollups Node", + Version: version.BuildVersion, + SilenceUsage: true, } var ( @@ -59,7 +61,7 @@ func init() { // Blockchain gas limit Cmd.PersistentFlags().Uint64Var(&gasLimit, "gas-limit", 0, - "Blockchain gas limit") + "Transaction gas limit. Zero estimates gas; a nonzero value skips estimation.") cobra.CheckErr(viper.BindPFlag(config.BLOCKCHAIN_GAS_LIMIT, Cmd.PersistentFlags().Lookup("gas-limit"))) cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("gas-limit")) @@ -75,6 +77,7 @@ func init() { Cmd.AddCommand(validate.Cmd) Cmd.AddCommand(execute.Cmd) Cmd.AddCommand(foreclose.Cmd) + Cmd.AddCommand(refund.Cmd) Cmd.AddCommand(provedriveroot.Cmd) Cmd.AddCommand(withdraw.Cmd) Cmd.AddCommand(app.Cmd) diff --git a/cmd/cartesi-rollups-cli/root/root_transaction_test.go b/cmd/cartesi-rollups-cli/root/root_transaction_test.go new file mode 100644 index 000000000..193d9dce3 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/root_transaction_test.go @@ -0,0 +1,194 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "bytes" + "context" + "errors" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +const ( + rootCommandProcess = "CARTESI_TEST_ROOT_COMMAND_PROCESS" + rootTestChainID = 31337 + rootTestGasLimit = 500000 + rootTestPrivateKey = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" +) + +// Run each invocation in a fresh process. The real command tree has global +// Cobra flags and Viper bindings. Do not reset or replace either in these tests. +func TestRootCommandProcess(t *testing.T) { + if os.Getenv(rootCommandProcess) != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + Cmd.SetArgs(os.Args[i+1:]) + if err := Cmd.ExecuteContext(t.Context()); err != nil { + os.Exit(1) + } + os.Exit(0) + } + } + t.Fatal("missing subprocess argument separator") +} + +func runRootCommandProcess(t *testing.T, extraEnv []string, args ...string) (string, string, error) { + t.Helper() + executable, err := os.Executable() + require.NoError(t, err) + const processTimeout = 20 * time.Second + ctx, cancel := context.WithTimeout(t.Context(), processTimeout) + defer cancel() + processArgs := append([]string{"-test.run=^TestRootCommandProcess$", "--"}, args...) + process := exec.CommandContext(ctx, executable, processArgs...) + // Each covered child must have its own output directory. Concurrent children + // can otherwise collide when they write coverage metadata at process exit. + process.Env = append(os.Environ(), rootCommandProcess+"=1", "GOCOVERDIR="+t.TempDir()) + process.Env = append(process.Env, extraEnv...) + var stdout, stderr bytes.Buffer + process.Stdout, process.Stderr = &stdout, &stderr + err = process.Run() + return stdout.String(), stderr.String(), err +} + +func TestRootTransactionHelp(t *testing.T) { + t.Parallel() + for _, command := range []string{ + "send", "execute", "deposit erc20", "foreclose", "prove-drive-root", "withdraw", "refund", + "deploy application", "deploy authority", "deploy quorum", + } { + t.Run(command, func(t *testing.T) { + t.Parallel() + args := append(strings.Fields(command), "--help") + stdout, stderr, err := runRootCommandProcess(t, nil, args...) + require.NoError(t, err, stderr) + require.Empty(t, stderr) + require.Contains(t, stdout, "Usage:") + help := strings.Join(strings.Fields(stdout), " ") + require.Contains(t, help, "--gas-limit") + require.Contains(t, help, "Zero estimates gas; a nonzero value skips estimation.") + require.Contains(t, help, "--no-wait") + require.Contains(t, help, "Return after broadcast without checking execution success") + require.Contains(t, help, "--wait-timeout") + require.NotContains(t, help, "--async") + }) + } +} + +func TestRootHelpRemainsAvailable(t *testing.T) { + t.Parallel() + stdout, stderr, err := runRootCommandProcess(t, nil, "--help") + require.NoError(t, err, stderr) + require.Empty(t, stderr) + require.Contains(t, stdout, "Usage:") + require.Contains(t, stdout, "Available Commands:") +} + +func TestRootTransactionErrorsKeepHashWithoutUsage(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + sendErr error + want string + }{ + {name: "broadcast", sendErr: errors.New("broadcast response lost"), want: "broadcast response lost"}, + {name: "mined failure", want: "receipt status 0"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + backend := &rootTransactionRPC{sendErr: test.sendErr, sent: make(chan *types.Transaction, 1)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + inputFile := filepath.Join(t.TempDir(), "input.hex") + require.NoError(t, os.WriteFile(inputFile, []byte("0xaabb\n"), 0o600)) + application := common.HexToAddress("0x01") + stdout, stderr, err := runRootCommandProcess(t, []string{ + config.AUTH_KIND + "=private_key", + config.AUTH_PRIVATE_KEY + "=" + rootTestPrivateKey, + }, "refund", application.Hex(), "7", "--input-file", inputFile, + "--blockchain-http-endpoint", httpServer.URL, "--gas-limit", "500000", "--yes", "--json") + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode(), stderr) + require.Empty(t, stdout, "a failed transaction must not produce a success result") + require.Len(t, backend.sent, 1, stderr) + tx := <-backend.sent + require.Equal(t, &application, tx.To()) + require.EqualValues(t, rootTestGasLimit, tx.Gas()) + _, err = types.Sender(types.LatestSignerForChainID(big.NewInt(rootTestChainID)), tx) + require.NoError(t, err, "the provider must receive a signed transaction") + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + calldata, err := appABI.Pack("issueRefund", big.NewInt(7), []byte{0xaa, 0xbb}) + require.NoError(t, err) + require.Equal(t, calldata, tx.Data()) + require.Contains(t, stderr, "Transaction hash: "+tx.Hash().Hex()) + require.Contains(t, stderr, "Error: refund:") + require.Contains(t, stderr, test.want) + require.GreaterOrEqual(t, strings.Count(stderr, tx.Hash().Hex()), 2, + "the returned error must retain the hash in addition to the pre-broadcast diagnostic") + require.NotContains(t, stderr, "Usage:", "runtime errors must not dump command help") + }) + } +} + +// This local RPC server lets the actual refund binding sign and submit its +// calldata. It reports either a lost broadcast response or a failed receipt. +// No request reaches a blockchain or a database. +type rootTransactionRPC struct { + sendErr error + sent chan *types.Transaction +} + +func (*rootTransactionRPC) ChainId(context.Context) hexutil.Uint64 { //nolint:revive // RPC method eth_chainId. + return rootTestChainID +} + +func (*rootTransactionRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(1), GasLimit: rootTestGasLimit} +} + +func (*rootTransactionRPC) GasPrice(context.Context) hexutil.Uint64 { return 1 } + +func (*rootTransactionRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (r *rootTransactionRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + select { + case r.sent <- tx: + default: + return tx.Hash(), errors.New("unexpected repeated broadcast") + } + return tx.Hash(), r.sendErr +} + +func (*rootTransactionRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(1), Logs: []*types.Log{}} +} diff --git a/cmd/cartesi-rollups-cli/root/send/send.go b/cmd/cartesi-rollups-cli/root/send/send.go index 30308de40..197302314 100644 --- a/cmd/cartesi-rollups-cli/root/send/send.go +++ b/cmd/cartesi-rollups-cli/root/send/send.go @@ -7,17 +7,20 @@ import ( "encoding/json" "fmt" "io" + "math/big" "os" "strings" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/repository/factory" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" - "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" ) @@ -27,12 +30,21 @@ var Cmd = &cobra.Command{ Short: "Sends a rollups input transaction to the ethereum provider", Example: examples, Args: cobra.MinimumNArgs(1), - Run: run, + PreRunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Flags().Changed("inputbox") { + return fmt.Errorf("--inputbox is not supported by send; the application selects its InputBox") + } + return nil + }, + RunE: run, Long: ` +Send to the InputBox selected by the Application contract. +An application address does not require a database. An application name does. +The global InputBox setting does not select the input destination. + Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string - CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS Input Box contract address`, + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint`, } const examples = `# Send the string "hi": @@ -42,7 +54,7 @@ cartesi-rollups-cli send echo-dapp "hi" cartesi-rollups-cli send echo-dapp 0x6869 --hex # Read from stdin: -echo "hi" | cartesi-rollups-cli send echo-dapp +echo "hi" | cartesi-rollups-cli send echo-dapp --yes # Skip confirmation prompt: cartesi-rollups-cli send echo-dapp "hi" --yes` @@ -51,22 +63,19 @@ var ( isHex bool skipConfirmation bool asJSONParam bool - asyncMode bool ) func init() { Cmd.Flags().BoolVarP(&isHex, "hex", "x", false, "Force interpretation of payload as hex.") Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") - Cmd.Flags().BoolVar(&asyncMode, "async", false, - "Send the transaction without waiting for confirmation. Prints the tx hash and returns immediately.") + cli.AddTransactionFlags(Cmd) origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { command.Flags().Lookup("verbose").Hidden = false command.Flags().Lookup("database-connection").Hidden = false command.Flags().Lookup("blockchain-http-endpoint").Hidden = false - command.Flags().Lookup("inputbox").Hidden = false origHelpFunc(command, strings) }) } @@ -102,96 +111,120 @@ func decodeHex(s string) ([]byte, error) { return b, nil } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } - dsn, err := config.GetDatabaseConnection() - cobra.CheckErr(err) + appAddress, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) - - iboxAddr, err := config.GetContractsInputBoxAddress() - cobra.CheckErr(err) - - repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) - cobra.CheckErr(err) - defer repo.Close() - - app, err := repo.GetApplication(ctx, nameOrAddress) - cobra.CheckErr(err) - if app == nil { - fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - repo.Close() - os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + if err != nil { + return err } // Check if stdin is being used for payload and --yes flag is not set if len(args) == 1 && !skipConfirmation && !cli.IsTerminal(os.Stdin) { - cobra.CheckErr(fmt.Errorf("reading payload from stdin. Use --yes flag to skip confirmation when piping data")) + return fmt.Errorf("reading payload from stdin: use --yes to skip confirmation when piping data") } payload, err := resolvePayload(args) - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() + application, err := iapplication.NewIApplication(appAddress, client) + if err != nil { + return err + } + inputBoxAddress, err := application.GetInputBox(&bind.CallOpts{Context: ctx}) + if err != nil { + return fmt.Errorf("read application InputBox: %w", cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + } + if inputBoxAddress == (common.Address{}) { + return fmt.Errorf("application %s returned the zero InputBox address", appAddress) + } chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) - - txOptsFactory := ethutil.NewStaticTransactOptsFactory(txOpts) + if err != nil { + return err + } // Ask for confirmation unless --yes flag is set if !skipConfirmation { - fmt.Printf("Preparing to send input to application %v (%v) with account %v\n", - app.Name, app.IApplicationAddress, txOpts.From) + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to send input to application %v with account %v\n", + appAddress, txOpts.From) + if err != nil { + return err + } - confirmed, promptErr := cli.ConfirmPrompt("Do you want to proceed?") - if promptErr != nil || !confirmed { - fmt.Println("Operation cancelled") - return + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to proceed?") + if promptErr != nil { + return promptErr + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled") + return err } } - if asyncMode { - txHash, err := ethutil.AddInputAsync(ctx, client, txOptsFactory, iboxAddr, app.IApplicationAddress, payload) - cobra.CheckErr(cli.DecorateRevert(err, iinputbox.IInputBoxMetaData, iapplication.IApplicationMetaData)) - if asJSONParam { - result := cli.SendResult{ - ApplicationAddress: app.IApplicationAddress.Hex(), - TransactionHash: txHash.Hex(), + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return inputBox.AddInput(opts, appAddress, payload) + }) + if err != nil { + return cli.DecorateRevert(err, iinputbox.IInputBoxMetaData, iapplication.IApplicationMetaData) + } + + result := cli.SendResult{ + TransactionResult: cli.NewTransactionResult(tx, receipt), + ApplicationAddress: appAddress.Hex(), + } + var inputIndex *big.Int + if receipt != nil { + for _, log := range receipt.Logs { + if log.Address != inputBoxAddress { + continue + } + event, err := inputBox.ParseInputAdded(*log) + if err == nil && event.AppContract == appAddress { + inputIndex = event.Index + result.InputIndex = hexutil.EncodeBig(event.Index) + break } - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Println(txHash.Hex()) } - return + if result.InputIndex == "" { + return fmt.Errorf("transaction %s mined, but its receipt has no matching InputAdded event", tx.Hash()) + } } - - inputIndex, blockNumber, txHash, err := ethutil.AddInput(ctx, client, txOptsFactory, iboxAddr, app.IApplicationAddress, payload) - cobra.CheckErr(cli.DecorateRevert(err, iinputbox.IInputBoxMetaData, iapplication.IApplicationMetaData)) - if asJSONParam { - result := cli.SendResult{ - ApplicationAddress: app.IApplicationAddress.Hex(), - TransactionHash: txHash.Hex(), - InputIndex: fmt.Sprintf("0x%x", inputIndex), - BlockNumber: fmt.Sprintf("0x%x", blockNumber), - } - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("Input sent to app at %s. Index: %d BlockNumber: %d\n", - app.IApplicationAddress, inputIndex, blockNumber) + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + if receipt != nil { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Input sent to app at %s. Index: %s BlockNumber: %s Tx-hash: %s\n", + appAddress, inputIndex.String(), receipt.BlockNumber.String(), result.TransactionHash) + return err } + return cli.WriteTransactionResult(cmd, tx, receipt) } diff --git a/cmd/cartesi-rollups-cli/root/send/send_test.go b/cmd/cartesi-rollups-cli/root/send/send_test.go new file mode 100644 index 000000000..0e9d60b17 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/send/send_test.go @@ -0,0 +1,295 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package send_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/send" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +const ( + sendTestGas = 90000 + jsonFlag = "--json" + noWaitFlag = "--no-wait" + gasLimitFlag = "--gas-limit" +) + +func TestSendUsesApplicationInputBox(t *testing.T) { + t.Run("address without database", func(t *testing.T) { runSendInputBoxTest(t, false) }) + t.Run("name from database", func(t *testing.T) { runSendInputBoxTest(t, true) }) +} + +func runSendInputBoxTest(t *testing.T, byName bool) { + t.Helper() + inputBox := common.HexToAddress("0xb2") + app := &model.Application{ + Name: "send-inputbox", IApplicationAddress: common.HexToAddress("0xc3"), + IConsensusAddress: common.HexToAddress("0xd4"), IInputBoxAddress: common.HexToAddress("0xf6"), + EpochLength: 10, ConsensusType: model.Consensus_Authority, Status: model.ApplicationStatus_OK, + } + appReference := app.IApplicationAddress.Hex() + t.Setenv(config.DATABASE_CONNECTION, "invalid") + if byName { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + release, err := db.LockTestPostgres(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(release) + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := factory.NewRepositoryFromConnectionString(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + _, err = repo.CreateApplication(t.Context(), app, false) + require.NoError(t, err) + t.Setenv(config.DATABASE_CONNECTION, endpoint) + appReference = app.Name + } + globalBox := common.HexToAddress("0xa1") + t.Setenv(config.CONTRACTS_INPUT_BOX_ADDRESS, globalBox.Hex()) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + applicationABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + getter := applicationABI.Methods["getInputBox"] + + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["InputAdded"] + data, err := event.Inputs.NonIndexed().Pack([]byte("encoded input")) + require.NoError(t, err) + inputLog := func(box, application common.Address, index int64) *types.Log { + return &types.Log{Address: box, Data: data, Topics: []common.Hash{ + event.ID, common.BytesToHash(application.Bytes()), common.BigToHash(big.NewInt(index)), + }} + } + for _, test := range []struct { + name string + flags []string + missingEvent bool + noWait bool + json bool + zeroInputBox bool + manualGas bool + }{ + {name: "decimal text"}, + {name: "hex JSON", flags: []string{jsonFlag}, json: true}, + {name: "broadcast only", flags: []string{jsonFlag, noWaitFlag}, json: true, noWait: true}, + {name: "reject unrelated events", missingEvent: true}, + {name: "manual gas", flags: []string{gasLimitFlag, "123456"}, manualGas: true}, + {name: "manual gas broadcast only", flags: []string{jsonFlag, noWaitFlag, gasLimitFlag, "123456"}, + json: true, noWait: true, manualGas: true}, + {name: "reject zero InputBox", zeroInputBox: true}, + {name: "reject zero InputBox with manual gas", flags: []string{gasLimitFlag, "123456"}, zeroInputBox: true, manualGas: true}, + {name: "reject zero InputBox without waiting", flags: []string{noWaitFlag}, zeroInputBox: true, noWait: true}, + {name: "reject zero InputBox with manual gas without waiting", flags: []string{noWaitFlag, gasLimitFlag, "123456"}, + zeroInputBox: true, noWait: true, manualGas: true}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &sendRPC{ + sent: make(chan *types.Transaction, 2), application: app.IApplicationAddress, + inputBox: inputBox, getterID: getter.ID, + logs: []*types.Log{ + inputLog(globalBox, app.IApplicationAddress, 1), + inputLog(inputBox, common.HexToAddress("0xe5"), 2), + }, + } + if !test.missingEvent { + backend.logs = append(backend.logs, inputLog(inputBox, app.IApplicationAddress, 17)) + } + if test.zeroInputBox { + backend.inputBox = common.Address{} + // Reject the destination before reading signer configuration. + t.Setenv(config.AUTH_KIND, "invalid-signer") + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", backend)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + resetSendCommand(t) + var stdout, stderr bytes.Buffer + root.Cmd.SetOut(&stdout) + root.Cmd.SetErr(&stderr) + root.Cmd.SetArgs(append([]string{"send", appReference, "hello", "--yes"}, test.flags...)) + err := root.Cmd.ExecuteContext(t.Context()) + if test.zeroInputBox { + require.ErrorContains(t, err, "zero InputBox address") + require.EqualValues(t, 1, backend.getterCalls.Load()) + require.Zero(t, backend.chainReads.Load()) + require.Zero(t, backend.estimates.Load()) + require.Zero(t, backend.receipts.Load()) + require.Empty(t, backend.sent) + require.Empty(t, stdout.String()) + return + } + require.Len(t, backend.sent, 1, stderr.String()) + tx := <-backend.sent + require.Equal(t, inputBox, *tx.To(), "the contract selects the InputBox, not global or cached configuration") + require.EqualValues(t, 1, backend.getterCalls.Load()) + method := parsed.Methods["addInput"] + require.Equal(t, method.ID, tx.Data()[:4]) + args, decodeErr := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, decodeErr) + require.Equal(t, app.IApplicationAddress, args[0]) + require.Equal(t, []byte("hello"), args[1]) + if test.manualGas { + require.Zero(t, backend.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + } else { + require.EqualValues(t, 1, backend.estimates.Load()) + } + if test.noWait { + require.Zero(t, backend.receipts.Load()) + } else { + require.EqualValues(t, 1, backend.receipts.Load()) + } + if test.missingEvent { + require.ErrorContains(t, err, "no matching InputAdded event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err, stderr.String()) + if !test.json { + require.Contains(t, stdout.String(), "Index: 17 BlockNumber: 16") + require.Contains(t, stdout.String(), tx.Hash().Hex()) + return + } + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if test.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "input_index") + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + require.Equal(t, "0x11", result["input_index"]) + require.Equal(t, "0x10", result["block_number"]) + } + }) + } +} + +func TestSendRejectsInputBoxOverride(t *testing.T) { + resetSendCommand(t) + // An unusable DSN proves the flag fails before database or chain work. + t.Setenv(config.DATABASE_CONNECTION, "invalid") + var stdout, stderr bytes.Buffer + root.Cmd.SetOut(&stdout) + root.Cmd.SetErr(&stderr) + root.Cmd.SetArgs([]string{"send", "example", "hello", "--inputbox", common.HexToAddress("0xa1").Hex()}) + require.ErrorContains(t, root.Cmd.ExecuteContext(t.Context()), "--inputbox is not supported by send") + require.Empty(t, stdout.String()) +} + +func resetSendCommand(t *testing.T) { + t.Helper() + for _, flags := range []*pflag.FlagSet{root.Cmd.PersistentFlags(), send.Cmd.Flags()} { + flags.VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) + } + send.Cmd.SetContext(t.Context()) + t.Cleanup(func() { + root.Cmd.SetOut(nil) + root.Cmd.SetErr(nil) + }) +} + +type sendRPC struct { + sent chan *types.Transaction + logs []*types.Log + application common.Address + inputBox common.Address + getterID []byte + getterCalls atomic.Int32 + chainReads atomic.Int32 + receipts atomic.Int32 + estimates atomic.Int32 +} + +type sendCall struct { + To common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` +} + +func (b *sendRPC) Call(_ context.Context, call sendCall, block string) (hexutil.Bytes, error) { + if call.To != b.application || !bytes.Equal(call.Input, b.getterID) || block != "latest" { + return nil, fmt.Errorf("unexpected application getter: %+v at %s", call, block) + } + b.getterCalls.Add(1) + return common.LeftPadBytes(b.inputBox.Bytes(), common.HashLength), nil +} + +func (b *sendRPC) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + b.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*sendRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(16), Difficulty: big.NewInt(1), GasLimit: sendTestGas} +} + +func (*sendRPC) GasPrice(context.Context) *hexutil.Big { return (*hexutil.Big)(big.NewInt(1)) } + +func (*sendRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { return 0 } + +func (*sendRPC) GetCode(context.Context, common.Address, string) hexutil.Bytes { + return hexutil.Bytes{1} +} + +func (b *sendRPC) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + b.estimates.Add(1) + return sendTestGas +} + +func (b *sendRPC) SendRawTransaction(ctx context.Context, data hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + select { + case b.sent <- tx: + case <-ctx.Done(): + return common.Hash{}, ctx.Err() + } + return tx.Hash(), nil +} + +func (b *sendRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + b.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(16), + Logs: b.logs, GasUsed: sendTestGas, CumulativeGasUsed: sendTestGas} +} diff --git a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go index 66a968c41..86d92ba0c 100644 --- a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go +++ b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go @@ -13,6 +13,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" @@ -28,7 +29,7 @@ var Cmd = &cobra.Command{ Short: "Withdraw the funds of a single account from a foreclosed application", Example: examples, Args: cobra.ExactArgs(1), - Run: run, + RunE: run, Long: ` Calls IApplication.withdraw(account, AccountValidityProof). The signer is just the gas-payer; the recipient of the funds is encoded inside the 'account' @@ -79,6 +80,7 @@ func init() { cobra.CheckErr(Cmd.MarkFlagRequired("proof-file")) Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) origHelpFunc := Cmd.HelpFunc() Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { @@ -89,53 +91,77 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) - cobra.CheckErr(err) + if err != nil { + return err + } account, proof, err := loadProof(proofFileParam) - cobra.CheckErr(err) + if err != nil { + return err + } appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) - cobra.CheckErr(err) + if err != nil { + return err + } ethEndpoint, err := config.GetBlockchainHttpEndpoint() - cobra.CheckErr(err) + if err != nil { + return err + } client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) - cobra.CheckErr(err) + if err != nil { + return err + } + defer client.Close() chainID, err := client.ChainID(ctx) - cobra.CheckErr(err) + if err != nil { + return err + } txOpts, err := cli.GetTransactOpts(ctx, chainID) - cobra.CheckErr(err) + if err != nil { + return err + } appContract, err := iapplication.NewIApplication(appAddr, client) - cobra.CheckErr(err) + if err != nil { + return err + } // Identify the WithdrawalOutputBuilder and try to surface a decoded // recipient + amount. A hand-edit that flips a few characters in // `account` would otherwise produce a self-consistent proof against // the wrong recipient and the withdraw would silently succeed. builderAddr, err := appContract.GetWithdrawalOutputBuilder(&bind.CallOpts{Context: ctx}) - cobra.CheckErr(err) + if err != nil { + return err + } accountDesc, matched, err := ethutil.DescribeWithdrawalAccount(ctx, client, builderAddr, account) - cobra.CheckErr(err) + if err != nil { + return err + } if !matched { // Unknown builder family. Print the raw bytes so the operator can // verify character-for-character, and force interactive // confirmation even when --yes is set. - fmt.Fprintf(os.Stderr, + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: builder %s is not a recognized WithdrawalOutputBuilder family.\n"+ " The recipient cannot be auto-decoded. Verify the bytes below\n"+ " match your intended account before confirming; --yes is ignored.\n%s", builderAddr, hex.Dump(account)) + if err != nil { + return err + } } if !skipConfirmation || !matched { - fmt.Printf("Preparing to withdraw an account from application %v\n"+ + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to withdraw an account from application %v\n"+ " gas-payer: %v (does NOT have to be the funds recipient)\n"+ " withdrawal builder: %v\n"+ " account size: %d bytes\n"+ @@ -143,39 +169,41 @@ func run(cmd *cobra.Command, args []string) { " proof siblings: %d\n", appAddr, txOpts.From, builderAddr, len(account), proof.AccountIndex, len(proof.AccountRootSiblings)) + if err != nil { + return err + } if matched { - fmt.Println(accountDesc) + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), accountDesc); err != nil { + return err + } + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr } - confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") - cobra.CheckErr(promptErr) if !confirmed { - fmt.Println("Transaction cancelled") - os.Exit(0) + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err } } - tx, err := appContract.Withdraw(txOpts, account, proof) - // go-ethereum's binding returns (signedTx, sendErr) when signing - // succeeded but the broadcast/response read failed — the tx may already - // be in the mempool. Surface the hash on stderr so the operator can find - // it even when CheckErr below aborts. - if tx != nil { - fmt.Fprintf(os.Stderr, "broadcast attempt sent — tx hash %s\n", tx.Hash().Hex()) + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.Withdraw(opts, account, proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) } - cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - txHash := tx.Hash() if asJSONParam { result := struct { - TransactionHash string `json:"transaction_hash"` + cli.TransactionResult ApplicationAddr common.Address `json:"application_address"` - }{TransactionHash: txHash.Hex(), ApplicationAddr: appAddr} - jsonBytes, err := json.MarshalIndent(&result, "", " ") - cobra.CheckErr(err) - fmt.Println(string(jsonBytes)) - } else { - fmt.Printf("withdraw tx-hash: %v\n", txHash) + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) } + return cli.WriteTransactionResult(cmd, tx, receipt) } func loadProof(path string) ([]byte, iapplication.AccountValidityProof, error) { diff --git a/cmd/cartesi-rollups-evm-reader/root/root.go b/cmd/cartesi-rollups-evm-reader/root/root.go index f8384798d..b868f83b2 100644 --- a/cmd/cartesi-rollups-evm-reader/root/root.go +++ b/cmd/cartesi-rollups-evm-reader/root/root.go @@ -25,7 +25,6 @@ var ( pollInterval string databaseConnection string maxStartupTime string - enableInputReader bool telemetryAddress string cfg *config.EvmreaderConfig maxBlockRange uint64 @@ -60,8 +59,6 @@ func init() { "Poll interval") cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, "Maximum startup time in seconds") - cli.AddFlagBoolVar(flags, &enableInputReader, "input-reader", config.FEATURE_INPUT_READER_ENABLED, - "Enable or disable the input reader (for external input readers)") cli.AddFlagUint64Var(flags, &maxBlockRange, "max-block-range", config.BLOCKCHAIN_MAX_BLOCK_RANGE, "Maximum number of blocks in a single query. large queries will be split automatically. Zero for unlimited.") diff --git a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go index 85e255b6b..35e0b93ba 100644 --- a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go +++ b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go @@ -5,10 +5,10 @@ package accountdrive import ( "bytes" - "encoding/binary" "errors" "fmt" "math" + "math/big" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -18,6 +18,7 @@ const ( Log2AccountSize = 5 AccountSize = 1 << Log2AccountSize DefaultLog2MaxAccount = 17 + usdBalanceSize = 12 ) var ( @@ -43,43 +44,44 @@ func DriveSize(log2MaxNumOfAccounts uint8, log2LeavesPerAccount uint8) (uint64, return 1 << (Log2AccountSize + log2MaxNumOfAccounts), nil } -func Encode(address common.Address, balance uint64) ([AccountSize]byte, error) { +func Encode(address common.Address, balance *big.Int) ([AccountSize]byte, error) { var account [AccountSize]byte if address == (common.Address{}) { return account, errors.New("account address must not be zero") } - if balance == 0 { + if balance == nil || balance.Sign() <= 0 { return account, errors.New("account balance must be positive") } - if balance > math.MaxInt64 { - return account, fmt.Errorf("account balance %d exceeds int64 accounts-drive limit", balance) + if balance.BitLen() > 8*usdBalanceSize { + return account, fmt.Errorf("account balance %s exceeds uint96 accounts-drive limit", balance) } - binary.LittleEndian.PutUint64(account[:8], balance) - copy(account[8:28], address.Bytes()) + balanceBytes := balance.Bytes() + for i := range balanceBytes { + account[i] = balanceBytes[len(balanceBytes)-1-i] + } + copy(account[usdBalanceSize:], address.Bytes()) return account, nil } -func Decode(account []byte) (common.Address, uint64, bool, error) { +func Decode(account []byte) (common.Address, *big.Int, bool, error) { var zero [AccountSize]byte if len(account) != AccountSize { - return common.Address{}, 0, false, fmt.Errorf("account record must be %d bytes, got %d", AccountSize, len(account)) + return common.Address{}, nil, false, fmt.Errorf("account record must be %d bytes, got %d", AccountSize, len(account)) } if bytes.Equal(account, zero[:]) { - return common.Address{}, 0, false, nil + return common.Address{}, nil, false, nil } - balance := binary.LittleEndian.Uint64(account[:8]) - if balance == 0 { - return common.Address{}, 0, false, errors.New("non-empty account has zero balance") + var balanceBytes [usdBalanceSize]byte + for i := range balanceBytes { + balanceBytes[len(balanceBytes)-1-i] = account[i] } - if balance > math.MaxInt64 { - return common.Address{}, 0, false, fmt.Errorf("account balance %d exceeds int64 accounts-drive limit", balance) + balance := new(big.Int).SetBytes(balanceBytes[:]) + if balance.Sign() == 0 { + return common.Address{}, nil, false, errors.New("non-empty account has zero balance") } - address := common.BytesToAddress(account[8:28]) + address := common.BytesToAddress(account[usdBalanceSize:]) if address == (common.Address{}) { - return common.Address{}, 0, false, errors.New("non-empty account has zero address") - } - if !bytes.Equal(account[28:32], []byte{0, 0, 0, 0}) { - return common.Address{}, 0, false, errors.New("non-empty account has non-zero padding") + return common.Address{}, nil, false, errors.New("non-empty account has zero address") } return address, balance, true, nil } diff --git a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go index 3ab33e5dc..94a57709a 100644 --- a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go +++ b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go @@ -6,23 +6,66 @@ package accountdrive import ( "encoding/hex" "errors" + "math/big" "testing" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" ) -func TestEncode_MatchesEwtoolsLayout(t *testing.T) { - account, err := Encode(common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), 7) +func TestEncodeMatchesLibUsdAccountLayout(t *testing.T) { + address := common.HexToAddress("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266") + balance, ok := new(big.Int).SetString("0123456789abcdef01234567", 16) + require.True(t, ok) + + account, err := Encode(address, balance) require.NoError(t, err) require.Equal(t, - "0700000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb00000000", + "67452301efcdab8967452301f39fd6e51aad88f6f4ce6ab8827279cfffb92266", hex.EncodeToString(account[:]), ) + + decodedAddress, decodedBalance, nonEmpty, err := Decode(account[:]) + require.NoError(t, err) + require.True(t, nonEmpty) + require.Equal(t, address, decodedAddress) + require.Zero(t, balance.Cmp(decodedBalance)) +} + +func TestEncodeSupportsUint96(t *testing.T) { + maxUint96 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 96), big.NewInt(1)) + address := common.HexToAddress("0x1111111111111111111111111111111111111111") + + account, err := Encode(address, maxUint96) + require.NoError(t, err) + require.Equal(t, "ffffffffffffffffffffffff1111111111111111111111111111111111111111", + hex.EncodeToString(account[:])) + + decodedAddress, decodedBalance, nonEmpty, err := Decode(account[:]) + require.NoError(t, err) + require.True(t, nonEmpty) + require.Equal(t, address, decodedAddress) + require.Zero(t, maxUint96.Cmp(decodedBalance)) } -func TestDecode_RejectsCorruptRecords(t *testing.T) { +func TestEncodeRejectsInvalidBalances(t *testing.T) { + address := common.HexToAddress("0x1111111111111111111111111111111111111111") + tooLarge := new(big.Int).Lsh(big.NewInt(1), 96) + for _, balance := range []*big.Int{nil, big.NewInt(0), big.NewInt(-1), tooLarge} { + _, err := Encode(address, balance) + require.Error(t, err) + } +} + +func TestDecodeRejectsCorruptRecords(t *testing.T) { + t.Run("wrong record size is invalid", func(t *testing.T) { + for _, size := range []int{AccountSize - 1, AccountSize + 1} { + _, _, _, err := Decode(make([]byte, size)) + require.ErrorContains(t, err, "must be 32 bytes") + } + }) + t.Run("zero record is empty", func(t *testing.T) { var zero [AccountSize]byte _, _, ok, err := Decode(zero[:]) @@ -30,22 +73,29 @@ func TestDecode_RejectsCorruptRecords(t *testing.T) { require.False(t, ok) }) - t.Run("non-zero padding is invalid", func(t *testing.T) { - account, err := Encode(common.HexToAddress("0x1111111111111111111111111111111111111111"), 1) - require.NoError(t, err) - account[31] = 1 + t.Run("zero balance is invalid", func(t *testing.T) { + var account [AccountSize]byte + copy(account[usdBalanceSize:], common.HexToAddress("0x1111111111111111111111111111111111111111").Bytes()) + + _, _, _, err := Decode(account[:]) + require.ErrorContains(t, err, "zero balance") + }) + + t.Run("zero address is invalid", func(t *testing.T) { + var account [AccountSize]byte + account[0] = 1 - _, _, _, err = Decode(account[:]) - require.ErrorContains(t, err, "padding") + _, _, _, err := Decode(account[:]) + require.ErrorContains(t, err, "zero address") }) } func TestBuildProof_BuildsVerifiableAccountProof(t *testing.T) { addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222") - account1, err := Encode(addr1, 10) + account1, err := Encode(addr1, big.NewInt(10)) require.NoError(t, err) - account2, err := Encode(addr2, 20) + account2, err := Encode(addr2, big.NewInt(20)) require.NoError(t, err) drive := make([]byte, 1<<(Log2AccountSize+DefaultLog2MaxAccount)) @@ -74,7 +124,7 @@ func TestBuildProof_RejectsUnsupportedLayout(t *testing.T) { func TestBuildProof_RejectsNonCompactAccountTable(t *testing.T) { addr := common.HexToAddress("0x1111111111111111111111111111111111111111") - account, err := Encode(addr, 10) + account, err := Encode(addr, big.NewInt(10)) require.NoError(t, err) drive := make([]byte, 3*AccountSize) copy(drive[2*AccountSize:3*AccountSize], account[:]) diff --git a/cmd/cartesi-rollups-node/root/root.go b/cmd/cartesi-rollups-node/root/root.go index eaaa53c6e..691f62b1a 100644 --- a/cmd/cartesi-rollups-node/root/root.go +++ b/cmd/cartesi-rollups-node/root/root.go @@ -41,7 +41,6 @@ var ( claimerPollInterval string prtPollInterval string maxStartupTime string - enableInputReader bool enableInspect bool enableJsonrpc bool enableSubmission bool @@ -107,8 +106,6 @@ func init() { "PRT poll interval") cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, "Maximum startup time in seconds") - cli.AddFlagBoolVar(flags, &enableInputReader, "input-reader", config.FEATURE_INPUT_READER_ENABLED, - "Enable or disable the input reader (for external input readers)") cli.AddFlagBoolVar(flags, &enableInspect, "inspect-enabled", config.FEATURE_INSPECT_ENABLED, "Enable or disable the inspect service") cli.AddFlagBoolVar(flags, &enableJsonrpc, "jsonrpc-enabled", config.FEATURE_JSONRPC_API_ENABLED, diff --git a/compose.individual-services.yaml b/compose.individual-services.yaml index f70b25611..ad7a3d86b 100644 --- a/compose.individual-services.yaml +++ b/compose.individual-services.yaml @@ -3,15 +3,18 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint CARTESI_BLOCKCHAIN_ID: 31337 CARTESI_EVM_READER_POLLING_INTERVAL: 1 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x346B3df038FE9f8380071eC6514D5a83aD143939 - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0 - CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: 0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483 - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x6145C5996a71a379E030aEb0440df79D60833418 - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0x33FFf0b681c90664dD048a60400AE2D827a4c5bb - CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: 0x0745787835A019cd4dae8EDB541Fbc0647793d63 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" CARTESI_DATABASE_CONNECTION_FILE: /run/secrets/database_connection CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 services: ethereum_provider: @@ -63,6 +66,11 @@ services: - devnet ports: - "10001:10001" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10001/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - auth_mnemonic - blockchain_http_endpoint @@ -86,6 +94,11 @@ services: ports: - "10002:10002" # Supervisor - "10012:10012" # Inspect Service + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10002/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - auth_mnemonic - database_connection @@ -102,6 +115,11 @@ services: - devnet ports: - "10003:10003" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10003/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - database_connection environment: @@ -119,6 +137,35 @@ services: - devnet ports: - "10004:10004" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10004/readyz"] + interval: 1s + timeout: 1s + retries: 5 + secrets: + - auth_mnemonic + - blockchain_http_endpoint + - database_connection + environment: + <<: *env + + prt: + image: cartesi/rollups-node:devel + command: cartesi-rollups-prt + depends_on: + database: + condition: service_healthy + ethereum_provider: + condition: service_healthy + networks: + - devnet + ports: + - "10006:10006" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10006/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - auth_mnemonic - blockchain_http_endpoint @@ -137,6 +184,11 @@ services: ports: - "10005:10005" # Supervisor - "10011:10011" # Jsonrpc API service + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10005/readyz"] + interval: 1s + timeout: 1s + retries: 5 secrets: - auth_mnemonic - blockchain_http_endpoint diff --git a/compose.yaml b/compose.yaml index 4cedca5f9..8921366c9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,15 +3,18 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint CARTESI_BLOCKCHAIN_ID: 31337 CARTESI_EVM_READER_POLLING_INTERVAL: 1 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x346B3df038FE9f8380071eC6514D5a83aD143939 - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0 - CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: 0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483 - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x6145C5996a71a379E030aEb0440df79D60833418 - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0x33FFf0b681c90664dD048a60400AE2D827a4c5bb - CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: 0x0745787835A019cd4dae8EDB541Fbc0647793d63 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" CARTESI_DATABASE_CONNECTION_FILE: /run/secrets/database_connection CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 services: ethereum_provider: diff --git a/go.mod b/go.mod index 89997af8b..ee3f893cf 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/holiman/uint256 v1.3.2 github.com/jackc/pgx/v5 v5.10.0 github.com/lmittmann/tint v1.1.3 github.com/oapi-codegen/runtime v1.4.1 @@ -69,7 +70,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/holiman/uint256 v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect diff --git a/internal/advancer/advancer.go b/internal/advancer/advancer.go index 546f40ab3..ed2f8a7f8 100644 --- a/internal/advancer/advancer.go +++ b/internal/advancer/advancer.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/manager" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" @@ -27,14 +28,18 @@ var ( // AdvancerRepository defines the repository interface needed by the Advancer service type AdvancerRepository interface { - ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool) ([]*Epoch, uint64, error) - ListInputs(ctx context.Context, nameOrAddress string, f repository.InputFilter, p repository.Pagination, descending bool) ([]*Input, uint64, error) + ListEpochs( + ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool, + ) ([]*Epoch, uint64, error) + ListInputs( + ctx context.Context, nameOrAddress string, f repository.InputFilter, p repository.Pagination, descending bool, + ) ([]*Input, uint64, error) GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) StoreAdvanceResult(ctx context.Context, appID int64, ar *AdvanceResult) error UpdateEpochInputsProcessed(ctx context.Context, nameOrAddress string, epochIndex uint64, proof *StateProof) error UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) - UpdateInputSnapshotURI(ctx context.Context, appId int64, inputIndex uint64, snapshotURI string) error + UpdateInputSnapshotURI(ctx context.Context, appID int64, inputIndex uint64, snapshotURI string) error GetLastSnapshot(ctx context.Context, nameOrAddress string) (*Input, error) GetLastProcessedInput(ctx context.Context, appAddress string) (*Input, error) } @@ -61,8 +66,8 @@ func getUnprocessedInputs( // order, and returns whether any application had work remaining. // // Per-app errors are accumulated so that a failure in one application does not block -// processing of other healthy applications. Context cancellation is always propagated -// immediately. +// processing of other healthy applications. Cancellation stops new application work +// and preserves errors already collected during the cycle. // // The returned boolean indicates whether any app successfully processed inputs and // potentially has more work. Callers use this to decide whether to re-tick immediately @@ -88,14 +93,17 @@ func (s *Service) Step(ctx context.Context) (bool, error) { anyWork := false errs := []error{updateErr} for _, app := range apps { + if err := ctx.Err(); err != nil { + return false, errors.Join(append(errs, err)...) + } hadWork, err := s.stepApp(ctx, app) if err != nil { + errs = append(errs, err) // Context errors (cancellation or timeout) mean no further apps will - // succeed — stop immediately instead of accumulating identical errors. + // succeed. Stop now, but retain earlier application failures. if ctx.Err() != nil { - return false, err + return false, errors.Join(errs...) } - errs = append(errs, err) continue } if hadWork { @@ -176,6 +184,11 @@ func (s *Service) finalizeEpoch(ctx context.Context, app *Application, epoch *Ep appAddress := app.IApplicationAddress.String() if err := s.repository.UpdateEpochInputsProcessed(ctx, appAddress, epoch.Index, proof); err != nil { + if errors.Is(err, repository.ErrEpochForeclosed) { + s.Logger.Info("Epoch was foreclosed before state-proof publication; discarding obsolete publication", + "application", app.Name, "epoch_index", epoch.Index) + return nil + } return fmt.Errorf( "publishing state proof for application %s epoch %d: %w", app.Name, @@ -315,7 +328,7 @@ func (s *Service) processInputs( if err != nil { var errCause string switch { - case errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled): + case errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err): errCause = "canceled advance result persistence" // Shutdown interrupted persistence after the machine advanced. // Discard that runtime; restart must recover from persisted state. diff --git a/internal/advancer/advancer_test.go b/internal/advancer/advancer_test.go index 2e93c7655..372225186 100644 --- a/internal/advancer/advancer_test.go +++ b/internal/advancer/advancer_test.go @@ -1427,7 +1427,7 @@ func (s *AdvancerSuite) TestRemoveSnapshot() { tmpDir := s.T().TempDir() advancer := &Service{snapshotsDir: tmpDir} - serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: "advancer"}} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) // Create a snapshot directory @@ -1446,7 +1446,7 @@ func (s *AdvancerSuite) TestRemoveSnapshot() { tmpDir := s.T().TempDir() advancer := &Service{snapshotsDir: tmpDir} - serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: "advancer"}} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) snapshotPath := filepath.Join(tmpDir, "myapp_epoch0_input0") @@ -1459,7 +1459,7 @@ func (s *AdvancerSuite) TestRemoveSnapshot() { tmpDir := s.T().TempDir() advancer := &Service{snapshotsDir: tmpDir} - serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: "advancer"}} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) // Try to traverse outside snapshotsDir @@ -1474,7 +1474,7 @@ func (s *AdvancerSuite) TestRemoveSnapshot() { tmpDir := s.T().TempDir() advancer := &Service{snapshotsDir: tmpDir} - serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: "advancer"}} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) snapshotPath := filepath.Join(tmpDir, "otherapp_epoch0_input0") @@ -2064,9 +2064,7 @@ func (s *AdvancerSuite) TestNoSelfWakeOnError() { "reschedule should NOT be signaled on error") } -// Error from Step does NOT signal reschedule. -// Uses the same pattern as FailedAppDoesNotBlockOtherApps, which -// verifies Step returns an error when one app fails. +// Progress in one application still requests another tick when another fails. func (s *AdvancerSuite) TestPartialSuccessStillReschedules() { require := s.Require() @@ -2289,7 +2287,7 @@ func (mock *MockMachineManager) GetMachine(appID int64) (manager.MachineInstance return instance, true } -func (mock *MockMachineManager) UpdateMachines(ctx context.Context) error { +func (mock *MockMachineManager) UpdateMachines(_ context.Context) error { if mock.UpdateMachinesError != nil { return mock.UpdateMachinesError } @@ -2342,13 +2340,15 @@ type MockMachineInstance struct { } // Advance implements the MachineInstance interface for testing -func (m *MockMachineInstance) Advance(ctx context.Context, input []byte, epochIndex uint64, index uint64, leafs bool) (*AdvanceResult, error) { +func (m *MockMachineInstance) Advance( + ctx context.Context, input []byte, epochIndex uint64, index uint64, leafs bool, +) (*AdvanceResult, error) { m.advanceCalls++ return m.machineImpl.Advance(ctx, input, epochIndex, index, leafs) } // Inspect implements the MachineInstance interface for testing -func (m *MockMachineInstance) Inspect(ctx context.Context, query []byte) (*manager.InspectResult, error) { +func (m *MockMachineInstance) Inspect(_ context.Context, _ []byte) (*manager.InspectResult, error) { // Not used in advancer tests, but needed to satisfy the interface return nil, nil } @@ -2370,7 +2370,7 @@ func (m *MockMachineInstance) StateProof(_ context.Context) (*StateProof, error) } // CreateSnapshot implements the MachineInstance interface for testing -func (m *MockMachineInstance) CreateSnapshot(ctx context.Context, processInputs uint64, path string) error { +func (m *MockMachineInstance) CreateSnapshot(_ context.Context, _ uint64, _ string) error { if m.createSnapshotError != nil && m.destroyAfterSnapshotError { m.machineImpl.AdvanceError = manager.ErrMachineClosed return errors.Join(manager.ErrMachineClosed, m.createSnapshotError) @@ -2379,7 +2379,7 @@ func (m *MockMachineInstance) CreateSnapshot(ctx context.Context, processInputs } // Retrieves the hash of the current machine state -func (m *MockMachineInstance) Hash(ctx context.Context) ([32]byte, error) { +func (m *MockMachineInstance) Hash(_ context.Context) ([32]byte, error) { // Not used in advancer tests, but needed to satisfy the interface return [32]byte{}, nil } @@ -2430,9 +2430,9 @@ type MockRepository struct { func (mock *MockRepository) ListEpochs( ctx context.Context, nameOrAddress string, - f repository.EpochFilter, - p repository.Pagination, - descending bool, + _ repository.EpochFilter, + _ repository.Pagination, + _ bool, ) ([]*Epoch, uint64, error) { // Check for context cancellation if ctx.Err() != nil { @@ -2454,7 +2454,7 @@ func (mock *MockRepository) ListInputs( nameOrAddress string, f repository.InputFilter, p repository.Pagination, - descending bool, + _ bool, ) ([]*Input, uint64, error) { // Check for context cancellation if ctx.Err() != nil { @@ -2567,7 +2567,7 @@ func (mock *MockRepository) UpdateEpochInputsProcessed( return mock.UpdateEpochsError } -func (mock *MockRepository) UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error { +func (mock *MockRepository) UpdateApplicationStatus(ctx context.Context, _ int64, status ApplicationStatus, reason *string) error { // Check for context cancellation if ctx.Err() != nil { return ctx.Err() @@ -2579,7 +2579,7 @@ func (mock *MockRepository) UpdateApplicationStatus(ctx context.Context, appID i return mock.UpdateApplicationStatusError } -func (mock *MockRepository) GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) { +func (mock *MockRepository) GetEpoch(ctx context.Context, _ string, _ uint64) (*Epoch, error) { if ctx.Err() != nil { return nil, ctx.Err() } @@ -2646,7 +2646,7 @@ func (mock *MockRepository) GetLastProcessedInput(ctx context.Context, appAddres return lastInput, nil } -func (mock *MockRepository) UpdateInputSnapshotURI(ctx context.Context, appId int64, inputIndex uint64, snapshotURI string) error { +func (mock *MockRepository) UpdateInputSnapshotURI(ctx context.Context, _ int64, _ uint64, _ string) error { if ctx.Err() != nil { return ctx.Err() } @@ -2654,7 +2654,7 @@ func (mock *MockRepository) UpdateInputSnapshotURI(ctx context.Context, appId in return mock.UpdateSnapshotURIError } -func (mock *MockRepository) GetLastSnapshot(ctx context.Context, nameOrAddress string) (*Input, error) { +func (mock *MockRepository) GetLastSnapshot(ctx context.Context, _ string) (*Input, error) { // Check for context cancellation if ctx.Err() != nil { return nil, ctx.Err() @@ -2717,19 +2717,19 @@ func randomSliceOfBytes() [][]byte { return slice } -func newInput(appId int64, epochIndex uint64, inputIndex uint64, data []byte) *Input { +func newInput(appID int64, epochIndex uint64, inputIndex uint64, data []byte) *Input { return &Input{ - EpochApplicationID: appId, + EpochApplicationID: appID, EpochIndex: epochIndex, Index: inputIndex, RawData: data, } } -func randomInputs(appId int64, epochIndex uint64, size int) []*Input { +func randomInputs(appID int64, epochIndex uint64, size int) []*Input { slice := make([]*Input, size) for i := range size { - slice[i] = newInput(appId, epochIndex, uint64(i), randomBytes()) + slice[i] = newInput(appID, epochIndex, uint64(i), randomBytes()) } return slice } @@ -2770,6 +2770,11 @@ func (s *AdvancerSuite) TestStoreAdvanceShutdownClassification() { {"independent cancellation", false, context.Canceled, nil, context.Canceled}, {"storage failure", false, storageErr, nil, storageErr}, {"storage failure during shutdown", true, storageErr, nil, storageErr}, + {"mixed storage failure during shutdown", true, errors.Join(context.Canceled, storageErr), nil, storageErr}, + {"nested storage failure during shutdown", true, + fmt.Errorf("store: %w", errors.Join(storageErr, context.Canceled)), nil, storageErr}, + {"deadline during shutdown", true, errors.Join(context.Canceled, context.DeadlineExceeded), nil, context.DeadlineExceeded}, + {"joined shutdown cancellation", true, errors.Join(context.Canceled, context.Canceled), nil, nil}, {"close failure during shutdown", true, context.Canceled, closeErr, closeErr}, } { s.Run(tc.name, func() { diff --git a/internal/advancer/publication_test.go b/internal/advancer/publication_test.go new file mode 100644 index 000000000..6da0de6e5 --- /dev/null +++ b/internal/advancer/publication_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + "fmt" + "log/slog" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *AdvancerSuite) TestFinalizeEpochPublicationOutcome() { + for _, test := range []struct { + name string + storeErr error + obsolete bool + }{ + {name: "published"}, + {name: "foreclosed during publication", storeErr: fmt.Errorf("state changed: %w", repository.ErrEpochForeclosed), obsolete: true}, + {name: "unexpected state", storeErr: repository.ErrNoUpdate}, + {name: "database failure", storeErr: errors.New("database write failed")}, + } { + s.Run(test.name, func() { + env := s.setupOneApp() + logs := &advancerLogCapture{} + env.service.Logger = slog.New(logs) + env.repo.UpdateEpochsError = test.storeErr + epoch := &Epoch{ApplicationID: env.app.Application.ID, Index: 2, Status: EpochStatus_Closed} + + err := env.service.finalizeEpoch(context.Background(), env.app.Application, epoch) + + if test.obsolete || test.storeErr == nil { + s.Require().NoError(err) + } else { + s.Require().ErrorIs(err, test.storeErr) + } + s.Equal(1, env.repo.EpochInputsProcessedCount, "make one publication attempt; do not retry a rejected write") + s.Equal(test.storeErr == nil, logs.contains(slog.LevelInfo, "Epoch updated to Inputs Processed")) + s.Equal(test.obsolete, logs.contains(slog.LevelInfo, + "Epoch was foreclosed before state-proof publication; discarding obsolete publication")) + s.Zero(env.repo.ApplicationStatusUpdates) + s.Equal(EpochStatus_Closed, epoch.Status, "the stale input object must not be used to reset stored state") + }) + } +} diff --git a/internal/advancer/service.go b/internal/advancer/service.go index 1c2cb341f..0525f4f29 100644 --- a/internal/advancer/service.go +++ b/internal/advancer/service.go @@ -10,6 +10,7 @@ import ( "log/slog" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/manager" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/machine" @@ -106,23 +107,14 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { // Without this, one failing app delays all healthy apps by a full poll interval. hadWork, err := s.Step(ctx) - if err == nil { - return hadWork, nil - } - // During shutdown, the machine manager is closed and GetMachine() may - // return ErrNoApp. Suppress this to avoid spurious ERR log entries. - if errors.Is(err, ErrNoApp) && ctx.Err() != nil { - s.Logger.Warn("Tick interrupted by shutdown", "error", err) - return hadWork, nil - } - // Canceled is graceful per the project convention: code paths that - // wrap cancellation (e.g. handleSnapshot → createSnapshot → - // "failed to update input snapshot URI: %w") would otherwise surface - // at ERR via the framework's Tick wrapper. DeadlineExceeded remains a - // real failure and is propagated. - if errors.Is(err, context.Canceled) { - s.Logger.Debug("Tick cancelled (shutdown)", "error", err) - return hadWork, nil + if ctx.Err() != nil { + hadWork = false + // Only cancellation caused by service shutdown is quiet. Preserve + // independent failures, including those joined with cancellation. + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick canceled during shutdown", "error", err) + err = nil + } } return hadWork, err } diff --git a/internal/advancer/shutdown_test.go b/internal/advancer/shutdown_test.go new file mode 100644 index 000000000..eb674df90 --- /dev/null +++ b/internal/advancer/shutdown_test.go @@ -0,0 +1,166 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/require" +) + +func TestAdvancerTickCancellation(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + shutdown bool + cause error + quiet bool + }{ + {name: "active cancellation", cause: context.Canceled}, + {name: "active mixed failure", cause: errors.Join(context.Canceled, dbErr)}, + {name: "shutdown cancellation", shutdown: true, cause: context.Canceled, quiet: true}, + {name: "wrapped shutdown", shutdown: true, cause: fmt.Errorf("query: %w", context.Canceled), quiet: true}, + {name: "joined shutdown", shutdown: true, cause: errors.Join(context.Canceled, context.Canceled), quiet: true}, + {name: "shutdown database failure", shutdown: true, cause: dbErr}, + {name: "shutdown mixed failure", shutdown: true, cause: errors.Join(context.Canceled, dbErr)}, + {name: "shutdown nested failure", shutdown: true, cause: fmt.Errorf("query: %w", errors.Join(dbErr, context.Canceled))}, + {name: "active deadline", cause: context.DeadlineExceeded}, + {name: "shutdown deadline", shutdown: true, cause: context.DeadlineExceeded}, + {name: "shutdown mixed deadline", shutdown: true, cause: errors.Join(context.Canceled, context.DeadlineExceeded)}, + {name: "active missing machine", cause: ErrNoApp}, + {name: "shutdown missing machine", shutdown: true, cause: ErrNoApp}, + {name: "shutdown missing machine and failure", shutdown: true, cause: errors.Join(ErrNoApp, dbErr)}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := &MockRepository{} + mm := newMockMachineManager() + svc, err := newMockAdvancerService(mm, repo) + require.NoError(t, err) + svc.machineManager = &shutdownMachineManager{MockMachineManager: mm, update: func(context.Context) error { + if test.shutdown { + cancel() + } + return test.cause + }} + logs := &advancerLogCapture{} + svc.Logger = slog.New(logs) + + reschedule, err := svc.Tick(ctx) + require.False(t, reschedule) + if test.quiet { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.cause, "preserve the complete dependency error") + } + require.Equal(t, test.quiet, logs.contains(slog.LevelDebug, "Tick canceled during shutdown")) + require.Zero(t, repo.ApplicationStatusUpdates) + require.False(t, mm.PendingApplicationFailures) + }) + } +} + +func TestAdvancerStepPreservesEarlierFailures(t *testing.T) { + firstErr := errors.New("first application database failure") + for _, lastErr := range []error{nil, context.Canceled, errors.Join(context.Canceled, errors.New("second database failure"))} { + for _, method := range []string{"Step", "Tick"} { + t.Run(fmt.Sprintf("%s/%v", method, lastErr), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + mm := newMockMachineManager() + for id := int64(1); id <= 3; id++ { + mm.Map[id] = newMockInstance(newMockMachine(id)) + } + baseRepo := &MockRepository{} + svc, err := newMockAdvancerService(mm, baseRepo) + require.NoError(t, err) + var calls []string + svc.repository = &shutdownEpochRepository{MockRepository: baseRepo, list: func(address string) error { + calls = append(calls, address) + if len(calls) == 1 { + return firstErr + } + cancel() + return lastErr + }} + run := svc.Step + if method == "Tick" { + run = svc.Tick + } + + reschedule, err := run(ctx) + require.False(t, reschedule) + require.ErrorIs(t, err, firstErr) + if lastErr != nil { + require.ErrorIs(t, err, lastErr) + } else { + require.ErrorIs(t, err, context.Canceled, "stop before the next application even after a successful read") + } + require.Equal(t, []string{ + mm.Map[1].application.IApplicationAddress.Hex(), + mm.Map[2].application.IApplicationAddress.Hex(), + }, calls, "do not dispatch another application after cancellation") + require.Zero(t, baseRepo.ApplicationStatusUpdates) + require.False(t, mm.PendingApplicationFailures) + }) + } + } +} + +func TestAdvancerServeReportsShutdownFailures(t *testing.T) { + for _, test := range []struct { + name string + cause error + quiet bool + }{ + {"cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"mixed database failure", errors.Join(context.Canceled, errors.New("database unavailable")), false}, + {"deadline", context.DeadlineExceeded, false}, + {"missing machine and failure", errors.Join(ErrNoApp, errors.New("database unavailable")), false}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + mm := newMockMachineManager() + svc, err := newMockAdvancerService(mm, &MockRepository{}) + require.NoError(t, err) + svc.machineManager = &shutdownMachineManager{MockMachineManager: mm, update: func(context.Context) error { + cancel() + return test.cause + }} + logs := &advancerLogCapture{} + svc.Logger = slog.New(logs) + + require.ErrorIs(t, svc.Serve(ctx), context.Canceled) + require.Equal(t, !test.quiet, logs.contains(slog.LevelError, "Tick")) + }) + } +} + +type shutdownMachineManager struct { + *MockMachineManager + update func(context.Context) error +} + +func (m *shutdownMachineManager) UpdateMachines(ctx context.Context) error { + return m.update(ctx) +} + +type shutdownEpochRepository struct { + *MockRepository + list func(string) error +} + +func (r *shutdownEpochRepository) ListEpochs( + _ context.Context, address string, _ repository.EpochFilter, _ repository.Pagination, _ bool, +) ([]*model.Epoch, uint64, error) { + return nil, 0, r.list(address) +} diff --git a/internal/appstatus/appstatus.go b/internal/appstatus/appstatus.go index 470b373d6..718f3e476 100644 --- a/internal/appstatus/appstatus.go +++ b/internal/appstatus/appstatus.go @@ -23,12 +23,12 @@ type Repository interface { } // SetFailed marks an application as FAILED (recoverable). -// Use for machine runtime errors that can be resolved by operator intervention -// (e.g., OOM kill, process crash). The operator can re-enable the application -// after fixing the root cause. +// Use for runtime or internal computation errors that can be resolved by +// operator intervention (e.g., OOM kill, process crash, or a repaired software +// defect). The operator can re-enable the application after fixing the cause. // // Recovery assumptions — FAILED is safe to re-enable only when: -// - The failure was a machine runtime error (not a DB desync). +// - The failure does not prove that persisted state is inconsistent. // - The last snapshot is consistent with the database state. // - replay.Run will correctly verify inputs from the snapshot point. // @@ -234,5 +234,26 @@ func setApplicationStatus( // the in-memory Application consistent with the database. app.Status = status app.Reason = &reason + if status == ApplicationStatus_Failed { + WarnBlockedForeclosure(logger, app) + } return nil } + +// WarnBlockedForeclosure explains why FAILED prevents foreclosure work from +// finishing. Call when failure or foreclosure is recorded, not on every tick. +func WarnBlockedForeclosure(logger *slog.Logger, app *Application) { + if app.Status != ApplicationStatus_Failed || app.ForecloseBlock == 0 { + return + } + var reason string + if app.Reason != nil { + reason = *app.Reason + } + logger.Warn("Application failure blocks foreclosure drain; repair the cause before clearing FAILED", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + "reason", reason, + "recovery_command", fmt.Sprintf("cartesi-rollups-cli app status %s enabled", app.IApplicationAddress.Hex())) +} diff --git a/internal/appstatus/appstatus_test.go b/internal/appstatus/appstatus_test.go index 9ac78c9ec..073ae92c3 100644 --- a/internal/appstatus/appstatus_test.go +++ b/internal/appstatus/appstatus_test.go @@ -4,6 +4,7 @@ package appstatus import ( + "bytes" "context" "errors" "log/slog" @@ -12,9 +13,41 @@ import ( . "github.com/cartesi/rollups-node/internal/model" "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) +func TestFailedForeclosureDiagnostic(t *testing.T) { + for _, tc := range []struct { + name string + foreclosed bool + writeError error + }{ + {name: "failure after foreclosure", foreclosed: true}, + {name: "failure without foreclosure"}, + {name: "failure not recorded", foreclosed: true, writeError: errors.New("database unavailable")}, + } { + t.Run(tc.name, func(t *testing.T) { + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, nil)) + app := newTestApp() + if tc.foreclosed { + app.ForecloseBlock = 80 + } + err := SetFailed(t.Context(), logger, &mockRepo{err: tc.writeError}, app, "machine process crashed") + require.ErrorIs(t, err, tc.writeError) + if tc.foreclosed && tc.writeError == nil { + require.Contains(t, logs.String(), "failure blocks foreclosure drain") + require.Contains(t, logs.String(), "foreclose_block=80") + require.Contains(t, logs.String(), "machine process crashed") + require.Contains(t, logs.String(), "repair the cause before clearing FAILED") + } else { + require.NotContains(t, logs.String(), "failure blocks foreclosure drain") + } + }) + } +} + func TestAppStatus(t *testing.T) { suite.Run(t, new(AppStatusSuite)) } diff --git a/internal/claimer/authority_owner_test.go b/internal/claimer/authority_owner_test.go new file mode 100644 index 000000000..f820eecbd --- /dev/null +++ b/internal/claimer/authority_owner_test.go @@ -0,0 +1,283 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iauthority" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func TestAuthoritySubmissionOwnerDiagnosis(t *testing.T) { + signer := common.HexToAddress("0x1") + other := common.HexToAddress("0x2") + revert := &rpcDataError{code: 3, msg: "execution reverted: original failure", data: "0xdeadbeef"} + transportErr := errors.New("RPC transport failed") + noDataErr := &rpcDataError{code: 3, msg: "submission reverted without data"} + + for _, test := range []struct { + name string + consensus model.Consensus + submissionErr error + configuredOwner common.Address + latestOwner common.Address + wantReads bool + wantOutcome submitClaimRevertOutcome + }{ + {name: "wrong agreed owner", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: other, latestOwner: other, wantReads: true, wantOutcome: submitClaimAppHalted}, + {name: "correct owner unrelated revert", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: signer, latestOwner: signer, wantReads: true}, + {name: "ownership transferred away in latest", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: signer, latestOwner: other, wantReads: true, wantOutcome: submitClaimRetryLater}, + {name: "ownership transferred to signer in latest", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: other, latestOwner: signer, wantReads: true, wantOutcome: submitClaimRetryLater}, + {name: "other owner changed between views", consensus: model.Consensus_Authority, submissionErr: revert, + configuredOwner: other, latestOwner: common.HexToAddress("0x3"), wantReads: true, wantOutcome: submitClaimRetryLater}, + {name: "accept-only ClaimNotStaged", consensus: model.Consensus_Authority, + submissionErr: claimNotStagedError(claimStatusAccepted), configuredOwner: other, latestOwner: other, + wantReads: true, wantOutcome: submitClaimAppHalted}, + {name: "accept-only ClaimStagingPeriodNotOverYet", consensus: model.Consensus_Authority, + submissionErr: consensusRevertError("ClaimStagingPeriodNotOverYet"), configuredOwner: other, latestOwner: other, + wantReads: true, wantOutcome: submitClaimAppHalted}, + {name: "nonce rejection with revert data", consensus: model.Consensus_Authority, + submissionErr: &rpcDataError{code: 3, msg: "nonce too low", data: "0xdeadbeef"}, wantOutcome: submitClaimRetryLater}, + {name: "transport failure", consensus: model.Consensus_Authority, submissionErr: transportErr}, + {name: "no revert data", consensus: model.Consensus_Authority, submissionErr: noDataErr}, + {name: "Quorum unchanged", consensus: model.Consensus_Quorum, submissionErr: revert}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo, _ := newServiceMock(t) + app := makeApplication() + app.ConsensusType = test.consensus + epoch := makeComputedEpoch(app, 3) + backend := &authorityOwnerRPC{} + if test.wantReads { + expectAuthorityHeader(backend) + expectAuthorityOwner(t, backend, app.IConsensusAddress, 20, test.configuredOwner, nil) + expectAuthorityOwner(t, backend, app.IConsensusAddress, rpc.LatestBlockNumber, test.latestOwner, nil) + } + blockchain := &claimerBlockchain{ + client: newAuthorityOwnerClient(t, backend), logger: s.Logger, defaultBlock: model.DefaultBlock_Finalized, + txOptsFactory: &reportedSignerFactory{ + TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: signer}), + }, + } + submitter := &revertingClaimSubmitter{err: test.submissionErr} + _, err := blockchain.submitClaimToBlockchain(t.Context(), submitter, app, epoch, model.StateProof{}) + require.ErrorIs(t, err, test.submissionErr) + require.Equal(t, signer, submitter.from) + if test.wantOutcome == submitClaimUnknown { + require.Same(t, test.submissionErr, err, "an unrelated error must remain unchanged") + } + if test.wantOutcome == submitClaimAppHalted { + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, signer.String()) && + strings.Contains(*reason, other.String()) && strings.Contains(*reason, "CARTESI_AUTH_*") && + strings.Contains(*reason, test.submissionErr.Error()) + })).Return(nil).Once() + } + outcome, statusErr := s.handleSubmitClaimRevert(t.Context(), err, app, epoch) + require.NoError(t, statusErr) + require.Equal(t, test.wantOutcome, outcome) + if test.wantOutcome == submitClaimAppHalted { + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + backend.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestAuthorityOwnerReadFailurePreservesSubmissionError(t *testing.T) { + for _, failureAt := range []string{"header", "configured owner", "latest owner"} { + t.Run(failureAt, func(t *testing.T) { + s, repo, _ := newServiceMock(t) + app := makeApplication() + app.ConsensusType = model.Consensus_Authority + backend := &authorityOwnerRPC{} + lookupErr := errors.New("owner RPC unavailable") + if failureAt == "header" { + backend.On("GetBlockByNumber", rpc.FinalizedBlockNumber, false). + Return((*types.Header)(nil), lookupErr).Once() + } else { + expectAuthorityHeader(backend) + if failureAt == "configured owner" { + expectAuthorityOwner(t, backend, app.IConsensusAddress, 20, common.Address{}, lookupErr) + } else { + expectAuthorityOwner(t, backend, app.IConsensusAddress, 20, common.HexToAddress("0x2"), nil) + expectAuthorityOwner(t, backend, app.IConsensusAddress, rpc.LatestBlockNumber, common.Address{}, lookupErr) + } + } + blockchain := &claimerBlockchain{ + client: newAuthorityOwnerClient(t, backend), defaultBlock: model.DefaultBlock_Finalized, + } + original := &rpcDataError{code: 3, msg: "original submit failure", data: "0xdeadbeef"} + err := blockchain.diagnoseAuthorityOwner(t.Context(), app, common.HexToAddress("0x1"), original) + require.ErrorIs(t, err, original) + require.ErrorContains(t, err, lookupErr.Error()) + outcome, statusErr := s.handleSubmitClaimRevert(t.Context(), err, app, makeComputedEpoch(app, 3)) + require.NoError(t, statusErr) + require.Equal(t, submitClaimUnknown, outcome) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + backend.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestAuthorityOwnerDiagnosisPreservesKnownReverts(t *testing.T) { + for _, test := range []struct { + name string + want submitClaimRevertOutcome + }{ + {name: "NotFirstClaim", want: submitClaimAlreadyOnChain}, + {name: "ApplicationForeclosed", want: submitClaimRetryLater}, + {name: "CallerIsNotValidator", want: submitClaimAppHalted}, + {name: "InvalidSiblingsArrayLength", want: submitClaimAppHalted}, + {name: "InvalidMachineMerkleProof", want: submitClaimAppHalted}, + {name: "InvalidPostEpochMachineIflagsYRegister", want: submitClaimAppHalted}, + {name: "InvalidPostEpochMachineHtifTohostRegister", want: submitClaimAppHalted}, + {name: applicationNotDeployedRevert, want: submitClaimAppHalted}, + {name: applicationRevertedRevert, want: submitClaimAppHalted}, + {name: illformedApplicationReturnDataRevert, want: submitClaimAppHalted}, + {name: notEpochFinalBlockRevert, want: submitClaimAppHalted}, + {name: "NotPastBlock", want: submitClaimRetryLater}, + } { + for _, ownerEvidence := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/owner_evidence_%t", test.name, ownerEvidence), func(t *testing.T) { + s, repo, _ := newServiceMock(t) + app := makeApplication() + app.ConsensusType = model.Consensus_Authority + epoch := makeComputedEpoch(app, 3) + signer := common.HexToAddress("0x1") + backend := &authorityOwnerRPC{} + // No RPC expectation: a known submit revert must not query either + // the configured head or the Authority owner. + blockchain := &claimerBlockchain{ + client: newAuthorityOwnerClient(t, backend), logger: s.Logger, defaultBlock: model.DefaultBlock_Finalized, + txOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: signer}), + } + revert := consensusRevertError(test.name) + submitter := &revertingClaimSubmitter{err: fmt.Errorf("estimating submitClaim: %w", revert)} + _, err := blockchain.submitClaimToBlockchain(t.Context(), submitter, app, epoch, model.StateProof{}) + require.ErrorIs(t, err, revert) + require.Same(t, submitter.err, err, "the wrapped submission error must remain unchanged") + require.Empty(t, backend.Calls) + if ownerEvidence { + // Keep known-revert precedence explicit even if owner evidence is + // supplied by another caller. This transfer must not mask a proof error. + err = &authorityOwnerMismatch{signer: signer, configuredOwner: common.HexToAddress("0x2"), + latestOwner: signer, submissionErr: err} + } + if test.want == submitClaimAppHalted { + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, test.name) && !strings.Contains(*reason, "CARTESI_AUTH_*") + })).Return(nil).Once() + } + outcome, statusErr := s.handleSubmitClaimRevert(t.Context(), err, app, epoch) + require.NoError(t, statusErr) + require.Equal(t, test.want, outcome) + if test.want == submitClaimAppHalted { + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + backend.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } + } +} + +// From deliberately differs from the signer returned in the transaction +// options. Owner diagnosis must use the signer on the attempted transaction. +type reportedSignerFactory struct{ ethutil.TransactOptsFactory } + +func (*reportedSignerFactory) From() common.Address { return common.HexToAddress("0xffff") } + +type revertingClaimSubmitter struct { + err error + from common.Address +} + +func (s *revertingClaimSubmitter) SubmitClaim( + opts *bind.TransactOpts, _ common.Address, _ *big.Int, _ [32]byte, _ iconsensus.MachineValidityProof, +) (*types.Transaction, error) { + s.from = opts.From + return nil, s.err +} + +type authorityOwnerRPC struct{ mock.Mock } + +func (m *authorityOwnerRPC) GetBlockByNumber( + _ context.Context, block rpc.BlockNumber, full bool, +) (*types.Header, error) { + args := m.Called(block, full) + header, _ := args.Get(0).(*types.Header) + return header, args.Error(1) +} + +func (m *authorityOwnerRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + owner := args.Get(0).(common.Address) + return common.LeftPadBytes(owner.Bytes(), common.HashLength), args.Error(1) +} + +func newAuthorityOwnerClient(t *testing.T, backend *authorityOwnerRPC) *ethclient.Client { + t.Helper() + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + return client +} + +func expectAuthorityHeader(backend *authorityOwnerRPC) { + backend.On("GetBlockByNumber", rpc.FinalizedBlockNumber, false). + Return(&types.Header{Number: big.NewInt(20), Difficulty: big.NewInt(0), Extra: []byte{}}, nil).Once() +} + +func expectAuthorityOwner( + t *testing.T, backend *authorityOwnerRPC, address common.Address, block rpc.BlockNumber, owner common.Address, err error, +) { + t.Helper() + contractABI, parseErr := iauthority.IAuthorityMetaData.GetAbi() + require.NoError(t, parseErr) + backend.On("Call", address, contractABI.Methods["owner"].ID, block).Return(owner, err).Once() +} diff --git a/internal/claimer/blockchain.go b/internal/claimer/blockchain.go index b49cf22e3..b7b91dc60 100644 --- a/internal/claimer/blockchain.go +++ b/internal/claimer/blockchain.go @@ -12,7 +12,7 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/model" - "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iauthority" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/contracts/iquorum" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -53,9 +53,10 @@ type iclaimerBlockchain interface { submitClaimToBlockchain( ctx context.Context, - ic *iconsensus.IConsensus, + ic consensusClaimSubmitter, application *model.Application, epoch *model.Epoch, + proof model.StateProof, ) (common.Hash, error) acceptClaimOnBlockchain( @@ -101,6 +102,16 @@ type iclaimerBlockchain interface { claimSubmitterAddress() (common.Address, bool) } +type consensusClaimSubmitter interface { + SubmitClaim( + opts *bind.TransactOpts, + appContract common.Address, + lastProcessedBlockNumber *big.Int, + machineMerkleRoot [32]byte, + proof iconsensus.MachineValidityProof, + ) (*types.Transaction, error) +} + type claimerBlockchain struct { client *ethclient.Client txOptsFactory ethutil.TransactOptsFactory @@ -117,56 +128,102 @@ func (cb *claimerBlockchain) claimSubmitterAddress() (common.Address, bool) { func (cb *claimerBlockchain) submitClaimToBlockchain( ctx context.Context, - ic *iconsensus.IConsensus, + ic consensusClaimSubmitter, application *model.Application, epoch *model.Epoch, + proof model.StateProof, ) (common.Hash, error) { txHash := common.Hash{} if cb.txOptsFactory == nil { return txHash, fmt.Errorf("txOptsFactory is required for claim submission") } - if epoch.TxBufferDataBlock == nil { - return txHash, fmt.Errorf( - "epoch %d (%d) has no tx_buffer_data_block to supply as the contract outputs Merkle root; refusing to submit claim", - epoch.Index, epoch.VirtualIndex) - } - // The DB trigger checks tx_buffer_proof when an epoch moves to - // CLAIM_COMPUTED. It does not stop a later UPDATE from clearing the proof. - // Submitting without a proof would revert on chain, so fail here with a - // clear local error. - if epoch.TxBufferProof == nil { - return txHash, fmt.Errorf( - "epoch %d (%d) has no tx_buffer_proof to supply as the contract outputs Merkle proof; refusing to submit claim", - epoch.Index, epoch.VirtualIndex) - } - proof := make([][32]byte, len(epoch.TxBufferProof)) - for i, h := range epoch.TxBufferProof { - proof[i] = h - } txOpts, err := cb.txOptsFactory.NewTransactOpts(ctx) if err != nil { return txHash, fmt.Errorf("creating transaction options for claim submission: %w", err) } lastBlockNumber := new(big.Int).SetUint64(epoch.LastBlock) tx, err := ic.SubmitClaim(txOpts, application.IApplicationAddress, - lastBlockNumber, *epoch.TxBufferDataBlock, proof) + lastBlockNumber, proof.MachineHash, consensusMachineValidityProof(proof)) if err != nil { + err = cb.diagnoseAuthorityOwner(ctx, application, txOpts.From, err) cb.logger.Warn("submitClaimToBlockchain:failed", "appContractAddress", application.IApplicationAddress, - "claimHash", *epoch.TxBufferDataBlock, + "machine_merkle_root", proof.MachineHash, + "outputs_merkle_root", proof.TxBufferDataBlock, "last_block", epoch.LastBlock, "error", err) } else { txHash = tx.Hash() cb.logger.Debug("submitClaimToBlockchain:success", "appContractAddress", application.IApplicationAddress, - "claimHash", *epoch.TxBufferDataBlock, + "machine_merkle_root", proof.MachineHash, + "outputs_merkle_root", proof.TxBufferDataBlock, "last_block", epoch.LastBlock, "TxHash", txHash) } return txHash, err } +// authorityOwnerMismatch carries owner evidence without depending on the +// ownership library's revert ABI. The original submission error is preserved. +type authorityOwnerMismatch struct { + signer common.Address + configuredOwner common.Address + latestOwner common.Address + submissionErr error +} + +func (e *authorityOwnerMismatch) Error() string { + return fmt.Sprintf("Authority owner check: signer %s, configured owner %s, latest owner %s: %v", + e.signer, e.configuredOwner, e.latestOwner, e.submissionErr) +} + +func (e *authorityOwnerMismatch) Unwrap() error { return e.submissionErr } + +func (cb *claimerBlockchain) diagnoseAuthorityOwner( + ctx context.Context, + app *model.Application, + signer common.Address, + submissionErr error, +) error { + if app.ConsensusType != model.Consensus_Authority { + return submissionErr + } + // Known submit errors already have a more specific diagnosis. Keep owner + // reads for otherwise-unclassified reverts, using the actual transaction signer. + if ethutil.IsNonceTooLowError(submissionErr) || submitClaimRevertName(submissionErr) != "" { + return submissionErr + } + if _, reverted := ethclient.RevertErrorData(submissionErr); !reverted { + return submissionErr + } + block, err := cb.getDefaultBlockNumber(ctx) + if err != nil { + return errors.Join(submissionErr, fmt.Errorf("resolving block for Authority owner check: %w", err)) + } + if block == nil || block.Sign() < 0 { + return errors.Join(submissionErr, errors.New("authority owner check returned an invalid configured block")) + } + authority, err := iauthority.NewIAuthorityCaller(app.IConsensusAddress, cb.client) + if err != nil { + return errors.Join(submissionErr, fmt.Errorf("binding Authority for owner check: %w", err)) + } + configuredOwner, err := authority.Owner(&bind.CallOpts{Context: ctx, BlockNumber: block}) + if err != nil { + return errors.Join(submissionErr, fmt.Errorf("reading Authority owner at configured block %s: %w", block, err)) + } + latestOwner, err := authority.Owner(&bind.CallOpts{Context: ctx}) + if err != nil { + return errors.Join(submissionErr, fmt.Errorf("reading latest Authority owner: %w", err)) + } + if configuredOwner == signer && latestOwner == signer { + return submissionErr + } + return &authorityOwnerMismatch{ + signer: signer, configuredOwner: configuredOwner, latestOwner: latestOwner, submissionErr: submissionErr, + } +} + type eventIterator interface { Next() bool Close() error @@ -329,11 +386,12 @@ func (cb *claimerBlockchain) findClaimStagedEventAndSucc( application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) } - if len(events) == 0 { + switch len(events) { + case 0: return ic, nil, nil, nil - } else if len(events) == 1 { + case 1: return ic, events[0], nil, nil - } else { + default: return ic, events[0], events[1], nil } } @@ -390,11 +448,12 @@ func (cb *claimerBlockchain) findClaimAcceptedEventAndSucc( application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) } - if len(events) == 0 { + switch len(events) { + case 0: return ic, nil, nil, nil - } else if len(events) == 1 { + case 1: return ic, events[0], nil, nil - } else { + default: return ic, events[0], events[1], nil } } @@ -489,15 +548,6 @@ func isCustomConsensusError(err error, name string) bool { ethutil.IsCustomError(err, iquorum.IQuorumMetaData, name) } -// isCustomApplicationError matches a typed Solidity error declared in the -// IApplication ABI. The on-chain merkle library errors (e.g. InvalidNodeIndex) -// are raised by consensus calls but are not declared in the IConsensus ABI; -// the selector is derived from the error signature alone, so any ABI that -// declares the error works for matching. -func isCustomApplicationError(err error, name string) bool { - return ethutil.IsCustomError(err, iapplication.IApplicationMetaData, name) -} - // poll a transaction for its receipt func (cb *claimerBlockchain) pollTransaction( ctx context.Context, diff --git a/internal/claimer/claimer.go b/internal/claimer/claimer.go index 52a990b24..166fa364f 100644 --- a/internal/claimer/claimer.go +++ b/internal/claimer/claimer.go @@ -37,36 +37,37 @@ // status OK. If it was already DIVERGED because of a divergence, EVM reader // preserves that status while still recording foreclose_block. // -// PRT (DaveConsensus) uses a different path. PRT epochs go directly from -// CLAIM_COMPUTED to CLAIM_ACCEPTED through tournament resolution. They never -// reach CLAIM_STAGED, and the claimer queries exclude PRT apps. +// PRT (DaveConsensus) stages and accepts tournament results through the PRT +// service. The claimer queries exclude PRT apps. package claimer import ( "context" "errors" + + "github.com/cartesi/rollups-node/internal/errutil" ) -func (s *Service) shutdownInterrupted(ctx context.Context, stage string, err error) bool { - // During shutdown, the parent context is canceled and RPC/DB calls - // return context.Canceled. Ignore only that normal shutdown case. Other - // errors, such as deadline exceeded, must still be returned. - if ctx.Err() == nil || !errors.Is(err, context.Canceled) { - return false - } - s.Logger.Warn("Tick interrupted by shutdown", "stage", stage, "error", err) - return true -} +func (s *Service) Tick(ctx context.Context) (reschedule bool, err error) { + // Classify the complete result once, on every return path. Cancellation can + // interrupt claim writes as well as work-selection queries. Keep all causes + // when a real failure accompanies shutdown; never hide it behind cancellation. + defer func() { + if !errors.Is(ctx.Err(), context.Canceled) { + return + } + reschedule = false + if errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "error", err) + err = nil + } + }() -func (s *Service) Tick(ctx context.Context) (bool, error) { // Use the same finalized block number for all chain reads in this tick. // This is one RPC per tick even when there is no DB work. The call is // cheap, and Tick already runs on a polling interval. defaultBlockNumber, err := s.blockchain.getDefaultBlockNumber(ctx) if err != nil { - if s.shutdownInterrupted(ctx, "getDefaultBlockNumber", err) { - return false, nil - } return false, err } s.consensusAddressChecks = map[consensusAddressCheckKey]error{} @@ -82,9 +83,6 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { // transaction receipt already contains ClaimStaged. prevSubmittedOrStaged, computedEpochs, computedApps, errComputed := s.repository.SelectClaimsToSubmitPerApp(ctx) if errComputed != nil { - if s.shutdownInterrupted(ctx, "SelectClaimsToSubmitPerApp", errComputed) { - return false, nil - } return false, errComputed } submitted, err := s.submitClaimsAndUpdateDatabase(ctx, prevSubmittedOrStaged, computedEpochs, computedApps, defaultBlockNumber) @@ -92,9 +90,6 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { // Stage 2: stage. SUBMITTED -> STAGED. This read sees stage 1 updates. prevAcceptedForSubmitted, submittedEpochs, submittedApps, errSubmitted := s.repository.SelectClaimsToStagePerApp(ctx) if errSubmitted != nil { - if s.shutdownInterrupted(ctx, "SelectClaimsToStagePerApp", errSubmitted) { - return false, err // Preserve errors accumulated by stage 1. - } return false, errors.Join(err, errSubmitted) } staged, stageErr := s.stageClaimsAndUpdateDatabase(ctx, prevAcceptedForSubmitted, submittedEpochs, submittedApps, defaultBlockNumber) @@ -105,9 +100,6 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { // This read sees stage 1 and stage 2 updates. prevAcceptedForStaged, stagedEpochs, stagedApps, errStaged := s.repository.SelectClaimsToAcceptPerApp(ctx) if errStaged != nil { - if s.shutdownInterrupted(ctx, "SelectClaimsToAcceptPerApp", errStaged) { - return false, err // Preserve errors accumulated by earlier stages. - } return false, errors.Join(err, errStaged) } diff --git a/internal/claimer/claimer_test.go b/internal/claimer/claimer_test.go index 457b76b3c..0c97f9852 100644 --- a/internal/claimer/claimer_test.go +++ b/internal/claimer/claimer_test.go @@ -4,8 +4,11 @@ package claimer import ( + "bytes" "context" + "errors" "fmt" + "log/slog" "math/big" "testing" "time" @@ -15,6 +18,8 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -120,11 +125,186 @@ func TestTickCancellationRequiresCanceledServiceContext(t *testing.T) { } } -func TestShutdownInterruptedDoesNotSuppressOtherErrors(t *testing.T) { - m, _, _ := newServiceMock(t) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - require.False(t, m.shutdownInterrupted(ctx, "test", context.DeadlineExceeded)) - require.False(t, m.shutdownInterrupted(ctx, "test", fmt.Errorf("database unavailable"))) - require.False(t, m.shutdownInterrupted(ctx, "test", nil)) +func TestTickShutdownErrorClassification(t *testing.T) { + const canceledState = "canceled" + dbErr := errors.New("database unavailable") + for _, state := range []string{"active", canceledState, "deadline exceeded"} { + for _, test := range []struct { + name string + err error + cancellationOnly bool + }{ + {"cancellation", context.Canceled, true}, + {"wrapped cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined cancellations", errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), true}, + {"database failure", dbErr, false}, + {"deadline", context.DeadlineExceeded, false}, + {"cancellation and deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"cancellation and database failure", errors.Join(context.Canceled, dbErr), false}, + {"nested database failure", fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), false}, + } { + t.Run(state+"/"+test.name, func(t *testing.T) { + m, _, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch state { + case canceledState: + cancel() + case "deadline exceeded": + var deadlineCancel context.CancelFunc + ctx, deadlineCancel = context.WithDeadline(ctx, time.Now().Add(-time.Second)) + defer deadlineCancel() + } + blockchain.On("getDefaultBlockNumber", ctx).Return(big.NewInt(100), test.err).Once() + + reschedule, err := m.Tick(ctx) + + require.False(t, reschedule) + if state == canceledState && test.cancellationOnly { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.err, "keep the complete operational error") + } + blockchain.AssertExpectations(t) + }) + } + } +} + +// Exercise the receipt-to-database path that shutdown interrupted in the +// snapshot integration test. Also run it through Serve to check the ERROR log. +func TestTickStagingWriteShutdown(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + writeErr error + queryFails bool + wantErr error + }{ + {"shutdown at next query", context.Canceled, true, nil}, + {"shutdown at tick end", fmt.Errorf("write: %w", context.Canceled), false, nil}, + {"shutdown after completed write", nil, false, nil}, + {"database failure before shutdown", dbErr, true, dbErr}, + {"mixed failure at tick end", errors.Join(context.Canceled, dbErr), false, dbErr}, + {"timeout before shutdown", context.DeadlineExceeded, true, context.DeadlineExceeded}, + } { + for _, throughServe := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/serve=%t", test.name, throughServe), func(t *testing.T) { + m, repo, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var logs bytes.Buffer + m.Logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + app := makeApplication() + epoch := makeComputedEpoch(app, 3) + computed := makeEpochMap(epoch) + txHash := common.HexToHash("0x10") + pending := inFlightTx{txHash: txHash} + m.claimsInFlight[app.ID] = pending + tickBlock := big.NewInt(100) + stagedLog := makeClaimStagedLog(app, epoch) + stagedLog.BlockNumber = epoch.LastBlock + 1 + blockchain.On("getDefaultBlockNumber", ctx).Return(tickBlock, nil).Once() + repo.On("SelectClaimsToSubmitPerApp", ctx). + Return(makeEpochMap(), computed, makeApplicationMap(app), nil).Once() + blockchain.On("pollTransaction", ctx, txHash, tickBlock). + Return(true, &types.Receipt{ + TxHash: txHash, Status: types.ReceiptStatusSuccessful, + BlockNumber: new(big.Int).SetUint64(stagedLog.BlockNumber), + Logs: []*types.Log{&stagedLog}, + }, nil).Once() + repo.On("UpdateEpochThroughStaging", ctx, app.ID, epoch.Index, txHash, stagedLog.BlockNumber). + Run(func(mock.Arguments) { cancel() }).Return(test.writeErr).Once() + var queryErr error + if test.queryFails { + queryErr = context.Canceled + } + repo.On("SelectClaimsToStagePerApp", ctx). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), queryErr).Once() + if !test.queryFails { + repo.On("SelectClaimsToAcceptPerApp", ctx). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), nil).Once() + repo.On("ListApplications", ctx, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, 0, nil).Once() + } + + if throughServe { + require.ErrorIs(t, m.Serve(ctx), context.Canceled) + if test.wantErr == nil { + require.NotContains(t, logs.String(), "level=ERROR") + } else { + require.Contains(t, logs.String(), "level=ERROR msg=Tick") + require.Contains(t, logs.String(), test.wantErr.Error()) + } + } else { + reschedule, err := m.Tick(ctx) + require.False(t, reschedule) + if test.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.wantErr) + } + } + if test.writeErr == nil { + require.NotContains(t, m.claimsInFlight, app.ID) + require.NotContains(t, computed, app.ID) + } else { + require.Equal(t, pending, m.claimsInFlight[app.ID], "keep tracking until the database write succeeds") + require.Contains(t, computed, app.ID) + } + repo.AssertExpectations(t) + blockchain.AssertExpectations(t) + }) + } + } +} + +func TestTickPreservesEarlierErrorsOnShutdown(t *testing.T) { + for _, test := range []struct { + name string + failStage bool + cancelAccept bool + }{ + {name: "submit error before stage cancellation"}, + {name: "submit error before accept cancellation", cancelAccept: true}, + {name: "stage error before accept cancellation", failStage: true, cancelAccept: true}, + } { + t.Run(test.name, func(t *testing.T) { + m, repo, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + workErr := errors.New("claim work RPC failed") + app := makeApplication() + computed, submitted := makeEpochMap(), makeEpochMap() + if test.failStage { + submitted = makeEpochMap(makeSubmittedEpoch(app, 3)) + } else { + computed = makeEpochMap(makeComputedEpoch(app, 3)) + } + blockchain.On("getDefaultBlockNumber", ctx).Return(big.NewInt(100), nil).Once() + blockchain.On("getConsensusAddress", ctx, app, big.NewInt(100)). + Return(app.IConsensusAddress, workErr).Once() + repo.On("SelectClaimsToSubmitPerApp", ctx). + Return(makeEpochMap(), computed, makeApplicationMap(app), nil).Once() + if test.cancelAccept { + repo.On("SelectClaimsToStagePerApp", ctx). + Return(makeEpochMap(), submitted, makeApplicationMap(app), nil).Once() + repo.On("SelectClaimsToAcceptPerApp", ctx). + Run(func(mock.Arguments) { cancel() }). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), context.Canceled).Once() + } else { + repo.On("SelectClaimsToStagePerApp", ctx). + Run(func(mock.Arguments) { cancel() }). + Return(makeEpochMap(), makeEpochMap(), makeApplicationMap(), context.Canceled).Once() + } + + reschedule, err := m.Tick(ctx) + + require.False(t, reschedule) + require.ErrorIs(t, err, workErr) + require.ErrorIs(t, err, context.Canceled, "keep both causes when shutdown accompanies a real error") + repo.AssertExpectations(t) + blockchain.AssertExpectations(t) + }) + } } diff --git a/internal/claimer/fixtures_test.go b/internal/claimer/fixtures_test.go index cdd157b01..0de5bc678 100644 --- a/internal/claimer/fixtures_test.go +++ b/internal/claimer/fixtures_test.go @@ -13,7 +13,6 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository/repotest" - "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" "github.com/cartesi/rollups-node/pkg/contracts/iquorum" "github.com/cartesi/rollups-node/pkg/service" @@ -94,17 +93,22 @@ func makeApplication() *model.Application { } func makeEpoch(id int64, status model.EpochStatus, i uint64) *model.Epoch { - outputsMerkleRoot := common.HexToHash("0x01") // dummy value - machineHash := common.HexToHash("0x03") // dummy value; matches events via testMachineHash - txHash := common.HexToHash("0x02") // dummy value + outputsMerkleRoot := common.HexToHash("0x01") + proof := repotest.KeccakStateProof(outputsMerkleRoot) + txHash := common.HexToHash("0x02") e := repotest.NewEpochBuilder(id). WithIndex(i). WithBlocks(i*10, i*10+9). WithStatus(status). WithClaimTransactionHash(txHash). WithTxBufferDataBlock(outputsMerkleRoot). - WithMachineHash(machineHash). + WithMachineHash(proof.MachineHash). Build() + e.TxBufferProof = stateProofHashes(proof.TxBufferProof) + e.IflagsYDataBlock = model.Pointer(proof.IflagsYDataBlock) + e.IflagsYProof = stateProofHashes(proof.IflagsYProof) + e.HtifTohostDataBlock = model.Pointer(proof.HtifTohostDataBlock) + e.HtifTohostProof = stateProofHashes(proof.HtifTohostProof) if status == model.EpochStatus_ClaimStaged { // CHECK constraint: staged_iff_block. b := uint64(i*10 + 1) @@ -113,6 +117,14 @@ func makeEpoch(id int64, status model.EpochStatus, i uint64) *model.Epoch { return e } +func stateProofHashes(proof [][32]byte) []common.Hash { + result := make([]common.Hash, len(proof)) + for i := range proof { + result[i] = proof[i] + } + return result +} + func makeAcceptedEpoch(app *model.Application, i uint64) *model.Epoch { return makeEpoch(app.ID, model.EpochStatus_ClaimAccepted, i) } @@ -258,7 +270,7 @@ func notFirstClaimError() error { selector := fmt.Sprintf("0x%x", id[:4]) return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: selector + "000000000000000000000000" + "01000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000027", @@ -267,27 +279,22 @@ func notFirstClaimError() error { // consensusRevertError creates a typed revert with only the 4-byte selector — // sufficient for the classifier to match by name. Looks up the error in -// IConsensus first, then IQuorum (for Quorum-only errors like -// CallerIsNotValidator), then IApplication (for merkle library errors like -// InvalidNodeIndex, which consensus calls raise but only the application ABI -// declares). +// IConsensus first, then IQuorum for Quorum-only errors such as +// CallerIsNotValidator. func consensusRevertError(errorName string) error { consensusABI, _ := iconsensus.IConsensusMetaData.GetAbi() quorumABI, _ := iquorum.IQuorumMetaData.GetAbi() - applicationABI, _ := iapplication.IApplicationMetaData.GetAbi() var id common.Hash if e, ok := consensusABI.Errors[errorName]; ok { id = e.ID } else if e, ok := quorumABI.Errors[errorName]; ok { id = e.ID - } else if e, ok := applicationABI.Errors[errorName]; ok { - id = e.ID } else { panic(fmt.Sprintf("unknown typed error: %s", errorName)) } return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", id[:4]), } } @@ -314,7 +321,7 @@ func appRevertDataError(errorName string, returndata []byte) error { payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", payload), } } @@ -338,7 +345,7 @@ func notPastBlockError(lastProcessed, upperBound uint64) error { payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", payload), } } @@ -363,38 +370,26 @@ func claimNotStagedError(status uint8) error { payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) return &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", payload), } } -// TestDecodeClaimNotStagedStatus pins the ABI-decode path used by -// handleAcceptClaimRevert. The status byte must come from the contract's - +// withForeclosed returns a copy with the foreclosure block and transaction set. func withForeclosed(app *model.Application, block uint64) *model.Application { - copy := *app - copy.ForecloseBlock = block + foreclosed := *app + foreclosed.ForecloseBlock = block txHash := common.HexToHash("0xcafe") - copy.ForecloseTransaction = &txHash - return © + foreclosed.ForecloseTransaction = &txHash + return &foreclosed } -// TestSubmitClaimForeclosesUnstagedForeclosedApp verifies the -// foreclosure-broadcast guard. A foreclosed app whose chain state is -// UNSTAGED still goes through the pre-submit reconciliation read -// (findClaimSubmittedEventAndSucc + getClaimStatus) — those would mirror -// any pre-foreclosure on-chain-accepted state into the local DB — but the -// submitClaimToBlockchain broadcast must be SKIPPED and the local claim - func makeStagedEpoch(app *model.Application, i uint64, stagedAtBlock uint64) *model.Epoch { e := makeEpoch(app.ID, model.EpochStatus_ClaimStaged, i) e.StagedAtBlock = &stagedAtBlock return e } -// TestStagingFastPathDivergence — Authority's submitClaim receipt contains a -// ClaimStaged event with a divergent machineMerkleRoot. The fast path detects - func buildClaimStagedLog(app *model.Application, epoch *model.Epoch, outputs common.Hash, machine common.Hash) types.Log { parsed, err := iconsensus.IConsensusMetaData.GetAbi() @@ -419,6 +414,3 @@ func buildClaimStagedLog(app *model.Application, epoch *model.Epoch, Data: data, } } - -// TestStageByObservation — submitted epoch + ClaimStaged event observed in -// the next-tick scan → transition to CLAIM_STAGED with staged_at_block diff --git a/internal/claimer/foreclosure.go b/internal/claimer/foreclosure.go index 56b9ef616..54754c0e6 100644 --- a/internal/claimer/foreclosure.go +++ b/internal/claimer/foreclosure.go @@ -51,9 +51,10 @@ func foreclosedClaimDrainApplicationsFilter() repository.ApplicationFilter { // // A foreclosed application is identified by foreclose_block, not by status: a // healthy foreclosed app is enabled with status=OK and foreclose_block set, and -// is drained here. A foreclosed app that is also DIVERGED or CORRUPTED is -// terminal and excluded by the selecting filter, but EVM reader keeps observing -// it (drive-prove, withdrawals) because observation is gated on enabled. +// is drained here. Any non-OK app is excluded by the selecting filter. FAILED +// requires repair before the operator clears it and resumes the drain. Terminal +// health statuses remain blocked. EVM reader still observes enabled apps +// (drive-prove, withdrawals), regardless of their health. // // This function does not send transactions. Submit and accept code already skip // broadcasts when foreclose_block is set. Once all drain checks pass there is no diff --git a/internal/claimer/inflight_test.go b/internal/claimer/inflight_test.go index 87bbc0c0d..e9cddd6e6 100644 --- a/internal/claimer/inflight_test.go +++ b/internal/claimer/inflight_test.go @@ -130,7 +130,7 @@ func TestInFlightReverted(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.HexToHash("0x10"), nil).Once() _, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -272,7 +272,7 @@ func TestClaimInFlightReceiptNotFoundAfterTimeoutClearsAndRetries(t *testing.T) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted(nil), nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(newTxHash, nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) diff --git a/internal/claimer/legacy_fees_test.go b/internal/claimer/legacy_fees_test.go new file mode 100644 index 000000000..0447f072e --- /dev/null +++ b/internal/claimer/legacy_fees_test.go @@ -0,0 +1,100 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" +) + +func TestCreateWiresLegacyFees(t *testing.T) { + for _, legacy := range []bool{false, true} { + for _, injectClient := range []bool{false, true} { + t.Run(fmt.Sprintf("legacy=%t/injected=%t", legacy, injectClient), func(t *testing.T) { + viper.Set(config.AUTH_KIND, "private_key") + viper.Set(config.AUTH_PRIVATE_KEY, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + t.Cleanup(func() { + viper.Set(config.AUTH_KIND, nil) + viper.Set(config.AUTH_PRIVATE_KEY, nil) + }) + raw, err := json.Marshal(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: true, + }) + require.NoError(t, err) + repo := &claimerCreateRepositoryMock{} + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey).Return(raw, time.Time{}, time.Time{}, nil).Once() + backend := &claimerLegacyFeeRPC{chainIDRPC: chainIDRPC{chainID: 42}} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + endpoint, err := config.ToURLFromString(httpServer.URL) + require.NoError(t, err) + var client *ethclient.Client + if injectClient { + client = ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + } + s, err := Create(t.Context(), &CreateInfo{ + Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainHttpEndpoint: endpoint, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: time.Second, + BlockchainLegacyEnabled: legacy, + }, + EthConn: client, Repository: repo, + }) + require.NoError(t, err) + blockchain, ok := s.(*Service).blockchain.(*claimerBlockchain) + require.True(t, ok) + if !injectClient { + t.Cleanup(blockchain.client.Close) + } + for sequence := int64(1); sequence <= 2; sequence++ { + opts, err := blockchain.txOptsFactory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Zero(t, opts.GasLimit, "keep estimation enabled") + if legacy { + require.Equal(t, big.NewInt(100+sequence), opts.GasPrice) + } else { + require.Nil(t, opts.GasPrice) + } + } + if legacy { + require.EqualValues(t, 2, backend.prices.Load()) + } else { + require.Zero(t, backend.prices.Load()) + } + repo.AssertExpectations(t) + }) + } + } +} + +type claimerLegacyFeeRPC struct { + chainIDRPC + prices atomic.Int32 +} + +func (r *claimerLegacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} diff --git a/internal/claimer/mocks_test.go b/internal/claimer/mocks_test.go index 2212aad6a..48c5a99f9 100644 --- a/internal/claimer/mocks_test.go +++ b/internal/claimer/mocks_test.go @@ -27,6 +27,11 @@ type claimerCreateRepositoryMock struct { mock.Mock } +func (m *claimerCreateRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + func (m *claimerCreateRepositoryMock) SaveNodeConfigRaw( ctx context.Context, key string, @@ -206,6 +211,11 @@ func (m *claimerRepositoryMock) UpdateEpochReconciledStaged( return args.Error(0) } +func (m *claimerRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + func (m *claimerRepositoryMock) SaveNodeConfigRaw( ctx context.Context, key string, @@ -299,11 +309,12 @@ func (m *claimerBlockchainMock) findClaimAcceptedEventAndSucc( func (m *claimerBlockchainMock) submitClaimToBlockchain( ctx context.Context, - instance *iconsensus.IConsensus, + instance consensusClaimSubmitter, app *model.Application, epoch *model.Epoch, + proof model.StateProof, ) (common.Hash, error) { - rets := m.Called(ctx, instance, app, epoch) + rets := m.Called(ctx, instance, app, epoch, proof) return rets.Get(0).(common.Hash), rets.Error(1) } diff --git a/internal/claimer/repository.go b/internal/claimer/repository.go index 2920bef9d..7b0869a1c 100644 --- a/internal/claimer/repository.go +++ b/internal/claimer/repository.go @@ -108,6 +108,7 @@ type iclaimerRepository interface { descending bool, ) ([]*model.Application, uint64, error) + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) } diff --git a/internal/claimer/reverts.go b/internal/claimer/reverts.go index d896ab345..9b6fbfe50 100644 --- a/internal/claimer/reverts.go +++ b/internal/claimer/reverts.go @@ -5,9 +5,9 @@ package claimer import ( "context" - "fmt" + "errors" "math/big" - "reflect" + "slices" "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/model" @@ -85,9 +85,12 @@ const ( // - ApplicationForeclosed: retry later while EVM reader records foreclose_block. // The app remains enabled for L1 observation; normal work stops once // foreclose_block is recorded. -// - InvalidOutputsMerkleRootProofSize: CORRUPTED; local data corruption. -// - InvalidNodeIndex: CORRUPTED; the outputs merkle proof in the DB does not -// form a valid machine-tree replacement proof. +// - InvalidSiblingsArrayLength, InvalidMachineMerkleProof, +// InvalidPostEpochMachineIflagsYRegister, and +// InvalidPostEpochMachineHtifTohostRegister: FAILED; the deployed +// contract rejected a proof that the node validated when it collected it. +// The stored proof may be corrupt, or node and contract validation may +// disagree. // - CallerIsNotValidator: FAILED; wrong operator key. // - the reverts shared with acceptClaim (see classifySharedConsensusRevert): // ApplicationNotDeployed, ApplicationReverted, @@ -102,6 +105,7 @@ func (s *Service) handleSubmitClaimRevert( app *model.Application, epoch *model.Epoch, ) (submitClaimRevertOutcome, error) { + revertName := submitClaimRevertName(err) switch { case ethutil.IsNonceTooLowError(err): // A transaction with this signer nonce was already mined. This can @@ -119,7 +123,7 @@ func (s *Service) handleSubmitClaimRevert( ) return submitClaimRetryLater, nil - case isCustomConsensusError(err, "NotFirstClaim"): + case revertName == "NotFirstClaim": // Gas estimation runs the call before sending the transaction. With the // default GasLimit == 0, this revert is caught before spending gas. If // GasLimit were set manually, the transaction could revert on chain. @@ -149,7 +153,7 @@ func (s *Service) handleSubmitClaimRevert( ) return submitClaimAlreadyOnChain, nil - case isCustomConsensusError(err, "ApplicationForeclosed"): + case revertName == "ApplicationForeclosed": // EVM reader should record foreclose_block soon. Keep the epoch for now. // Later ticks will see foreclose_block and skip new broadcasts. Read-only // reconciliation can still copy any already-accepted chain state into @@ -162,17 +166,16 @@ func (s *Service) handleSubmitClaimRevert( ) return submitClaimRetryLater, nil - case isCustomConsensusError(err, "InvalidOutputsMerkleRootProofSize"): - stateErr := s.setApplicationCorrupted( - ctx, app, - "submitClaim reverted with InvalidOutputsMerkleRootProofSize for "+ - "epoch %d (%d), last_block %d — tx_buffer_proof in DB is "+ - "the wrong length for the machine memory tree.", - epoch.Index, epoch.VirtualIndex, epoch.LastBlock, + case slices.Contains(machineValidationRevertNames[:], revertName): + stateErr := appstatus.SetFailedf( + ctx, s.Logger, s.repository, app, + "submitClaim rejected the persisted machine state proof with %s for epoch %d (%d), last_block %d. %s", + revertName, epoch.Index, epoch.VirtualIndex, epoch.LastBlock, + machineValidationFailureGuidance(revertName), ) return submitClaimAppHalted, stateErr - case isCustomConsensusError(err, "CallerIsNotValidator"): + case revertName == "CallerIsNotValidator": // Operator configuration error: the signing key is not a Quorum // validator. The operator can fix the key, so use FAILED rather than a // terminal DIVERGED/CORRUPTED status. @@ -183,20 +186,6 @@ func (s *Service) handleSubmitClaimRevert( app.IApplicationAddress, ) return submitClaimAppHalted, stateErr - - case isCustomApplicationError(err, "InvalidNodeIndex"): - // Raised by the on-chain merkle library while replacing the - // outputs-root leaf in the machine memory tree: the proof's shape does - // not fit the tree. Like InvalidOutputsMerkleRootProofSize, this means - // the proof stored locally is bad — local data corruption. - stateErr := s.setApplicationCorrupted( - ctx, app, - "submitClaim reverted with InvalidNodeIndex for "+ - "epoch %d (%d), last_block %d — tx_buffer_proof in DB does "+ - "not form a valid replacement proof for the machine memory tree.", - epoch.Index, epoch.VirtualIndex, epoch.LastBlock, - ) - return submitClaimAppHalted, stateErr } switch action, stateErr := s.classifySharedConsensusRevert(ctx, "submitClaim", err, app, epoch); action { @@ -205,11 +194,70 @@ func (s *Service) handleSubmitClaimRevert( case sharedRevertRetryLater: return submitClaimRetryLater, nil case sharedRevertNoMatch: - // Not a shared revert either; report unknown. + // No known revert matched. Check the Authority owner diagnosis below. + } + var ownerMismatch *authorityOwnerMismatch + if errors.As(err, &ownerMismatch) { + if ownerMismatch.configuredOwner != ownerMismatch.latestOwner { + s.Logger.Warn("Authority owner views differ after claim submission failed; waiting for confirmation", + "app", app.IApplicationAddress, "error", err) + return submitClaimRetryLater, nil + } + stateErr := appstatus.SetFailedf(ctx, s.Logger, s.repository, app, + "The configured signer %s is not the Authority owner %s for consensus %s. "+ + "The configured and latest block views agree. Check CARTESI_AUTH_* against the Authority owner before re-enabling. "+ + "Original submission error: %v", + ownerMismatch.signer, ownerMismatch.configuredOwner, app.IConsensusAddress, ownerMismatch.submissionErr) + return submitClaimAppHalted, stateErr } return submitClaimUnknown, nil } +// submitClaimRevertName recognizes only the reverts handled by the submit path. +// An error declared in the ABI but used only by acceptClaim is not a match. +// The handler and Authority owner probe share this side-effect-free recognizer. +func submitClaimRevertName(err error) string { + for _, name := range []string{"NotFirstClaim", "ApplicationForeclosed", "CallerIsNotValidator"} { + if isCustomConsensusError(err, name) { + return name + } + } + if name := machineValidationRevertName(err); name != "" { + return name + } + return sharedConsensusRevertName(err) +} + +var machineValidationRevertNames = [...]string{ + "InvalidSiblingsArrayLength", + "InvalidMachineMerkleProof", + "InvalidPostEpochMachineIflagsYRegister", + "InvalidPostEpochMachineHtifTohostRegister", +} + +func machineValidationRevertName(err error) string { + for _, name := range machineValidationRevertNames { + if isCustomConsensusError(err, name) { + return name + } + } + return "" +} + +// Keep this guidance aligned with PRT's machine-validation diagnostics. The +// classifiers remain separate because the two services use different ABIs. +func machineValidationFailureGuidance(name string) string { + switch name { + case "InvalidPostEpochMachineIflagsYRegister", "InvalidPostEpochMachineHtifTohostRegister": + return "The proven post-epoch machine state cannot finalize. " + + "Check the selected post-epoch state, stored data, and node and contract versions. " + + "If this state is unrecoverable, consider guardian foreclosure to permit emergency withdrawals and deposit refunds." + default: + return "The node validated the proof when it collected it. " + + "Check proof serialization, stored proof data, and node and contract versions before re-enabling." + } +} + // sharedRevertAction tells the caller how to map a revert that submitClaim // and acceptClaim have in common onto its own outcome type. type sharedRevertAction int @@ -228,6 +276,21 @@ const ( sharedRevertRetryLater ) +func sharedConsensusRevertName(err error) string { + for _, name := range []string{ + "ApplicationNotDeployed", + "ApplicationReverted", + "IllformedApplicationReturnData", + "NotEpochFinalBlock", + "NotPastBlock", + } { + if isCustomConsensusError(err, name) { + return name + } + } + return "" +} + // classifySharedConsensusRevert handles the reverts that submitClaim and // acceptClaim have in common. Both methods run the application foreclosure // probe — ApplicationNotDeployed, ApplicationReverted, and @@ -241,8 +304,8 @@ func (s *Service) classifySharedConsensusRevert( app *model.Application, epoch *model.Epoch, ) (sharedRevertAction, error) { - switch { - case isCustomConsensusError(err, "ApplicationNotDeployed"): + switch sharedConsensusRevertName(err) { + case "ApplicationNotDeployed": // The consensus contract found no code at the application address — // the common wrong-address case, which the contract reports separately // from ApplicationReverted. A provider simulating against a block just @@ -258,7 +321,7 @@ func (s *Service) classifySharedConsensusRevert( ) return sharedRevertAppHalted, stateErr - case isCustomConsensusError(err, "ApplicationReverted"): + case "ApplicationReverted": // Not a divergence (no on-chain claim disagrees with ours) and not // local DB corruption — operator action can fix it, so FAILED. The // application's own revert data is the only evidence of why the call @@ -272,11 +335,11 @@ func (s *Service) classifySharedConsensusRevert( "contract and its compatibility with the consensus contract "+ "before re-enabling.%s", call, app.IApplicationAddress, epoch.Index, epoch.VirtualIndex, epoch.LastBlock, - appReturnDataSuffix(err, "ApplicationReverted"), + ethutil.ApplicationReturnDataSuffix(err, iconsensus.IConsensusMetaData, "ApplicationReverted"), ) return sharedRevertAppHalted, stateErr - case isCustomConsensusError(err, "IllformedApplicationReturnData"): + case "IllformedApplicationReturnData": stateErr := appstatus.SetFailedf(ctx, s.Logger, s.repository, app, "%s reverted with IllformedApplicationReturnData for app %s, "+ "epoch %d (%d), last_block %d: the application contract returned "+ @@ -284,11 +347,11 @@ func (s *Service) classifySharedConsensusRevert( "the deployed contract and its compatibility with the consensus "+ "contract before re-enabling.%s", call, app.IApplicationAddress, epoch.Index, epoch.VirtualIndex, epoch.LastBlock, - appReturnDataSuffix(err, "IllformedApplicationReturnData"), + ethutil.ApplicationReturnDataSuffix(err, iconsensus.IConsensusMetaData, "IllformedApplicationReturnData"), ) return sharedRevertAppHalted, stateErr - case isCustomConsensusError(err, "NotEpochFinalBlock"): + case "NotEpochFinalBlock": stateErr := appstatus.SetFailedf(ctx, s.Logger, s.repository, app, "%s reverted with NotEpochFinalBlock for app %s, "+ "epoch %d (%d), last_block %d: the node submitted a "+ @@ -300,7 +363,7 @@ func (s *Service) classifySharedConsensusRevert( ) return sharedRevertAppHalted, stateErr - case isCustomConsensusError(err, "NotPastBlock"): + case "NotPastBlock": // Can be transient: the RPC provider may simulate the call against a // block newer or different from the block used by this tick's pinned // reads. It is permanent when last_block is beyond the real chain head @@ -477,36 +540,8 @@ func decodeClaimNotStagedStatus(err error) (uint8, bool) { if !ok || len(values) < 4 { return 0, false } - // Use reflection instead of a direct `.(uint8)` cast. abigen returns uint8 - // today, but a future version may return a named uint8 type. Checking the - // kind works for both forms. - v := reflect.ValueOf(values[3]) - if !v.IsValid() || v.Kind() != reflect.Uint8 { - return 0, false - } - return uint8(v.Uint()), true -} - -// appReturnDataSuffix formats the application-provided returndata carried by -// ApplicationReverted and IllformedApplicationReturnData reverts as a reason -// suffix: -// -// error ApplicationReverted(address appContract, bytes returndata); -// error IllformedApplicationReturnData(address appContract, bytes returndata); -// -// The bytes are controlled by the application contract, so they are -// hex-encoded rather than string-decoded to keep them inert in logs and in -// the database. Returns "" when the revert data cannot be decoded. -func appReturnDataSuffix(err error, name string) string { - values, ok := unpackConsensusRevert(err, name) - if !ok || len(values) < 2 { - return "" - } - data, ok := values[1].([]byte) - if !ok { - return "" - } - return fmt.Sprintf(" Application return data: 0x%x.", data) + status, ok := values[3].(uint8) + return status, ok } // decodeNotPastBlockBounds reads the arguments from a NotPastBlock revert: diff --git a/internal/claimer/reverts_test.go b/internal/claimer/reverts_test.go index 1a30f0a2b..fac98656d 100644 --- a/internal/claimer/reverts_test.go +++ b/internal/claimer/reverts_test.go @@ -19,6 +19,14 @@ import ( "github.com/stretchr/testify/mock" ) +const ( + executionRevertedErrorMessage = "execution reverted" + notEpochFinalBlockRevert = "NotEpochFinalBlock" + applicationNotDeployedRevert = "ApplicationNotDeployed" + applicationRevertedRevert = "ApplicationReverted" + illformedApplicationReturnDataRevert = "IllformedApplicationReturnData" +) + func TestDecodeClaimNotStagedStatus(t *testing.T) { t.Run("ValidStatuses", func(t *testing.T) { for _, s := range []uint8{0, 1, 2, 3} { @@ -40,13 +48,13 @@ func TestDecodeClaimNotStagedStatus(t *testing.T) { }) t.Run("EmptyPayload", func(t *testing.T) { - e := &rpcDataError{code: 3, msg: "execution reverted", data: "0x"} + e := &rpcDataError{code: 3, msg: executionRevertedErrorMessage, data: "0x"} _, ok := decodeClaimNotStagedStatus(e) assert.False(t, ok) }) t.Run("PayloadShorterThanSelector", func(t *testing.T) { - e := &rpcDataError{code: 3, msg: "execution reverted", data: "0xabcd"} + e := &rpcDataError{code: 3, msg: executionRevertedErrorMessage, data: "0xabcd"} _, ok := decodeClaimNotStagedStatus(e) assert.False(t, ok) }) @@ -54,7 +62,7 @@ func TestDecodeClaimNotStagedStatus(t *testing.T) { t.Run("WrongSelector", func(t *testing.T) { e := &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, // Valid 132-byte payload, but selector is for a different error. data: "0xdeadbeef" + strings.Repeat("00", 128), } @@ -70,7 +78,7 @@ func TestDecodeClaimNotStagedStatus(t *testing.T) { payload := append(append([]byte{}, abiErr.ID[:4]...), make([]byte, 32)...) e := &rpcDataError{ code: 3, - msg: "execution reverted", + msg: executionRevertedErrorMessage, data: fmt.Sprintf("0x%x", payload), } _, ok := decodeClaimNotStagedStatus(e) @@ -93,7 +101,7 @@ func TestNotFirstClaimHandledGracefully(t *testing.T) { expectPreSubmitPath(b, app, currEpoch, endBlock) // submitClaim reverts with NotFirstClaim (caught by eth_estimateGas). - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, notFirstClaimError()).Once() _, err := m.submitClaimsAndUpdateDatabase( @@ -118,7 +126,7 @@ func TestNotFirstClaimQuorumRetriesForEventSync(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, notFirstClaimError()).Once() _, err := m.submitClaimsAndUpdateDatabase( @@ -142,7 +150,7 @@ func TestApplicationForeclosedIsTransient(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, consensusRevertError("ApplicationForeclosed")).Once() currEpochs := makeEpochMap(currEpoch) @@ -154,30 +162,52 @@ func TestApplicationForeclosedIsTransient(t *testing.T) { assert.Equal(t, 0, len(m.claimsInFlight), "no claim in flight") } -// TestInvalidOutputsMerkleRootProofSizeSetsCorrupted verifies that a -// proof-size revert is treated as local data corruption — the app moves -// to CORRUPTED. -func TestInvalidOutputsMerkleRootProofSizeSetsCorrupted(t *testing.T) { - m, r, b := newServiceMock(t) - defer r.AssertExpectations(t) - defer b.AssertExpectations(t) +func TestMachineValidationRevertsSetFailed(t *testing.T) { + for _, test := range []struct { + name string + register bool + }{ + {name: "InvalidSiblingsArrayLength"}, + {name: "InvalidMachineMerkleProof"}, + {name: "InvalidPostEpochMachineIflagsYRegister", register: true}, + {name: "InvalidPostEpochMachineHtifTohostRegister", register: true}, + } { + t.Run(test.name, func(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) - endBlock := big.NewInt(40) - app := makeApplication() - currEpoch := makeComputedEpoch(app, 3) + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) - expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). - Return(common.Hash{}, consensusRevertError("InvalidOutputsMerkleRootProofSize")).Once() - r.On("UpdateApplicationStatus", mock.Anything, int64(0), model.ApplicationStatus_Corrupted, mock.Anything). - Return(nil).Once() + expectPreSubmitPath(b, app, currEpoch, endBlock) + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). + Return(common.Hash{}, consensusRevertError(test.name)).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + if reason == nil || !strings.Contains(*reason, test.name) || + !strings.Contains(*reason, "node and contract versions") { + return false + } + if test.register { + return strings.Contains(*reason, "proven post-epoch machine state cannot finalize") && + strings.Contains(*reason, "selected post-epoch state") && + strings.Contains(*reason, "If this state is unrecoverable, consider guardian foreclosure") + } + return strings.Contains(*reason, "proof serialization, stored proof data") && + !strings.Contains(*reason, "guardian foreclosure") + })).Return(nil).Once() - currEpochs := makeEpochMap(currEpoch) - _, err := m.submitClaimsAndUpdateDatabase( - context.Background(), makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) - assert.Error(t, err, "CORRUPTED transition must surface a terminal error") - assert.Equal(t, 0, len(currEpochs), "epoch must be dropped from work map") - assert.Equal(t, 0, len(m.claimsInFlight)) + currEpochs := makeEpochMap(currEpoch) + _, err := m.submitClaimsAndUpdateDatabase( + t.Context(), makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + assert.NoError(t, err, "a successful FAILED transition does not surface an error") + assert.Empty(t, currEpochs, "epoch must be dropped from work map") + assert.Empty(t, m.claimsInFlight) + assert.Equal(t, model.ApplicationStatus_Failed, app.Status) + }) + } } // TestCallerIsNotValidatorSetsFailed verifies that a Quorum membership @@ -194,7 +224,7 @@ func TestCallerIsNotValidatorSetsFailed(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, consensusRevertError("CallerIsNotValidator")).Once() r.On("UpdateApplicationStatus", mock.Anything, int64(0), model.ApplicationStatus_Failed, mock.Anything). Return(nil).Once() @@ -224,7 +254,7 @@ func TestNotPastBlockRetriesLater(t *testing.T) { expectPreSubmitPath(b, app, currEpoch, endBlock) // The revert carries the contract's (lastProcessedBlockNumber, upperBound) // arguments, exercising the bounds decode in the warn path. - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, notPastBlockError(currEpoch.LastBlock, currEpoch.LastBlock-1)).Once() currEpochs := makeEpochMap(currEpoch) @@ -252,22 +282,22 @@ func TestSubmitClaimRevertsSetApplicationFailed(t *testing.T) { extraReason []string }{ { - revertName: "ApplicationReverted", - err: appRevertDataError("ApplicationReverted", []byte{0xde, 0xad, 0xbe, 0xef}), + revertName: applicationRevertedRevert, + err: appRevertDataError(applicationRevertedRevert, []byte{0xde, 0xad, 0xbe, 0xef}), extraReason: []string{"Application return data: 0xdeadbeef"}, }, { - revertName: "IllformedApplicationReturnData", - err: appRevertDataError("IllformedApplicationReturnData", []byte{0x01, 0x02}), + revertName: illformedApplicationReturnDataRevert, + err: appRevertDataError(illformedApplicationReturnDataRevert, []byte{0x01, 0x02}), extraReason: []string{"Application return data: 0x0102"}, }, { - revertName: "NotEpochFinalBlock", - err: consensusRevertError("NotEpochFinalBlock"), + revertName: notEpochFinalBlockRevert, + err: consensusRevertError(notEpochFinalBlockRevert), }, { - revertName: "ApplicationNotDeployed", - err: consensusRevertError("ApplicationNotDeployed"), + revertName: applicationNotDeployedRevert, + err: consensusRevertError(applicationNotDeployedRevert), }, } for _, tc := range cases { @@ -281,7 +311,7 @@ func TestSubmitClaimRevertsSetApplicationFailed(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.Hash{}, tc.err).Once() r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.MatchedBy(func(reason *string) bool { @@ -330,8 +360,8 @@ func TestSubmitClaimFailedRevertWithDBError(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). - Return(common.Hash{}, consensusRevertError("ApplicationReverted")).Once() + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). + Return(common.Hash{}, consensusRevertError(applicationRevertedRevert)).Once() r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). Return(fmt.Errorf("db down")).Once() @@ -438,7 +468,7 @@ func TestDecodeNotPastBlockBounds(t *testing.T) { }) t.Run("WrongSelector", func(t *testing.T) { - _, _, ok := decodeNotPastBlockBounds(consensusRevertError("NotEpochFinalBlock")) + _, _, ok := decodeNotPastBlockBounds(consensusRevertError(notEpochFinalBlockRevert)) assert.False(t, ok) }) } @@ -472,9 +502,9 @@ func TestClaimNotStagedUnmodeledStatusFailsClosed(t *testing.T) { // FAILED with a reason naming acceptClaim and carrying operational context. func TestAcceptClaimRevertsSetApplicationFailed(t *testing.T) { for _, revertName := range []string{ - "ApplicationNotDeployed", - "ApplicationReverted", - "IllformedApplicationReturnData", + applicationNotDeployedRevert, + applicationRevertedRevert, + illformedApplicationReturnDataRevert, "NotEpochFinalBlock", } { t.Run(revertName, func(t *testing.T) { @@ -512,29 +542,11 @@ func TestAcceptClaimRevertsSetApplicationFailed(t *testing.T) { } } -// TestInvalidNodeIndexSetsCorrupted verifies that a submitClaim revert from -// the on-chain merkle library is treated as local data corruption: the stored -// outputs_merkle_proof does not form a valid machine-tree replacement proof, -// so the app moves to CORRUPTED. -func TestInvalidNodeIndexSetsCorrupted(t *testing.T) { - m, r, _ := newServiceMock(t) - defer r.AssertExpectations(t) - app := makeApplication() - epoch := makeComputedEpoch(app, 3) - - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, mock.Anything). - Return(nil).Once() - - outcome, stateErr := m.handleSubmitClaimRevert(context.Background(), consensusRevertError("InvalidNodeIndex"), app, epoch) - assert.Equal(t, submitClaimAppHalted, outcome) - assert.Error(t, stateErr, "CORRUPTED is terminal; the handler must return the reason error") -} - // TestHandleSubmitClaimRevert — dispatch matrix for the non-mutating typed // reverts handleSubmitClaimRevert recognises plus the JSON-RPC // "nonce too low" broadcast rejection. The classifier mutates state only -// for the AppHalted outcomes (InvalidOutputsMerkleRootProofSize, -// InvalidNodeIndex, CallerIsNotValidator, ApplicationNotDeployed, +// for the AppHalted outcomes (the four machine-validation errors, +// CallerIsNotValidator, ApplicationNotDeployed, // ApplicationReverted, IllformedApplicationReturnData, NotEpochFinalBlock); // those paths are covered by the end-to-end submit pipeline tests above and // the direct dispatch tests, with repository expectations for the status diff --git a/internal/claimer/service.go b/internal/claimer/service.go index f88b01855..3bbe6df79 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -69,11 +69,7 @@ const defaultMaxAcceptAttempts uint64 = 5 const ClaimerConfigKey = "claimer" -type PersistentConfig struct { - DefaultBlock model.DefaultBlock - ClaimSubmissionEnabled bool - ChainID uint64 -} +type PersistentConfig = config.PersistentSubmitterConfig func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { var err error @@ -122,22 +118,16 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro } } - nodeConfig, err := setupPersistentConfig(ctx, s.Logger, c.Repository, &c.Config) - if err != nil { - return nil, fmt.Errorf("setting up persistent config: %w", err) - } - - chainId, err := ethClient.ChainID(ctx) + chainID, err := ethClient.ChainID(ctx) if err != nil { return nil, fmt.Errorf("querying chain ID: %w", err) } - if chainId.Uint64() != c.Config.BlockchainId { - return nil, fmt.Errorf("chainId mismatch: network %d != provided %d", chainId.Uint64(), c.Config.BlockchainId) + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err } - - if chainId.Uint64() != nodeConfig.ChainID { - return nil, fmt.Errorf("NodeConfig chainId mismatch: network %d != config %d", - chainId.Uint64(), nodeConfig.ChainID) + nodeConfig, err := setupPersistentConfig(ctx, s.Logger, c.Repository, &c.Config) + if err != nil { + return nil, fmt.Errorf("setting up persistent config: %w", err) } s.submissionEnabled = nodeConfig.ClaimSubmissionEnabled s.claimsInFlight = map[int64]inFlightTx{} @@ -154,10 +144,13 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro if s.submissionTimeout == 0 { return nil, fmt.Errorf("BlockchainHttpRequestTimeout must be different from zero") } - txOptsFactory, err = auth.GetTransactOptsFactory(ctx, chainId) + txOptsFactory, err = auth.GetTransactOptsFactory(ctx, chainID) if err != nil { return nil, fmt.Errorf("getting transaction options: %w", err) } + if c.Config.BlockchainLegacyEnabled { + txOptsFactory = ethutil.WithLegacyFees(txOptsFactory, ethClient) + } s.Logger.Info("Claim submitter identity", "address", txOptsFactory.From()) } @@ -180,24 +173,27 @@ func setupPersistentConfig( repo iclaimerRepository, c *config.ClaimerConfig, ) (*PersistentConfig, error) { + requested := PersistentConfig{ + DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId, + ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, + } + if err := requested.Validate(); err != nil { + return nil, fmt.Errorf("invalid claimer config: %w", err) + } config, err := repository.LoadNodeConfig[PersistentConfig](ctx, repo, ClaimerConfigKey) if config == nil && errors.Is(err, repository.ErrNotFound) { nc := model.NodeConfig[PersistentConfig]{ - Key: ClaimerConfigKey, - Value: PersistentConfig{ - DefaultBlock: c.BlockchainDefaultBlock, - ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, - ChainID: c.BlockchainId, - }, + Key: ClaimerConfigKey, + Value: requested, } logger.Info("Initializing claimer persistent config", "config", nc.Value) - err = repository.SaveNodeConfig(ctx, repo, &nc) - if err != nil { - return nil, fmt.Errorf("saving claimer persistent config: %w", err) + config, err = repository.InitializeNodeConfig(ctx, repo, &nc) + } + if err == nil { + if err := config.Value.CheckRequested(requested); err != nil { + return nil, fmt.Errorf("claimer persistent config: %w", err) } - return &nc.Value, nil - } else if err == nil { - logger.Info("Claimer was already configured. Using previous persistent config", "config", config.Value) + logger.Info("Claimer persistent config matches requested config", "config", config.Value) return &config.Value, nil } diff --git a/internal/claimer/service_test.go b/internal/claimer/service_test.go index a085419f0..ba5e2c513 100644 --- a/internal/claimer/service_test.go +++ b/internal/claimer/service_test.go @@ -6,18 +6,105 @@ package claimer import ( "context" "encoding/json" + "fmt" "testing" "time" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -func TestCreateUsesPersistedDefaultBlock(t *testing.T) { +func TestSetupPersistentConfigChecksInitializedValue(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, concurrent := range []bool{false, true} { + t.Run(fmt.Sprintf("enabled=%t/concurrent=%t", enabled, concurrent), func(t *testing.T) { + s, repo, _ := newServiceMock(t) + requested := PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: enabled, + } + raw, err := json.Marshal(requested) + require.NoError(t, err) + stored := requested + if concurrent { + stored.ClaimSubmissionEnabled = !enabled + } + storedRaw, err := json.Marshal(stored) + require.NoError(t, err) + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey). + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound).Once() + repo.On("InitializeNodeConfigRaw", mock.Anything, ClaimerConfigKey, raw).Return(nil).Once() + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey). + Return(storedRaw, time.Time{}, time.Time{}, nil).Once() + got, err := setupPersistentConfig(t.Context(), s.Logger, repo, &config.ClaimerConfig{ + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: enabled, + }) + if concurrent { + require.Nil(t, got) + require.ErrorContains(t, err, "claim submission mode mismatch") + } else { + require.NoError(t, err) + require.Equal(t, requested, *got) + } + repo.AssertNumberOfCalls(t, "SaveNodeConfigRaw", 0) + repo.AssertExpectations(t) + }) + } + } +} + +func TestCreateRejectsConfigBeforeSignerResolution(t *testing.T) { + // Invalid auth must not hide a saved-config error or resolve a signing key. + viper.Set(config.AUTH_KIND, "invalid") + t.Cleanup(func() { viper.Set(config.AUTH_KIND, nil) }) + for _, test := range []struct { + name string + raw string + requestedEnabled bool + want string + }{ + {"chain mismatch", `{"ChainID":2,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, true, + "chain ID mismatch: database=2, configured=42"}, + {"policy mismatch", `{"ChainID":42,"DefaultBlock":"LATEST","ClaimSubmissionEnabled":true}`, true, + "observation policy mismatch: database=LATEST, configured=FINALIZED"}, + {"enable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":false}`, true, + "claim submission mode mismatch: database=false, configured=true"}, + {"disable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, false, + "claim submission mode mismatch: database=true, configured=false"}, + {"missing mode", `{"ChainID":42,"DefaultBlock":"FINALIZED"}`, true, "non-null ClaimSubmissionEnabled"}, + {"null mode", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":null}`, true, + "non-null ClaimSubmissionEnabled"}, + {"missing chain", `{"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, true, "non-null DefaultBlock and ChainID"}, + {"invalid policy", `{"ChainID":42,"DefaultBlock":"invalid","ClaimSubmissionEnabled":true}`, true, "invalid DefaultBlock"}, + } { + t.Run(test.name, func(t *testing.T) { + repo := &claimerCreateRepositoryMock{} + repo.On("LoadNodeConfigRaw", mock.Anything, ClaimerConfigKey). + Return([]byte(test.raw), time.Time{}, time.Time{}, nil).Once() + info := &CreateInfo{ + Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: test.requestedEnabled, BlockchainHttpRequestTimeout: time.Second, + }, + EthConn: newTestEthClient(t, 42), Repository: repo, + } + svc, err := Create(t.Context(), info) + require.Nil(t, svc) + require.ErrorContains(t, err, test.want) + repo.AssertNumberOfCalls(t, "SaveNodeConfigRaw", 0) + repo.AssertExpectations(t) + }) + } +} + +func TestCreateUsesMatchingPersistedDefaultBlock(t *testing.T) { persistedConfig := PersistentConfig{ DefaultBlock: model.DefaultBlock_Latest, ClaimSubmissionEnabled: false, @@ -33,9 +120,9 @@ func TestCreateUsesPersistedDefaultBlock(t *testing.T) { s, err := Create(context.Background(), &CreateInfo{ Config: config.ClaimerConfig{ ClaimerPollingInterval: time.Hour, - BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainDefaultBlock: model.DefaultBlock_Latest, BlockchainId: 42, - FeatureClaimSubmissionEnabled: true, + FeatureClaimSubmissionEnabled: false, }, EthConn: newTestEthClient(t, 42), Repository: repo, diff --git a/internal/claimer/state_proof.go b/internal/claimer/state_proof.go new file mode 100644 index 000000000..df0a4b342 --- /dev/null +++ b/internal/claimer/state_proof.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" +) + +func consensusMachineValidityProof(proof model.StateProof) iconsensus.MachineValidityProof { + return iconsensus.MachineValidityProof{ + IflagsYProof: iconsensus.LeafProof{ + DataBlock: proof.IflagsYDataBlock, + Siblings: append([][32]byte(nil), proof.IflagsYProof...), + }, + HtifTohostProof: iconsensus.LeafProof{ + DataBlock: proof.HtifTohostDataBlock, + Siblings: append([][32]byte(nil), proof.HtifTohostProof...), + }, + TxBufferProof: iconsensus.LeafProof{ + DataBlock: proof.TxBufferDataBlock, + Siblings: append([][32]byte(nil), proof.TxBufferProof...), + }, + } +} diff --git a/internal/claimer/state_proof_test.go b/internal/claimer/state_proof_test.go new file mode 100644 index 000000000..fa6ee8825 --- /dev/null +++ b/internal/claimer/state_proof_test.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/repository/repotest" + "io" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestConsensusMachineValidityProofUsesContractFieldOrder(t *testing.T) { + canonical := repotest.KeccakStateProof(common.HexToHash("0xa1")) + + wire := consensusMachineValidityProof(canonical) + require.Equal(t, [32]byte(canonical.IflagsYDataBlock), wire.IflagsYProof.DataBlock) + require.Equal(t, canonical.IflagsYProof, wire.IflagsYProof.Siblings) + require.Equal(t, [32]byte(canonical.HtifTohostDataBlock), wire.HtifTohostProof.DataBlock) + require.Equal(t, canonical.HtifTohostProof, wire.HtifTohostProof.Siblings) + require.Equal(t, [32]byte(canonical.TxBufferDataBlock), wire.TxBufferProof.DataBlock) + require.Equal(t, canonical.TxBufferProof, wire.TxBufferProof.Siblings) + + wire.IflagsYProof.Siblings[0][0] ^= 0xff + require.NotEqual(t, canonical.IflagsYProof[0], wire.IflagsYProof.Siblings[0], + "the contract proof must own its sibling storage") +} + +func TestSubmitClaimUsesMachineRootAndOutputsRoot(t *testing.T) { + app := makeApplication() + epoch := makeComputedEpoch(app, 3) + proof, err := epoch.StateProof() + require.NoError(t, err) + capture := &claimSubmitterCapture{} + blockchain := &claimerBlockchain{ + txOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{}), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + txHash, err := blockchain.submitClaimToBlockchain(t.Context(), capture, app, epoch, proof) + require.NoError(t, err) + require.Equal(t, capture.tx.Hash(), txHash) + require.Equal(t, app.IApplicationAddress, capture.appContract) + require.Equal(t, new(big.Int).SetUint64(epoch.LastBlock), capture.lastProcessedBlockNumber) + require.Equal(t, [32]byte(proof.MachineHash), capture.machineMerkleRoot) + require.Equal(t, [32]byte(proof.IflagsYDataBlock), capture.proof.IflagsYProof.DataBlock) + require.Equal(t, proof.IflagsYProof, capture.proof.IflagsYProof.Siblings) + require.Equal(t, [32]byte(proof.HtifTohostDataBlock), capture.proof.HtifTohostProof.DataBlock) + require.Equal(t, proof.HtifTohostProof, capture.proof.HtifTohostProof.Siblings) + require.Equal(t, [32]byte(proof.TxBufferDataBlock), capture.proof.TxBufferProof.DataBlock) + require.Equal(t, proof.TxBufferProof, capture.proof.TxBufferProof.Siblings) +} + +type claimSubmitterCapture struct { + tx *types.Transaction + appContract common.Address + lastProcessedBlockNumber *big.Int + machineMerkleRoot [32]byte + proof iconsensus.MachineValidityProof +} + +func (c *claimSubmitterCapture) SubmitClaim( + _ *bind.TransactOpts, + appContract common.Address, + lastProcessedBlockNumber *big.Int, + machineMerkleRoot [32]byte, + proof iconsensus.MachineValidityProof, +) (*types.Transaction, error) { + c.tx = types.NewTx(&types.LegacyTx{Nonce: 7}) + c.appContract = appContract + c.lastProcessedBlockNumber = new(big.Int).Set(lastProcessedBlockNumber) + c.machineMerkleRoot = machineMerkleRoot + c.proof = proof + return c.tx, nil +} diff --git a/internal/claimer/submit.go b/internal/claimer/submit.go index adea78c46..dbf01460c 100644 --- a/internal/claimer/submit.go +++ b/internal/claimer/submit.go @@ -427,6 +427,19 @@ func (s *Service) broadcastComputedClaim( currEpoch *model.Epoch, defaultBlockNumber *big.Int, ) claimStepResult { + proof, err := currEpoch.StateProof() + if err != nil { + stateErr := s.setApplicationCorrupted( + ctx, + app, + "cannot submit claim for epoch %d (%d), last_block %d: persisted machine state proof is incomplete: %v", + currEpoch.Index, + currEpoch.VirtualIndex, + currEpoch.LastBlock, + err, + ) + return claimDropped(stateErr) + } s.Logger.Debug("Submitting claim to blockchain", "app", app.IApplicationAddress, "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), @@ -434,7 +447,7 @@ func (s *Service) broadcastComputedClaim( ) txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) defer cancel() - txHash, err := s.blockchain.submitClaimToBlockchain(txCtx, ic, app, currEpoch) + txHash, err := s.blockchain.submitClaimToBlockchain(txCtx, ic, app, currEpoch, proof) if err != nil { switch outcome, stateErr := s.handleSubmitClaimRevert(ctx, err, app, currEpoch); outcome { case submitClaimAlreadyOnChain: diff --git a/internal/claimer/submit_test.go b/internal/claimer/submit_test.go index be456773c..200ccc61b 100644 --- a/internal/claimer/submit_test.go +++ b/internal/claimer/submit_test.go @@ -6,6 +6,7 @@ package claimer import ( "context" "math/big" + "strings" "testing" "time" @@ -36,7 +37,7 @@ func TestSubmitFirstClaim(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.HexToHash("0x10"), nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -45,10 +46,64 @@ func TestSubmitFirstClaim(t *testing.T) { assert.Equal(t, 1, transitions, "submitting a claim counts as a transition") } -// withForeclosed returns a copy of app with ForecloseBlock / ForecloseTransaction -// populated, matching the in-memory state evmreader leaves behind after -// checkForForeclosure has run on a foreclosed application. +func TestSubmitClaimRejectsIncompletePersistedStateProofBeforeBroadcast(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + currEpoch.HtifTohostProof = currEpoch.HtifTohostProof[:model.StateProofSiblingCount-1] + expectPreSubmitPath(b, app, currEpoch, endBlock) + // No submitClaimToBlockchain expectation is registered. An attempted + // broadcast makes the mock fail this test. + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "persisted machine state proof is incomplete") + })).Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + transitions, err := m.submitClaimsAndUpdateDatabase( + t.Context(), makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + require.Zero(t, transitions) + require.ErrorContains(t, err, "persisted machine state proof is incomplete") + require.Empty(t, currEpochs) + require.Empty(t, m.claimsInFlight) + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) +} + +func TestSubmitClaimDefersCompleteProofValidationToContract(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + currEpoch.IflagsYProof[0][0] ^= 0xff + corruptSibling := currEpoch.IflagsYProof[0] + expectPreSubmitPath(b, app, currEpoch, endBlock) + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, + mock.MatchedBy(func(proof model.StateProof) bool { + return proof.IflagsYProof[0] == corruptSibling + })).Return(common.Hash{}, consensusRevertError("InvalidMachineMerkleProof")).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "proof serialization, stored proof data") + })).Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + _, err := m.submitClaimsAndUpdateDatabase( + t.Context(), makeEpochMap(), currEpochs, makeApplicationMap(app), endBlock) + require.NoError(t, err) + require.Empty(t, currEpochs) + require.Empty(t, m.claimsInFlight) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) +} +// TestSubmitClaimForeclosesUnstagedForeclosedApp checks that reconciliation +// continues after foreclosure but no new claim is broadcast. func TestSubmitClaimForeclosesUnstagedForeclosedApp(t *testing.T) { m, r, b := newServiceMock(t) defer r.AssertExpectations(t) @@ -145,7 +200,7 @@ func TestSubmitClaimForecloseMidFlight(t *testing.T) { Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, epochN, endBlock) tick1TxHash := common.HexToHash("0xa1") - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, epochN). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, epochN, mock.Anything). Return(tick1TxHash, nil).Once() transitions1, errs1 := m.submitClaimsAndUpdateDatabase( @@ -304,7 +359,7 @@ func TestSubmitClaimWithAntecessor(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.HexToHash("0x10"), nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -332,7 +387,7 @@ func TestSubmitClaimWithAcceptedAntecessorWithoutClaimTransactionHash(t *testing b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{prevEvent, currEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(common.HexToHash("0x10"), nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase( @@ -520,7 +575,7 @@ func TestQuorumDifferentOutputSubmittedEventStillSubmitsLocalClaim(t *testing.T) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(txHash, nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -548,7 +603,7 @@ func TestQuorumForeignMatchingSubmittedEventStillSubmitsLocalClaim(t *testing.T) b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(txHash, nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -615,7 +670,7 @@ func TestQuorumSubmittedEventsIgnoresForeignAdversarialProofAndSubmitsLocalClaim b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent, adversarialEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(txHash, nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -779,7 +834,7 @@ func TestQuorumPreviousSubmittedEventsIgnoresForeignMismatchAndSubmitsCurrentCla b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, prevEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignPrevEvent, matchingPrevEvent}, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Return(txHash, nil).Once() transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -951,7 +1006,7 @@ func TestSubmitClaimTimeout(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Run(func(args mock.Arguments) { ctx := args.Get(0).(context.Context) select { @@ -990,7 +1045,7 @@ func TestSubmitClaimContextCanceled(t *testing.T) { b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch, mock.Anything). Run(func(args mock.Arguments) { ctx := args.Get(0).(context.Context) select { diff --git a/internal/cli/ethereum.go b/internal/cli/ethereum.go index 00357d81d..8776e46f3 100644 --- a/internal/cli/ethereum.go +++ b/internal/cli/ethereum.go @@ -11,11 +11,12 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) -func GetTransactOpts(ctx context.Context, chainId *big.Int) (*bind.TransactOpts, error) { - factory, err := auth.GetTransactOptsFactory(ctx, chainId) +func GetTransactOpts(ctx context.Context, chainID *big.Int) (*bind.TransactOpts, error) { + factory, err := auth.GetTransactOptsFactory(ctx, chainID) if err != nil { return nil, err } @@ -41,3 +42,22 @@ func GetTransactOptsFromFactory( } return txOpts, nil } + +// prepareTransactionFees applies the CLI fee policy immediately before signing. +// Dependent transactions, such as approval followed by deposit, each obtain a +// fresh price. A false legacy setting preserves the binding's automatic choice. +func prepareTransactionFees( + ctx context.Context, + client ethereum.GasPricer, + opts *bind.TransactOpts, +) (*bind.TransactOpts, error) { + legacy, err := config.GetBlockchainLegacyEnabled() + if err != nil && !errors.Is(err, config.ErrNotDefined) { + return nil, err + } + factory := ethutil.NewStaticTransactOptsFactory(opts) + if legacy { + factory = ethutil.WithLegacyFees(factory, client) + } + return factory.NewTransactOpts(ctx) +} diff --git a/internal/cli/legacy_fees_test.go b/internal/cli/legacy_fees_test.go new file mode 100644 index 000000000..6b29f0afc --- /dev/null +++ b/internal/cli/legacy_fees_test.go @@ -0,0 +1,170 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "fmt" + "math/big" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" +) + +func TestTransactLegacyFeePolicy(t *testing.T) { + for _, legacy := range []bool{false, true} { + for _, manualGas := range []bool{false, true} { + for _, noWait := range []bool{false, true} { + t.Run(fmt.Sprintf("legacy_%t/manual_gas_%t/no_wait_%t", legacy, manualGas, noWait), func(t *testing.T) { + viper.Reset() + config.SetDefaults() + t.Cleanup(func() { viper.Reset(); config.SetDefaults() }) + viper.Set(config.AUTH_KIND, "private_key") + viper.Set(config.AUTH_PRIVATE_KEY, testPrivateKey) + viper.Set(config.BLOCKCHAIN_LEGACY_ENABLED, legacy) + if manualGas { + viper.Set(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + backend := &cliLegacyFeeRPC{sent: make(chan *types.Transaction, 2)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chainID := big.NewInt(31337) + opts, err := GetTransactOpts(t.Context(), chainID) + require.NoError(t, err) + opts.Nonce, opts.Value = big.NewInt(9), big.NewInt(8) + inputBox, err := iinputbox.NewIInputBox(common.HexToAddress("0x01"), client) + require.NoError(t, err) + cmd, _, _ := transactionCommand() + require.NoError(t, cmd.Flags().Set("no-wait", fmt.Sprint(noWait))) + for sequence := int64(1); sequence <= 2; sequence++ { + tx, receipt, err := Transact(t.Context(), cmd, client, opts, + func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.True(t, prepared.NoSend) + require.Equal(t, t.Context(), prepared.Context) + return inputBox.AddInput(prepared, common.HexToAddress("0x02"), []byte{3}) + }) + require.NoError(t, err) + require.Len(t, backend.sent, 1, "broadcast exactly once") + sent := <-backend.sent + require.Equal(t, tx.Hash(), sent.Hash()) + if legacy { + require.EqualValues(t, types.LegacyTxType, sent.Type()) + require.Equal(t, big.NewInt(100+sequence), sent.GasPrice()) + } else { + require.EqualValues(t, types.DynamicFeeTxType, sent.Type()) + } + from, err := types.Sender(types.LatestSignerForChainID(chainID), sent) + require.NoError(t, err) + require.Equal(t, opts.From, from) + require.Equal(t, opts.Nonce.Uint64(), sent.Nonce()) + require.Equal(t, opts.Value, sent.Value()) + if manualGas { + require.EqualValues(t, 123456, sent.Gas()) + } else { + require.EqualValues(t, 75000, sent.Gas()) + } + if noWait { + require.Nil(t, receipt) + } else { + require.Equal(t, types.ReceiptStatusSuccessful, receipt.Status) + } + } + require.False(t, opts.NoSend) + require.Nil(t, opts.GasPrice, "do not cache the fee in caller options") + if legacy { + require.EqualValues(t, 2, backend.prices.Load()) + } else { + require.Zero(t, backend.prices.Load()) + } + if manualGas { + require.Zero(t, backend.estimates.Load()) + } else { + require.EqualValues(t, 2, backend.estimates.Load()) + } + if noWait { + require.Zero(t, backend.receipts.Load()) + } else { + require.EqualValues(t, 2, backend.receipts.Load()) + } + }) + } + } + } +} + +type cliLegacyFeeRPC struct { + prices atomic.Int32 + estimates atomic.Int32 + receipts atomic.Int32 + sent chan *types.Transaction +} + +func (r *cliLegacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} + +func (*cliLegacyFeeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(0), BaseFee: big.NewInt(10)}, nil +} + +func (*cliLegacyFeeRPC) MaxPriorityFeePerGas(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(2)), nil +} + +func (*cliLegacyFeeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (r *cliLegacyFeeRPC) EstimateGas(context.Context, map[string]any) (hexutil.Uint64, error) { + r.estimates.Add(1) + return 75000, nil +} + +func (r *cliLegacyFeeRPC) SendRawTransaction(_ context.Context, data hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + r.sent <- tx + return tx.Hash(), nil +} + +func (r *cliLegacyFeeRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(1), Logs: []*types.Log{}}, nil +} + +func TestTransactLegacyFeeFailureDoesNotSign(t *testing.T) { + viper.Reset() + config.SetDefaults() + t.Cleanup(func() { viper.Reset(); config.SetDefaults() }) + viper.Set(config.BLOCKCHAIN_LEGACY_ENABLED, true) + client := &transactionRPC{} + cmd, _, stderr := transactionCommand() + tx, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { + t.Fatal("must not sign without a gas price") + return nil, nil + }) + require.ErrorContains(t, err, "prepare transaction fees: suggest legacy gas price") + require.Nil(t, tx) + require.Nil(t, receipt) + require.Empty(t, stderr.String()) + require.Zero(t, client.sends) + require.Zero(t, client.receipts) +} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index 2c7e3f4b8..d366e09f4 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -7,6 +7,7 @@ import ( "bufio" "errors" "fmt" + "io" "os" "strings" ) @@ -21,10 +22,17 @@ var ErrNonInteractive = errors.New( // Returns ErrNonInteractive if stdin is not a terminal (e.g. piped in a script), // or a wrapped I/O error if reading from stdin fails. func ConfirmPrompt(message string) (bool, error) { + return ConfirmPromptTo(os.Stdout, message) +} + +// ConfirmPromptTo writes the human prompt separately from command results. +func ConfirmPromptTo(output io.Writer, message string) (bool, error) { if !IsTerminal(os.Stdin) { return false, ErrNonInteractive } - fmt.Printf("%s [y/N]: ", message) + if _, err := fmt.Fprintf(output, "%s [y/N]: ", message); err != nil { + return false, fmt.Errorf("write confirmation prompt: %w", err) + } scanner := bufio.NewScanner(os.Stdin) scanner.Scan() if err := scanner.Err(); err != nil { diff --git a/internal/cli/transaction.go b/internal/cli/transaction.go new file mode 100644 index 000000000..b784501a7 --- /dev/null +++ b/internal/cli/transaction.go @@ -0,0 +1,192 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" +) + +const ( + defaultReceiptTimeout = 2 * time.Minute + receiptPollInterval = 500 * time.Millisecond +) + +// AddTransactionFlags installs the receipt policy for one transaction command. +// Commands with dependent steps must also reject incompatible --no-wait use. +func AddTransactionFlags(cmd *cobra.Command) { + cmd.Flags().Bool("no-wait", false, "Return after broadcast without checking execution success") + cmd.Flags().Duration("wait-timeout", defaultReceiptTimeout, "Maximum time to wait for a mined receipt") + previousRun := cmd.PreRunE + cmd.PreRunE = func(command *cobra.Command, args []string) error { + _, _, err := transactionPolicy(command) + if err != nil { + return err + } + if previousRun != nil { + return previousRun(command, args) + } + return nil + } + previousHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + if flag := command.Flags().Lookup("gas-limit"); flag != nil { + flag.Hidden = false + } + previousHelp(command, args) + }) +} + +func transactionPolicy(cmd *cobra.Command) (bool, time.Duration, error) { + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return false, 0, err + } + timeout, err := cmd.Flags().GetDuration("wait-timeout") + if err != nil { + return false, 0, err + } + if timeout <= 0 { + return false, 0, fmt.Errorf("wait-timeout must be positive") + } + return noWait, timeout, nil +} + +// TransactionClient provides fee suggestions, broadcast, and receipt lookup. +type TransactionClient interface { + ethereum.GasPricer + SendTransaction(context.Context, *types.Transaction) error + TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) +} + +// Transact prepares and signs once, broadcasts once, and optionally checks mining. +// A nil receipt with no error means broadcast-only, not confirmed execution. +// After signing, the transaction is returned even if sending or waiting fails. +// GasLimit is preserved: zero estimates gas; a manual limit skips estimation. +func Transact( + ctx context.Context, + cmd *cobra.Command, + client TransactionClient, + opts *bind.TransactOpts, + build func(*bind.TransactOpts) (*types.Transaction, error), +) (*types.Transaction, *types.Receipt, error) { + noWait, timeout, err := transactionPolicy(cmd) + if err != nil { + return nil, nil, err + } + if err := ctx.Err(); err != nil { + return nil, nil, err + } + preparedOpts, err := prepareTransactionFees(ctx, client, opts) + if err != nil { + return nil, nil, fmt.Errorf("prepare transaction fees: %w", err) + } + preparedOpts.NoSend = true + tx, err := build(preparedOpts) + if err != nil { + return nil, nil, fmt.Errorf("prepare transaction: %w", err) + } + if tx == nil { + return nil, nil, fmt.Errorf("prepare transaction: no signed transaction") + } + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Transaction hash: %s\n", tx.Hash().Hex()); err != nil { + return tx, nil, fmt.Errorf("write transaction hash before broadcast: %w", err) + } + // Before broadcast, keep normal signal handling for prompts, stdin, and + // signers that capture their own context. No transaction has been sent. + // After this point, cancellation must report the known transaction hash. + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + if err := client.SendTransaction(ctx, tx); err != nil { + return tx, nil, fmt.Errorf("broadcast transaction %s: %w; check its receipt before retrying", tx.Hash().Hex(), err) + } + if noWait { + return tx, nil, nil + } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + receipt, err := waitForReceipt(waitCtx, client, tx.Hash()) + return tx, receipt, err +} + +func waitForReceipt(ctx context.Context, client TransactionClient, hash common.Hash) (*types.Receipt, error) { + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), err) + } + receipt, err := client.TransactionReceipt(ctx, hash) + switch { + case err == nil && receipt != nil: + if receipt.TxHash != hash { + return nil, fmt.Errorf("transaction %s outcome unknown: provider returned a different receipt hash", hash.Hex()) + } + if receipt.Status != types.ReceiptStatusSuccessful { + return receipt, fmt.Errorf("transaction %s failed in block %v (receipt status %d)", + hash.Hex(), receipt.BlockNumber, receipt.Status) + } + return receipt, nil + case err != nil && !errors.Is(err, ethereum.NotFound): + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), err) + } + // A missing receipt is normal while the transaction is pending. Do not + // infer failure or resubmit from mempool visibility on another RPC node. + timer := time.NewTimer(receiptPollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), ctx.Err()) + case <-timer.C: + } + } +} + +// TransactionResult distinguishes broadcast from successful mined execution. +// A mined result does not assert finality or application input processing. +type TransactionResult struct { + TransactionHash string `json:"transaction_hash"` + Status string `json:"status"` + BlockNumber string `json:"block_number,omitempty"` +} + +func NewTransactionResult(tx *types.Transaction, receipt *types.Receipt) TransactionResult { + result := TransactionResult{TransactionHash: tx.Hash().Hex(), Status: "broadcast"} + if receipt != nil { + result.Status = "mined" + if receipt.BlockNumber != nil { + result.BlockNumber = fmt.Sprintf("0x%x", receipt.BlockNumber) + } + } + return result +} + +func WriteTransactionResult(cmd *cobra.Command, tx *types.Transaction, receipt *types.Receipt) error { + result := NewTransactionResult(tx, receipt) + asJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + if asJSON { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + err = encoder.Encode(result) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Transaction %s: %s\n", result.Status, result.TransactionHash) + } + if err != nil { + return fmt.Errorf("write transaction result: %w", err) + } + return nil +} diff --git a/internal/cli/transaction_review_test.go b/internal/cli/transaction_review_test.go new file mode 100644 index 000000000..c4f77cafc --- /dev/null +++ b/internal/cli/transaction_review_test.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "syscall" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +const transactionSignalTestChild = "CARTESI_CLI_TRANSACTION_SIGNAL_TEST_CHILD" + +func TestTransactHashWriteFailureStopsBroadcast(t *testing.T) { + t.Parallel() + cmd, stdout, _ := transactionCommand() + cmd.SetErr(failingTransactionWriter{}) + client := &transactionRPC{} + tx := transactionFixture() + actual, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorIs(t, err, io.ErrClosedPipe) + require.ErrorContains(t, err, "write transaction hash before broadcast") + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Zero(t, client.sends) + require.Zero(t, client.receipts) + require.Empty(t, stdout.String()) +} + +// Signals must reach only the subprocess. Other tests can have active +// transaction contexts in the parent process at the same time. +func TestTransactSignalCancellation(t *testing.T) { + if os.Getenv(transactionSignalTestChild) == "1" { + runTransactionSignalChild(t) + return + } + + executable, err := os.Executable() + require.NoError(t, err) + for _, signal := range []os.Signal{os.Interrupt, syscall.SIGTERM} { + t.Run(signal.String(), func(t *testing.T) { + const childTimeout = 10 * time.Second + ctx, cancel := context.WithTimeout(t.Context(), childTimeout) + defer cancel() + child := exec.CommandContext(ctx, executable, "-test.run=^TestTransactSignalCancellation$") + child.Env = append(os.Environ(), transactionSignalTestChild+"=1") + var stderr bytes.Buffer + child.Stderr = &stderr + stdout, err := child.StdoutPipe() + require.NoError(t, err) + require.NoError(t, child.Start()) + t.Cleanup(func() { + cancel() + if child.ProcessState == nil { + _ = child.Wait() + } + }) + + // The child acknowledges the receipt request after Transact installs + // its signal handler. No sleep or process-start timing is required. + scanner := bufio.NewScanner(stdout) + require.True(t, scanner.Scan(), "child exited before requesting a receipt") + require.Equal(t, "waiting-for-receipt", scanner.Text()) + require.NoError(t, child.Process.Signal(signal)) + require.NoError(t, child.Wait(), stderr.String()) + require.Contains(t, stderr.String(), transactionFixture().Hash().Hex()) + require.Contains(t, stderr.String(), "outcome unknown") + require.Contains(t, stderr.String(), "context canceled") + }) + } +} + +func runTransactionSignalChild(t *testing.T) { + t.Helper() + cmd, _, _ := transactionCommand() + cmd.SetErr(os.Stderr) + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return nil }, + receipt: func(ctx context.Context, _ common.Hash) (*types.Receipt, error) { + _, err := fmt.Fprintln(os.Stdout, "waiting-for-receipt") + require.NoError(t, err) + <-ctx.Done() + return nil, ctx.Err() + }, + } + tx := transactionFixture() + actual, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorIs(t, err, context.Canceled) + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Equal(t, 1, client.sends) + require.Equal(t, 1, client.receipts) + _, writeErr := fmt.Fprintln(os.Stderr, err) + require.NoError(t, writeErr) +} diff --git a/internal/cli/transaction_test.go b/internal/cli/transaction_test.go new file mode 100644 index 000000000..e6ef18f3d --- /dev/null +++ b/internal/cli/transaction_test.go @@ -0,0 +1,262 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +type transactionRPC struct { + send func(context.Context, *types.Transaction) error + receipt func(context.Context, common.Hash) (*types.Receipt, error) + sends int + receipts int +} + +func (*transactionRPC) SuggestGasPrice(context.Context) (*big.Int, error) { + return nil, errors.New("unexpected gas price request") +} + +func (r *transactionRPC) SendTransaction(ctx context.Context, tx *types.Transaction) error { + r.sends++ + return r.send(ctx, tx) +} + +func (r *transactionRPC) TransactionReceipt(ctx context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts++ + return r.receipt(ctx, hash) +} + +func transactionCommand() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + cmd := &cobra.Command{Use: "test-transaction"} + AddTransactionFlags(cmd) + cmd.Flags().Bool("json", false, "JSON output") + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + return cmd, stdout, stderr +} + +func transactionFixture() *types.Transaction { + return types.NewTx(&types.LegacyTx{Gas: 21000, GasPrice: big.NewInt(1)}) +} + +func TestTransactPolicy(t *testing.T) { + t.Parallel() + for _, noWait := range []bool{false, true} { + name := "wait" + if noWait { + name = "no_wait" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + if noWait { + require.NoError(t, cmd.Flags().Set("no-wait", "true")) + } + tx := transactionFixture() + opts := &bind.TransactOpts{GasLimit: 123456} + var built bool + client := &transactionRPC{ + send: func(_ context.Context, sent *types.Transaction) error { + require.True(t, built) + require.Same(t, tx, sent) + require.Contains(t, stderr.String(), tx.Hash().Hex(), "hash must be available before broadcast") + return nil + }, + receipt: func(_ context.Context, hash common.Hash) (*types.Receipt, error) { + require.False(t, noWait, "no-wait must not query receipts") + require.Equal(t, tx.Hash(), hash) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(9)}, nil + }, + } + actual, receipt, err := Transact(t.Context(), cmd, client, opts, + func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.NotSame(t, opts, prepared) + require.True(t, prepared.NoSend) + require.Equal(t, opts.GasLimit, prepared.GasLimit) + require.NoError(t, prepared.Context.Err()) + built = true + return tx, nil + }) + require.NoError(t, err) + require.Same(t, tx, actual) + require.False(t, opts.NoSend, "do not mutate caller options") + require.Equal(t, 1, client.sends) + require.Empty(t, stdout.String(), "the command owns its final result") + if noWait { + require.Nil(t, receipt) + require.Zero(t, client.receipts) + } else { + require.Equal(t, types.ReceiptStatusSuccessful, receipt.Status) + require.Equal(t, 1, client.receipts) + } + }) + } +} + +func TestTransactErrorsKeepHash(t *testing.T) { + t.Parallel() + rpcError := errors.New("RPC response lost") + tx := transactionFixture() + for _, test := range []struct { + name string + sendError error + receipt *types.Receipt + readError error + want string + cause error + }{ + {name: "broadcast", sendError: rpcError, want: "check its receipt before retrying", cause: rpcError}, + {name: "read", readError: rpcError, want: "outcome unknown", cause: rpcError}, + {name: "manual_gas_mined_failure", receipt: &types.Receipt{ + TxHash: tx.Hash(), Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(9)}, want: "failed in block 9"}, + {name: "wrong_receipt", receipt: &types.Receipt{TxHash: common.HexToHash("0x99")}, want: "different receipt hash"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + cmd, _, stderr := transactionCommand() + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return test.sendError }, + receipt: func(context.Context, common.Hash) (*types.Receipt, error) { return test.receipt, test.readError }, + } + actual, _, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{GasLimit: tx.Gas()}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorContains(t, err, test.want) + require.ErrorContains(t, err, tx.Hash().Hex()) + if test.cause != nil { + require.ErrorIs(t, err, test.cause) + } + require.Same(t, tx, actual) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.Equal(t, 1, client.sends, "never resubmit on send or receipt errors") + }) + } +} + +func TestTransactPendingTimeoutAndCancellation(t *testing.T) { + t.Parallel() + for _, cancelWait := range []bool{false, true} { + name := "timeout" + if cancelWait { + name = "cancel" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, _, _ := transactionCommand() + require.NoError(t, cmd.Flags().Set("wait-timeout", "10ms")) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return nil }, + receipt: func(context.Context, common.Hash) (*types.Receipt, error) { + if cancelWait { + cancel() + } + return nil, ethereum.NotFound + }, + } + tx := transactionFixture() + actual, receipt, err := Transact(ctx, cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorContains(t, err, "outcome unknown") + require.ErrorContains(t, err, tx.Hash().Hex()) + if cancelWait { + require.ErrorIs(t, err, context.Canceled) + } else { + require.ErrorIs(t, err, context.DeadlineExceeded) + } + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Equal(t, 1, client.sends) + require.Equal(t, 1, client.receipts) + }) + } +} + +func TestTransactPreparationStopsBeforeBroadcast(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + estimateError := errors.New("execution reverted during estimation") + client := &transactionRPC{} + tx, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(opts *bind.TransactOpts) (*types.Transaction, error) { + require.Zero(t, opts.GasLimit, "preserve the default estimation policy") + return nil, estimateError + }) + require.ErrorIs(t, err, estimateError) + require.Nil(t, tx) + require.Nil(t, receipt) + require.Zero(t, client.sends) + require.Empty(t, stdout.String()) + require.Empty(t, stderr.String()) +} + +func TestTransactionFlagValidation(t *testing.T) { + t.Parallel() + for _, timeout := range []string{"0s", "-1s"} { + t.Run(timeout, func(t *testing.T) { + t.Parallel() + cmd, _, _ := transactionCommand() + cmd.RunE = func(*cobra.Command, []string) error { t.Fatal("must reject before command runs"); return nil } + cmd.SetArgs([]string{"--wait-timeout", timeout}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "wait-timeout must be positive") + }) + } + t.Run("preserve_command_validation", func(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{Use: "test", PreRunE: func(*cobra.Command, []string) error { return io.ErrUnexpectedEOF }} + AddTransactionFlags(cmd) + require.ErrorIs(t, cmd.PreRunE(cmd, nil), io.ErrUnexpectedEOF) + }) +} + +type failingTransactionWriter struct{} + +func (failingTransactionWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestWriteTransactionResult(t *testing.T) { + t.Parallel() + for _, asJSON := range []bool{false, true} { + name := "text" + if asJSON { + name = "json" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + if asJSON { + require.NoError(t, cmd.Flags().Set("json", "true")) + } + tx := transactionFixture() + require.NoError(t, WriteTransactionResult(cmd, tx, nil)) + require.Contains(t, stdout.String(), tx.Hash().Hex()) + require.Contains(t, stdout.String(), "broadcast") + require.Empty(t, stderr.String()) + if asJSON { + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.NotContains(t, result, "block_number") + } + cmd.SetOut(failingTransactionWriter{}) + require.ErrorIs(t, WriteTransactionResult(cmd, tx, nil), io.ErrClosedPipe) + }) + } + result := NewTransactionResult(transactionFixture(), &types.Receipt{BlockNumber: big.NewInt(16)}) + require.Equal(t, "mined", result.Status) + require.Equal(t, "0x10", result.BlockNumber) +} diff --git a/internal/cli/types.go b/internal/cli/types.go index 449901a51..a6b20b402 100644 --- a/internal/cli/types.go +++ b/internal/cli/types.go @@ -2,23 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 (see LICENSE) // Package cli defines the JSON output types used by the CLI commands. -// Types here are CLI-specific (SendResult, ExecuteResult, ValidateResult). +// Types here are CLI-specific (SendResult, TransactionResult, ValidateResult). // Response envelope types (ListResponse, SingleResponse, Pagination) live // in internal/jsonrpc/api. package cli // SendResult is the JSON output of the "send" CLI command. -// In async mode, only ApplicationAddress and TransactionHash are populated. +// With --no-wait, the input index and block number are not yet known. type SendResult struct { + TransactionResult ApplicationAddress string `json:"application_address"` - TransactionHash string `json:"transaction_hash"` InputIndex string `json:"input_index,omitempty"` - BlockNumber string `json:"block_number,omitempty"` -} - -// ExecuteResult is the JSON output of the "execute" CLI command. -type ExecuteResult struct { - TransactionHash string `json:"transaction_hash"` } // ValidateResult is the JSON output of the "validate" CLI command. diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index d06166d4a..2e6c0e850 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -21,22 +21,54 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" ) -func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.TransactOptsFactory, error) { - if chainId == nil || chainId.Sign() <= 0 { +type authGetters struct { + kind func() (AuthKind, error) + privateKey func() (RedactedString, error) + mnemonic func() (RedactedString, error) + mnemonicAccountIndex func() (RedactedUint, error) + awsKMSKeyID func() (RedactedString, error) +} + +func GetTransactOptsFactory(ctx context.Context, chainID *big.Int) (ethutil.TransactOptsFactory, error) { + return getTransactOptsFactory(ctx, chainID, authGetters{ + kind: GetAuthKind, + privateKey: GetAuthPrivateKey, + mnemonic: GetAuthMnemonic, + mnemonicAccountIndex: GetAuthMnemonicAccountIndex, + awsKMSKeyID: GetAuthAwsKmsKeyId, + }) +} + +func GetPrtTransactOptsFactory(ctx context.Context, chainID *big.Int) (ethutil.TransactOptsFactory, error) { + return getTransactOptsFactory(ctx, chainID, authGetters{ + kind: GetPrtAuthKind, + privateKey: GetPrtAuthPrivateKey, + mnemonic: GetPrtAuthMnemonic, + mnemonicAccountIndex: GetPrtAuthMnemonicAccountIndex, + awsKMSKeyID: GetPrtAuthAwsKmsKeyId, + }) +} + +func getTransactOptsFactory( + ctx context.Context, + chainID *big.Int, + getters authGetters, +) (ethutil.TransactOptsFactory, error) { + if chainID == nil || chainID.Sign() <= 0 { return nil, bind.ErrNoChainID } - authKind, err := GetAuthKind() + authKind, err := getters.kind() if err != nil { return nil, err } switch authKind { - case AuthKindMnemonicVar: - mnemonic, err := GetAuthMnemonic() + case AuthKindMnemonicVar, AuthKindMnemonicFile: + mnemonic, err := getters.mnemonic() if err != nil { return nil, err } - accountIndex, err := GetAuthMnemonicAccountIndex() + accountIndex, err := getters.mnemonicAccountIndex() if err != nil { return nil, err } @@ -44,13 +76,13 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran if err != nil { return nil, err } - txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainId) + txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainID) if err != nil { return nil, err } return ethutil.NewStaticTransactOptsFactory(txOpts), nil - case AuthKindPrivateKeyVar: - privateKey, err := GetAuthPrivateKey() + case AuthKindPrivateKeyVar, AuthKindPrivateKeyFile: + privateKey, err := getters.privateKey() if err != nil { return nil, err } @@ -58,13 +90,13 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran if err != nil { return nil, err } - txOpts, err := bind.NewKeyedTransactorWithChainID(key, chainId) + txOpts, err := bind.NewKeyedTransactorWithChainID(key, chainID) if err != nil { return nil, err } return ethutil.NewStaticTransactOptsFactory(txOpts), nil case AuthKindAWS: - keyId, err := GetAuthAwsKmsKeyId() + keyID, err := getters.awsKMSKeyID() if err != nil { return nil, err } @@ -76,8 +108,8 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran return signtx.CreateAWSTransactOptsFactory( ctx, kmsClient, - aws.String(keyId.Value), - types.LatestSignerForChainID(chainId), + aws.String(keyID.Value), + types.LatestSignerForChainID(chainID), ) default: return nil, fmt.Errorf("no valid authentication method found") diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go index 3fb54c58f..1953dc475 100644 --- a/internal/config/auth/auth_test.go +++ b/internal/config/auth/auth_test.go @@ -8,10 +8,13 @@ import ( "crypto/rand" "encoding/asn1" "encoding/base64" + "encoding/hex" "encoding/json" "math/big" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -22,8 +25,131 @@ import ( "github.com/stretchr/testify/require" . "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/ethutil" ) +func TestGetTransactOptsFactoryAcceptsVariableAndFileKinds(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + privateKeyText := hex.EncodeToString(crypto.FromECDSA(privateKey)) + privateKeyAddress := crypto.PubkeyToAddress(privateKey.PublicKey) + + mnemonicKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 4) + require.NoError(t, err) + mnemonicAddress := crypto.PubkeyToAddress(mnemonicKey.PublicKey) + + tests := []struct { + name string + kind AuthKind + expectedAddress common.Address + }{ + {name: "private key variable", kind: AuthKindPrivateKeyVar, expectedAddress: privateKeyAddress}, + {name: "private key file", kind: AuthKindPrivateKeyFile, expectedAddress: privateKeyAddress}, + {name: "mnemonic variable", kind: AuthKindMnemonicVar, expectedAddress: mnemonicAddress}, + {name: "mnemonic file", kind: AuthKindMnemonicFile, expectedAddress: mnemonicAddress}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, err := getTransactOptsFactory(t.Context(), big.NewInt(31337), authGetters{ + kind: func() (AuthKind, error) { return test.kind, nil }, + privateKey: func() (RedactedString, error) { + return RedactedString{Value: privateKeyText}, nil + }, + mnemonic: func() (RedactedString, error) { + return RedactedString{Value: ethutil.FoundryMnemonic}, nil + }, + mnemonicAccountIndex: func() (RedactedUint, error) { + return RedactedUint{Value: 4}, nil + }, + awsKMSKeyID: func() (RedactedString, error) { + t.Fatal("AWS KMS getter must not be called") + return RedactedString{}, nil + }, + }) + require.NoError(t, err) + require.Equal(t, test.expectedAddress, factory.From()) + }) + } +} + +func TestGetPrtTransactOptsFactoryUsesOnlyPrtAuth(t *testing.T) { + for _, test := range []struct { + name string + index string + want uint32 + }{ + {name: "default", want: 6}, + {name: "explicit nonzero index", index: "4", want: 4}, + {name: "explicit zero index", index: "0"}, + } { + t.Run(test.name, func(t *testing.T) { + resetAuthConfig(t) + t.Setenv(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, test.index) + viper.Set(AUTH_KIND, "mnemonic") + viper.Set(AUTH_MNEMONIC, ethutil.FoundryMnemonic) + viper.Set(AUTH_MNEMONIC_ACCOUNT_INDEX, 0) + viper.Set(PRT_AUTH_KIND, "mnemonic") + viper.Set(PRT_AUTH_MNEMONIC, ethutil.FoundryMnemonic) + + claimerFactory, err := GetTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + prtFactory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + prtKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, test.want) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(prtKey.PublicKey), prtFactory.From()) + if test.want != 0 { + require.NotEqual(t, claimerFactory.From(), prtFactory.From()) + } else { + // Operators may configure the same address for both services. + require.Equal(t, claimerFactory.From(), prtFactory.From()) + } + }) + } +} + +func TestGetPrtTransactOptsFactoryDoesNotFallbackToGenericAuth(t *testing.T) { + resetAuthConfig(t) + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + viper.Set(AUTH_KIND, "private_key") + viper.Set(AUTH_PRIVATE_KEY, hex.EncodeToString(crypto.FromECDSA(privateKey))) + viper.Set(PRT_AUTH_KIND, "private_key") + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.Nil(t, factory) + require.ErrorContains(t, err, PRT_AUTH_PRIVATE_KEY) +} + +func TestGetPrtTransactOptsFactoryRequiresPrtMnemonic(t *testing.T) { + resetAuthConfig(t) + viper.Set(AUTH_KIND, "mnemonic") + viper.Set(AUTH_MNEMONIC, ethutil.FoundryMnemonic) + viper.Set(PRT_AUTH_KIND, "mnemonic") + viper.Set(PRT_AUTH_MNEMONIC, "") + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.Nil(t, factory) + require.ErrorContains(t, err, PRT_AUTH_MNEMONIC) +} + +func TestGetPrtTransactOptsFactoryReadsFileAuth(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + privateKeyText := hex.EncodeToString(crypto.FromECDSA(privateKey)) + privateKeyFile := filepath.Join(t.TempDir(), "prt-private-key") + require.NoError(t, os.WriteFile(privateKeyFile, []byte(privateKeyText), 0o600)) + + resetAuthConfig(t) + viper.Set(PRT_AUTH_KIND, "private_key_file") + viper.Set(PRT_AUTH_PRIVATE_KEY_FILE, privateKeyFile) + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), factory.From()) +} + func TestGetTransactOptsFactoryAWSSignsDynamicFeeTransaction(t *testing.T) { server := newFakeKMSServer(t) t.Cleanup(server.Close) @@ -94,15 +220,21 @@ func TestGetTransactOptsFactoryRejectsInvalidChainID(t *testing.T) { } } -func setupAWSAuth(t *testing.T, endpoint string) { +func resetAuthConfig(t *testing.T) { t.Helper() viper.Reset() viper.AutomaticEnv() + SetDefaults() t.Cleanup(func() { viper.Reset() viper.AutomaticEnv() SetDefaults() }) +} + +func setupAWSAuth(t *testing.T, endpoint string) { + t.Helper() + resetAuthConfig(t) viper.Set(AUTH_KIND, "aws") viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key") diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index cb39dc353..ee7256153 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -69,18 +69,13 @@ used-by = ["validator", "node"] # Features # -[features.CARTESI_FEATURE_INPUT_READER_ENABLED] -default = "true" -go-type = "bool" -description = """ -If set to false, the node will not read inputs from the blockchain.""" -used-by = ["evmreader", "node"] - [features.CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED] default = "true" go-type = "bool" description = """ -If set to false, the node will not submit claims (reader mode).""" +If set to false, the node will not submit claims (reader mode). +The mode is saved when each submitter first starts. A later configuration mismatch +stops startup. Mode changes on an existing database are not supported.""" used-by = ["claimer", "node", "prt"] [features.CARTESI_FEATURE_INSPECT_ENABLED] @@ -218,15 +213,20 @@ used-by = ["evmreader", "claimer", "node"] default = "false" go-type = "bool" description = """ -If set to true the node will send transactions using the legacy gas fee model -(instead of EIP-1559).""" +If true, force the legacy transaction format with a fresh suggested gas price +for each transaction. If false, select the fee format automatically: EIP-1559 +when the network provides a base fee, or legacy otherwise. +This setting does not change gas estimation or replace pending transactions.""" used-by = ["claimer", "node", "cli", "prt"] [blockchain.CARTESI_BLOCKCHAIN_DEFAULT_BLOCK] default = "finalized" go-type = "DefaultBlock" description = """ -The default block to be used by EVM Reader and Claimer when requesting new blocks. +The block used by EVM Reader, Claimer, and PRT for chain-state observations. +PRT checks transaction readiness separately at 'latest'. +Use 'finalized' in production. The node does not detect or roll back reorgs. +The policy is saved when each service first starts. A later configuration mismatch stops startup. One of 'latest', 'pending', 'safe', 'finalized'""" used-by = ["evmreader", "claimer", "node", "prt"] @@ -341,7 +341,7 @@ One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws". The auth variable for the kind defined here is required. Eg.: CARTESI_AUTH_MNEMONIC""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] [auth.CARTESI_AUTH_PRIVATE_KEY] file = true @@ -349,7 +349,7 @@ go-type = "RedactedString" description = """ The node will use this private key to sign transactions.""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] [auth.CARTESI_AUTH_MNEMONIC] file = true @@ -357,7 +357,7 @@ go-type = "RedactedString" description = """ The node will use the private key generated from this mnemonic to sign transactions.""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] [auth.CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX] default = "0" @@ -366,7 +366,7 @@ description = """ When using mnemonics to sign transactions, the node will use this account index to generate the private key.""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] [auth.CARTESI_AUTH_AWS_KMS_KEY_ID] go-type = "RedactedString" @@ -383,7 +383,70 @@ Prefer an ARN or a bare key ID over an alias: an alias is resolved per-region, s the same alias in a different region names a different key and therefore a different signing address.""" omit = true -used-by = ["claimer", "node", "cli", "prt"] +used-by = ["claimer", "node", "cli"] + +# +# PRT Auth +# + +[prt-auth.CARTESI_PRT_AUTH_KIND] +default = "mnemonic" +go-type = "AuthKind" +description = """ +One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws". + +When claim submission is enabled, configure the PRT auth variable for the selected +kind, for example CARTESI_PRT_AUTH_MNEMONIC. The standalone node always starts PRT, +even when it serves only Authority or Quorum applications. CARTESI_AUTH_* does not +replace CARTESI_PRT_AUTH_*. PRT signer credentials are not required when claim +submission is disabled. + +Use distinct addresses for claimer and PRT.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_PRIVATE_KEY] +file = true +go-type = "RedactedString" +description = """ +The PRT service will use this private key to sign transactions.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_MNEMONIC] +file = true +go-type = "RedactedString" +description = """ +The PRT service will use the private key generated from this mnemonic to sign transactions.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX] +default = "6" +go-type = "RedactedUint" +description = """ +When using a mnemonic to sign PRT transactions, +the PRT service will use this account index to generate the private key. +The resulting PRT and Claimer signer addresses must differ.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_AWS_KMS_KEY_ID] +go-type = "RedactedString" +description = """ +An AWS KMS key ID, alias, or ARN for the PRT service. + +If set, the PRT service will use the AWS KMS service with this key to sign transactions. + +Everything else about the AWS connection — region, endpoint, and credentials — is +resolved by the AWS SDK's standard chain, not by CARTESI_ variables. See the +"Externally-provided configuration" section for the variables involved. + +Prefer an ARN or a bare key ID over an alias: an alias is resolved per-region, so +the same alias in a different region names a different key and therefore a +different signing address.""" +omit = true +used-by = ["node", "prt"] # # Database diff --git a/internal/config/generated.go b/internal/config/generated.go index cf9dab7ce..af3ab8829 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -40,7 +40,6 @@ const ( CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS = "CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS" DATABASE_CONNECTION = "CARTESI_DATABASE_CONNECTION" FEATURE_CLAIM_SUBMISSION_ENABLED = "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED" - FEATURE_INPUT_READER_ENABLED = "CARTESI_FEATURE_INPUT_READER_ENABLED" FEATURE_INSPECT_ENABLED = "CARTESI_FEATURE_INSPECT_ENABLED" FEATURE_JSONRPC_API_ENABLED = "CARTESI_FEATURE_JSONRPC_API_ENABLED" FEATURE_MACHINE_HASH_CHECK_ENABLED = "CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED" @@ -68,6 +67,11 @@ const ( LOG_LEVEL_PRT = "CARTESI_LOG_LEVEL_PRT" LOG_LEVEL_VALIDATOR = "CARTESI_LOG_LEVEL_VALIDATOR" JSONRPC_MACHINE_LOG_LEVEL = "CARTESI_JSONRPC_MACHINE_LOG_LEVEL" + PRT_AUTH_AWS_KMS_KEY_ID = "CARTESI_PRT_AUTH_AWS_KMS_KEY_ID" + PRT_AUTH_KIND = "CARTESI_PRT_AUTH_KIND" + PRT_AUTH_MNEMONIC = "CARTESI_PRT_AUTH_MNEMONIC" + PRT_AUTH_MNEMONIC_ACCOUNT_INDEX = "CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX" + PRT_AUTH_PRIVATE_KEY = "CARTESI_PRT_AUTH_PRIVATE_KEY" ADVANCER_INPUT_BATCH_SIZE = "CARTESI_ADVANCER_INPUT_BATCH_SIZE" ADVANCER_POLLING_INTERVAL = "CARTESI_ADVANCER_POLLING_INTERVAL" BLOCKCHAIN_GAS_LIMIT = "CARTESI_BLOCKCHAIN_GAS_LIMIT" @@ -95,6 +99,10 @@ const ( BLOCKCHAIN_HTTP_ENDPOINT_FILE = "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE" DATABASE_CONNECTION_FILE = "CARTESI_DATABASE_CONNECTION_FILE" + + PRT_AUTH_MNEMONIC_FILE = "CARTESI_PRT_AUTH_MNEMONIC_FILE" + + PRT_AUTH_PRIVATE_KEY_FILE = "CARTESI_PRT_AUTH_PRIVATE_KEY_FILE" ) func SetDefaults() { @@ -136,8 +144,6 @@ func SetDefaults() { viper.SetDefault(FEATURE_CLAIM_SUBMISSION_ENABLED, "true") - viper.SetDefault(FEATURE_INPUT_READER_ENABLED, "true") - viper.SetDefault(FEATURE_INSPECT_ENABLED, "true") viper.SetDefault(FEATURE_JSONRPC_API_ENABLED, "true") @@ -192,6 +198,16 @@ func SetDefaults() { viper.SetDefault(JSONRPC_MACHINE_LOG_LEVEL, "info") + // no default for CARTESI_PRT_AUTH_AWS_KMS_KEY_ID + + viper.SetDefault(PRT_AUTH_KIND, "mnemonic") + + // no default for CARTESI_PRT_AUTH_MNEMONIC + + viper.SetDefault(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, "6") + + // no default for CARTESI_PRT_AUTH_PRIVATE_KEY + viper.SetDefault(ADVANCER_INPUT_BATCH_SIZE, "500") viper.SetDefault(ADVANCER_POLLING_INTERVAL, "3") @@ -406,7 +422,10 @@ func LoadAdvancerConfig() (*AdvancerConfig, error) { // ClaimerConfig holds configuration values for the claimer service. type ClaimerConfig struct { - // The default block to be used by EVM Reader and Claimer when requesting new blocks. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // PRT checks transaction readiness separately at 'latest'. + // Use 'finalized' in production. The node does not detect or roll back reorgs. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. // One of 'latest', 'pending', 'safe', 'finalized' BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` @@ -416,8 +435,10 @@ type ClaimerConfig struct { // An unique identifier representing a blockchain network. BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` - // If set to true the node will send transactions using the legacy gas fee model - // (instead of EIP-1559). + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation or replace pending transactions. BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). @@ -431,6 +452,8 @@ type ClaimerConfig struct { DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` // HTTP address for Claimer's telemetry service. @@ -612,7 +635,10 @@ func LoadClaimerConfig() (*ClaimerConfig, error) { // EvmreaderConfig holds configuration values for the evmreader service. type EvmreaderConfig struct { - // The default block to be used by EVM Reader and Claimer when requesting new blocks. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // PRT checks transaction readiness separately at 'latest'. + // Use 'finalized' in production. The node does not detect or roll back reorgs. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. // One of 'latest', 'pending', 'safe', 'finalized' BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` @@ -632,9 +658,6 @@ type EvmreaderConfig struct { // for more information. DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` - // If set to false, the node will not read inputs from the blockchain. - FeatureInputReaderEnabled bool `mapstructure:"CARTESI_FEATURE_INPUT_READER_ENABLED"` - // HTTP address for EVM Reader's telemetry service. EvmReaderTelemetryAddress string `mapstructure:"CARTESI_EVM_READER_TELEMETRY_ADDRESS"` @@ -715,13 +738,6 @@ func LoadEvmreaderConfig() (*EvmreaderConfig, error) { return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the evmreader service: %w", err) } - cfg.FeatureInputReaderEnabled, err = GetFeatureInputReaderEnabled() - if err != nil && err != ErrNotDefined { - return nil, fmt.Errorf("failed to get CARTESI_FEATURE_INPUT_READER_ENABLED: %w", err) - } else if err == ErrNotDefined { - return nil, fmt.Errorf("CARTESI_FEATURE_INPUT_READER_ENABLED is required for the evmreader service: %w", err) - } - cfg.EvmReaderTelemetryAddress, err = GetEvmReaderTelemetryAddress() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_EVM_READER_TELEMETRY_ADDRESS: %w", err) @@ -918,7 +934,10 @@ func LoadJsonrpcConfig() (*JsonrpcConfig, error) { // NodeConfig holds configuration values for the node service. type NodeConfig struct { - // The default block to be used by EVM Reader and Claimer when requesting new blocks. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // PRT checks transaction readiness separately at 'latest'. + // Use 'finalized' in production. The node does not detect or roll back reorgs. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. // One of 'latest', 'pending', 'safe', 'finalized' BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` @@ -928,8 +947,10 @@ type NodeConfig struct { // An unique identifier representing a blockchain network. BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` - // If set to true the node will send transactions using the legacy gas fee model - // (instead of EIP-1559). + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation or replace pending transactions. BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). @@ -943,11 +964,10 @@ type NodeConfig struct { DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` - // If set to false, the node will not read inputs from the blockchain. - FeatureInputReaderEnabled bool `mapstructure:"CARTESI_FEATURE_INPUT_READER_ENABLED"` - // If set to false, the node will not start the inspect service. FeatureInspectEnabled bool `mapstructure:"CARTESI_FEATURE_INSPECT_ENABLED"` @@ -1113,13 +1133,6 @@ func LoadNodeConfig() (*NodeConfig, error) { return nil, fmt.Errorf("CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED is required for the node service: %w", err) } - cfg.FeatureInputReaderEnabled, err = GetFeatureInputReaderEnabled() - if err != nil && err != ErrNotDefined { - return nil, fmt.Errorf("failed to get CARTESI_FEATURE_INPUT_READER_ENABLED: %w", err) - } else if err == ErrNotDefined { - return nil, fmt.Errorf("CARTESI_FEATURE_INPUT_READER_ENABLED is required for the node service: %w", err) - } - cfg.FeatureInspectEnabled, err = GetFeatureInspectEnabled() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_FEATURE_INSPECT_ENABLED: %w", err) @@ -1322,7 +1335,10 @@ func LoadNodeConfig() (*NodeConfig, error) { // PrtConfig holds configuration values for the prt service. type PrtConfig struct { - // The default block to be used by EVM Reader and Claimer when requesting new blocks. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // PRT checks transaction readiness separately at 'latest'. + // Use 'finalized' in production. The node does not detect or roll back reorgs. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. // One of 'latest', 'pending', 'safe', 'finalized' BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` @@ -1332,8 +1348,10 @@ type PrtConfig struct { // An unique identifier representing a blockchain network. BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` - // If set to true the node will send transactions using the legacy gas fee model - // (instead of EIP-1559). + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation or replace pending transactions. BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). @@ -1347,6 +1365,8 @@ type PrtConfig struct { DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` // HTTP address for PRT's telemetry service. @@ -1655,7 +1675,6 @@ func (c *NodeConfig) ToEvmreaderConfig() *EvmreaderConfig { BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, BlockchainId: c.BlockchainId, DatabaseConnection: c.DatabaseConnection, - FeatureInputReaderEnabled: c.FeatureInputReaderEnabled, LogColor: c.LogColor, LogLevel: c.LogLevel, BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, @@ -1989,19 +2008,6 @@ func GetFeatureClaimSubmissionEnabled() (bool, error) { return notDefinedbool(), fmt.Errorf("%s: %w", FEATURE_CLAIM_SUBMISSION_ENABLED, ErrNotDefined) } -// GetFeatureInputReaderEnabled returns the value for the environment variable CARTESI_FEATURE_INPUT_READER_ENABLED. -func GetFeatureInputReaderEnabled() (bool, error) { - s := viper.GetString(FEATURE_INPUT_READER_ENABLED) - if s != "" { - v, err := toBool(s) - if err != nil { - return v, fmt.Errorf("failed to parse %s: %w", FEATURE_INPUT_READER_ENABLED, err) - } - return v, nil - } - return notDefinedbool(), fmt.Errorf("%s: %w", FEATURE_INPUT_READER_ENABLED, ErrNotDefined) -} - // GetFeatureInspectEnabled returns the value for the environment variable CARTESI_FEATURE_INSPECT_ENABLED. func GetFeatureInspectEnabled() (bool, error) { s := viper.GetString(FEATURE_INSPECT_ENABLED) @@ -2353,6 +2359,87 @@ func GetJsonrpcMachineLogLevel() (string, error) { return notDefinedstring(), fmt.Errorf("%s: %w", JSONRPC_MACHINE_LOG_LEVEL, ErrNotDefined) } +// GetPrtAuthAwsKmsKeyId returns the value for the environment variable CARTESI_PRT_AUTH_AWS_KMS_KEY_ID. +func GetPrtAuthAwsKmsKeyId() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_AWS_KMS_KEY_ID) + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_AWS_KMS_KEY_ID, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_AWS_KMS_KEY_ID, ErrNotDefined) +} + +// GetPrtAuthKind returns the value for the environment variable CARTESI_PRT_AUTH_KIND. +func GetPrtAuthKind() (AuthKind, error) { + s := viper.GetString(PRT_AUTH_KIND) + if s != "" { + v, err := toAuthKind(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_KIND, err) + } + return v, nil + } + return notDefinedAuthKind(), fmt.Errorf("%s: %w", PRT_AUTH_KIND, ErrNotDefined) +} + +// GetPrtAuthMnemonic returns the value for the environment variable CARTESI_PRT_AUTH_MNEMONIC. +func GetPrtAuthMnemonic() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_MNEMONIC) + if s == "" { + filename := viper.GetString(PRT_AUTH_MNEMONIC_FILE) + contents, err := os.ReadFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_MNEMONIC, ErrNotDefined) +} + +// GetPrtAuthMnemonicAccountIndex returns the value for the environment variable CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX. +func GetPrtAuthMnemonicAccountIndex() (RedactedUint, error) { + s := viper.GetString(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX) + if s != "" { + v, err := toRedactedUint(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, err) + } + return v, nil + } + return notDefinedRedactedUint(), fmt.Errorf("%s: %w", PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, ErrNotDefined) +} + +// GetPrtAuthPrivateKey returns the value for the environment variable CARTESI_PRT_AUTH_PRIVATE_KEY. +func GetPrtAuthPrivateKey() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_PRIVATE_KEY) + if s == "" { + filename := viper.GetString(PRT_AUTH_PRIVATE_KEY_FILE) + contents, err := os.ReadFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", PRT_AUTH_PRIVATE_KEY_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_PRIVATE_KEY, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_PRIVATE_KEY, ErrNotDefined) +} + // GetAdvancerInputBatchSize returns the value for the environment variable CARTESI_ADVANCER_INPUT_BATCH_SIZE. func GetAdvancerInputBatchSize() (uint64, error) { s := viper.GetString(ADVANCER_INPUT_BATCH_SIZE) diff --git a/internal/config/persistent.go b/internal/config/persistent.go new file mode 100644 index 000000000..10e83b9ce --- /dev/null +++ b/internal/config/persistent.go @@ -0,0 +1,130 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "encoding/json" + "fmt" + "math/big" + "slices" + + "github.com/cartesi/rollups-node/internal/model" +) + +// PersistentChainConfig identifies the chain and the observation policy used to +// write service state. A restart must not silently change either value. +type PersistentChainConfig struct { + DefaultBlock model.DefaultBlock + ChainID uint64 +} + +func (c PersistentChainConfig) Validate() error { + if c.ChainID == 0 { + return fmt.Errorf("ChainID must be greater than zero") + } + if !slices.Contains(model.DefaultBlockAllValues, c.DefaultBlock) { + return fmt.Errorf("invalid DefaultBlock %q", c.DefaultBlock) + } + return nil +} + +// UnmarshalJSON checks required fields. Plain struct decoding would accept a +// missing field or null as a valid zero value, including a disabled submitter. +func (c *PersistentChainConfig) UnmarshalJSON(data []byte) error { + var raw struct { + DefaultBlock *model.DefaultBlock + ChainID *uint64 + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.DefaultBlock == nil || raw.ChainID == nil { + return fmt.Errorf("persistent config requires non-null DefaultBlock and ChainID") + } + next := PersistentChainConfig{DefaultBlock: *raw.DefaultBlock, ChainID: *raw.ChainID} + if err := next.Validate(); err != nil { + return err + } + *c = next + return nil +} + +// CheckRequested rejects changes to a database's chain or observation policy. +// A stronger block policy does not repair data already read at a weaker policy. +func (c PersistentChainConfig) CheckRequested(requested PersistentChainConfig) error { + if err := requested.Validate(); err != nil { + return fmt.Errorf("invalid requested config: %w", err) + } + if err := c.Validate(); err != nil { + return fmt.Errorf("invalid saved config: %w", err) + } + if c.ChainID != requested.ChainID { + return fmt.Errorf("chain ID mismatch: database=%d, configured=%d; saved config was not changed", + c.ChainID, requested.ChainID) + } + if c.DefaultBlock != requested.DefaultBlock { + return fmt.Errorf("observation policy mismatch: database=%s, configured=%s; saved config was not changed", + c.DefaultBlock, requested.DefaultBlock) + } + return nil +} + +// PersistentSubmitterConfig adds a submission mode fixed when the service first +// starts. Mode transitions require claim and tournament reconciliation that is +// not supported yet. In particular, observing a Quorum claim is not a local vote. +type PersistentSubmitterConfig struct { + DefaultBlock model.DefaultBlock + ClaimSubmissionEnabled bool + ChainID uint64 +} + +func (c PersistentSubmitterConfig) chainConfig() PersistentChainConfig { + return PersistentChainConfig{DefaultBlock: c.DefaultBlock, ChainID: c.ChainID} +} + +func (c PersistentSubmitterConfig) Validate() error { + return c.chainConfig().Validate() +} + +func (c *PersistentSubmitterConfig) UnmarshalJSON(data []byte) error { + var chain PersistentChainConfig + if err := json.Unmarshal(data, &chain); err != nil { + return err + } + var raw struct{ ClaimSubmissionEnabled *bool } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.ClaimSubmissionEnabled == nil { + return fmt.Errorf("persistent config requires non-null ClaimSubmissionEnabled") + } + *c = PersistentSubmitterConfig{ + DefaultBlock: chain.DefaultBlock, ChainID: chain.ChainID, + ClaimSubmissionEnabled: *raw.ClaimSubmissionEnabled, + } + return nil +} + +func (c PersistentSubmitterConfig) CheckRequested(requested PersistentSubmitterConfig) error { + if err := c.chainConfig().CheckRequested(requested.chainConfig()); err != nil { + return err + } + if c.ClaimSubmissionEnabled != requested.ClaimSubmissionEnabled { + return fmt.Errorf("claim submission mode mismatch: database=%t, configured=%t; "+ + "mode changes on an existing database are not supported; saved config was not changed", + c.ClaimSubmissionEnabled, requested.ClaimSubmissionEnabled) + } + return nil +} + +// CheckNetworkChainID avoids truncating an invalid or oversized RPC chain ID. +func CheckNetworkChainID(network *big.Int, configured uint64) error { + if network == nil || network.Sign() <= 0 || !network.IsUint64() { + return fmt.Errorf("invalid network chain ID %v", network) + } + if network.Uint64() != configured { + return fmt.Errorf("chain ID mismatch: network=%s, configured=%d", network, configured) + } + return nil +} diff --git a/internal/config/persistent_test.go b/internal/config/persistent_test.go new file mode 100644 index 000000000..a25640629 --- /dev/null +++ b/internal/config/persistent_test.go @@ -0,0 +1,112 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "encoding/json" + "math" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" +) + +func TestPersistentConfigRejectsInvalidJSONFields(t *testing.T) { + for _, raw := range []string{ + `null`, `{}`, `[]`, ``, `{`, + `{"DefaultBlock":"FINALIZED"}`, + `{"ChainID":1}`, + `{"DefaultBlock":null,"ChainID":1}`, + `{"DefaultBlock":"FINALIZED","ChainID":null}`, + `{"DefaultBlock":"FINALIZED","ChainID":0}`, + `{"DefaultBlock":"FINALIZED","ChainID":-1}`, + `{"DefaultBlock":"FINALIZED","ChainID":18446744073709551616}`, + `{"DefaultBlock":"FINALIZED","ChainID":"1"}`, + `{"DefaultBlock":1,"ChainID":1}`, + `{"DefaultBlock":"finalized","ChainID":1}`, + `{"DefaultBlock":"INVALID","ChainID":1}`, + } { + t.Run(raw, func(t *testing.T) { + var chain PersistentChainConfig + require.Error(t, json.Unmarshal([]byte(raw), &chain)) + var submitter PersistentSubmitterConfig + require.Error(t, json.Unmarshal([]byte(raw), &submitter)) + }) + } + for _, mode := range []string{"", `,"ClaimSubmissionEnabled":null`, `,"ClaimSubmissionEnabled":"false"`} { + t.Run("mode"+mode, func(t *testing.T) { + raw := []byte(`{"DefaultBlock":"FINALIZED","ChainID":1` + mode + `}`) + var submitter PersistentSubmitterConfig + require.Error(t, json.Unmarshal(raw, &submitter)) + }) + } +} + +func TestPersistentConfigRoundTrip(t *testing.T) { + for _, policy := range model.DefaultBlockAllValues { + for _, enabled := range []bool{false, true} { + want := PersistentSubmitterConfig{ + DefaultBlock: policy, ClaimSubmissionEnabled: enabled, ChainID: math.MaxUint64, + } + raw, err := json.Marshal(want) + require.NoError(t, err) + var got PersistentSubmitterConfig + require.NoError(t, json.Unmarshal(raw, &got)) + require.Equal(t, want, got) + require.NoError(t, got.CheckRequested(want)) + var chain PersistentChainConfig + require.NoError(t, json.Unmarshal(raw, &chain)) + require.NoError(t, chain.CheckRequested(want.chainConfig())) + } + } +} + +func TestPersistentConfigAcceptsUnknownFields(t *testing.T) { + t.Parallel() + raw := []byte(`{"DefaultBlock":"FINALIZED","ChainID":42,"ClaimSubmissionEnabled":true,"ExtraSetting":{"enabled":false}}`) + var chain PersistentChainConfig + require.NoError(t, json.Unmarshal(raw, &chain)) + require.Equal(t, PersistentChainConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42}, chain) + var submitter PersistentSubmitterConfig + require.NoError(t, json.Unmarshal(raw, &submitter)) + require.Equal(t, PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: true, + }, submitter) +} + +func TestPersistentConfigRejectsRequestedChanges(t *testing.T) { + saved := PersistentSubmitterConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1} + for _, test := range []struct { + name string + change func(*PersistentSubmitterConfig) + want string + }{ + {"chain", func(c *PersistentSubmitterConfig) { c.ChainID = 2 }, "database=1, configured=2"}, + {"policy", func(c *PersistentSubmitterConfig) { c.DefaultBlock = model.DefaultBlock_Latest }, + "database=FINALIZED, configured=LATEST"}, + {"mode", func(c *PersistentSubmitterConfig) { c.ClaimSubmissionEnabled = true }, "database=false, configured=true"}, + {"invalid chain", func(c *PersistentSubmitterConfig) { c.ChainID = 0 }, "invalid requested config"}, + {"invalid policy", func(c *PersistentSubmitterConfig) { c.DefaultBlock = "" }, "invalid requested config"}, + } { + t.Run(test.name, func(t *testing.T) { + requested := saved + test.change(&requested) + require.ErrorContains(t, saved.CheckRequested(requested), test.want) + }) + } + requested := saved + saved.ClaimSubmissionEnabled = true + require.ErrorContains(t, saved.CheckRequested(requested), "database=true, configured=false") + saved.ChainID = 0 + require.ErrorContains(t, saved.CheckRequested(requested), "invalid saved config") +} + +func TestCheckNetworkChainID(t *testing.T) { + for _, invalid := range []*big.Int{nil, big.NewInt(0), big.NewInt(-1), new(big.Int).Lsh(big.NewInt(1), 64)} { + require.ErrorContains(t, CheckNetworkChainID(invalid, 1), "invalid network chain ID") + } + require.ErrorContains(t, CheckNetworkChainID(big.NewInt(2), 1), "network=2, configured=1") + require.NoError(t, CheckNetworkChainID(new(big.Int).SetUint64(math.MaxUint64), math.MaxUint64)) +} diff --git a/internal/errutil/cancellation.go b/internal/errutil/cancellation.go new file mode 100644 index 000000000..f1e90416b --- /dev/null +++ b/internal/errutil/cancellation.go @@ -0,0 +1,35 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package errutil provides error classification shared by node components. +package errutil + +import ( + "context" + "errors" +) + +// IsOnlyCancellation reports whether every cause of err is context.Canceled. +// Unlike errors.Is, it does not hide other causes in a joined error. Nil is not +// a cancellation. Errors without children are checked with errors.Is. Callers +// must separately establish that the service is shutting down. +func IsOnlyCancellation(err error) bool { + switch cause := err.(type) { + case interface{ Unwrap() []error }: + children := cause.Unwrap() + if len(children) == 0 { + return errors.Is(err, context.Canceled) + } + for _, child := range children { + if !IsOnlyCancellation(child) { + return false + } + } + return true + case interface{ Unwrap() error }: + if child := cause.Unwrap(); child != nil { + return IsOnlyCancellation(child) + } + } + return errors.Is(err, context.Canceled) +} diff --git a/internal/errutil/cancellation_test.go b/internal/errutil/cancellation_test.go new file mode 100644 index 000000000..93be1aa10 --- /dev/null +++ b/internal/errutil/cancellation_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package errutil + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsOnlyCancellation(t *testing.T) { + other := errors.New("database unavailable") + for _, test := range []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"canceled", context.Canceled, true}, + {"wrapped", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined", errors.Join(context.Canceled, fmt.Errorf("write: %w", context.Canceled)), true}, + {"multiple wrapped", fmt.Errorf("queries: %w; %w", context.Canceled, context.Canceled), true}, + {"nested", fmt.Errorf("query: %w", errors.Join(context.Canceled, context.Canceled)), true}, + {"deadline", context.DeadlineExceeded, false}, + {"other", other, false}, + {"mixed", errors.Join(context.Canceled, other), false}, + {"mixed reversed", errors.Join(other, context.Canceled), false}, + {"mixed deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"nested mixed", fmt.Errorf("query: %w", errors.Join(context.Canceled, other)), false}, + {"empty causes", &cancellationTestError{}, false}, + {"nil cause", &cancellationTestError{causes: []error{nil}}, false}, + {"nil wrapped cause", fmt.Errorf("query: %w", nil), false}, + {"custom cancellation leaf", &cancellationTestError{canceled: true}, true}, + {"custom cancellation with failure", &cancellationTestError{canceled: true, causes: []error{other}}, false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, IsOnlyCancellation(test.err)) + }) + } +} + +type cancellationTestError struct { + causes []error + canceled bool +} + +func (*cancellationTestError) Error() string { return "test dependency error" } +func (e *cancellationTestError) Unwrap() []error { return e.causes } +func (e *cancellationTestError) Is(target error) bool { + return e.canceled && target == context.Canceled +} diff --git a/internal/evmreader/adapter_resolver.go b/internal/evmreader/adapter_resolver.go index c806d20f0..b70f8d57c 100644 --- a/internal/evmreader/adapter_resolver.go +++ b/internal/evmreader/adapter_resolver.go @@ -20,7 +20,6 @@ type cachedAdapters struct { consensusAddr common.Address inputBoxAddr common.Address isDaveConsensus bool - hasInputBoxDA bool } type applicationAdapterResolver struct { @@ -101,7 +100,6 @@ func (r *applicationAdapterResolver) getOrCreateAdapters(app *Application) (cach consensusAddr: app.IConsensusAddress, inputBoxAddr: app.IInputBoxAddress, isDaveConsensus: app.IsDaveConsensus(), - hasInputBoxDA: app.HasDataAvailabilitySelector(DataAvailability_InputBox), } r.cache[addr] = cached return cached, true @@ -110,6 +108,5 @@ func (r *applicationAdapterResolver) getOrCreateAdapters(app *Application) (cach func adaptersAreStale(cached cachedAdapters, app *Application) bool { return cached.consensusAddr != app.IConsensusAddress || cached.inputBoxAddr != app.IInputBoxAddress || - cached.isDaveConsensus != app.IsDaveConsensus() || - cached.hasInputBoxDA != app.HasDataAvailabilitySelector(DataAvailability_InputBox) + cached.isDaveConsensus != app.IsDaveConsensus() } diff --git a/internal/evmreader/adapter_resolver_test.go b/internal/evmreader/adapter_resolver_test.go index fc8741a30..47ac2f55d 100644 --- a/internal/evmreader/adapter_resolver_test.go +++ b/internal/evmreader/adapter_resolver_test.go @@ -56,12 +56,6 @@ func TestApplicationAdapterResolver_InvalidatesStaleAdapters(t *testing.T) { app.ConsensusType = Consensus_PRT }, }, - { - name: "InputBox data availability changed", - change: func(app *Application) { - app.DataAvailability = []byte{0xff} - }, - }, } for _, tt := range tests { @@ -155,7 +149,6 @@ func resolverApp(id int64) *Application { IApplicationAddress: common.BigToAddress(big.NewInt(id)), IConsensusAddress: common.HexToAddress("0x00000000000000000000000000000000000000c1"), IInputBoxAddress: common.HexToAddress("0x00000000000000000000000000000000000000b1"), - DataAvailability: DataAvailability_InputBox[:], ConsensusType: Consensus_Authority, Enabled: true, Status: ApplicationStatus_OK, diff --git a/internal/evmreader/block_scan_plan.go b/internal/evmreader/block_scan_plan.go index d88f8162f..8afec5390 100644 --- a/internal/evmreader/block_scan_plan.go +++ b/internal/evmreader/block_scan_plan.go @@ -3,8 +3,6 @@ package evmreader -import . "github.com/cartesi/rollups-node/internal/model" - type blockScanPlan struct { iConsensusInputTargets []appContracts daveEpochTargets []appContracts @@ -32,8 +30,7 @@ func buildBlockScanPlan(apps []appContracts) blockScanPlan { continue } - if application.LastInputCheckBlock < application.ForecloseBlock && - application.HasDataAvailabilitySelector(DataAvailability_InputBox) { + if application.LastInputCheckBlock < application.ForecloseBlock { plan.iConsensusInputTargets = append(plan.iConsensusInputTargets, app) } continue diff --git a/internal/evmreader/block_scan_plan_test.go b/internal/evmreader/block_scan_plan_test.go index a46c30156..9e468b2e3 100644 --- a/internal/evmreader/block_scan_plan_test.go +++ b/internal/evmreader/block_scan_plan_test.go @@ -5,9 +5,11 @@ package evmreader import ( "fmt" + "math/big" "testing" . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" ) @@ -26,14 +28,6 @@ func TestBuildBlockScanPlan_RoutesScannerTargets(t *testing.T) { wantIConsensusInput: []int64{1}, wantOutput: []int64{1}, }, - { - name: "OK IConsensus app without InputBox data availability remains an input target", - apps: []appContracts{planApp(2, planAppConfig{ - withoutInputBoxDA: true, - })}, - wantIConsensusInput: []int64{2}, - wantOutput: []int64{2}, - }, { name: "OK DaveConsensus app is executable", apps: []appContracts{planApp(3, planAppConfig{ @@ -66,16 +60,6 @@ func TestBuildBlockScanPlan_RoutesScannerTargets(t *testing.T) { wantOutput: []int64{5}, wantPostForeclosure: []int64{5}, }, - { - name: "foreclosed IConsensus app without InputBox data availability skips input catch-up", - apps: []appContracts{planApp(6, planAppConfig{ - withoutInputBoxDA: true, - forecloseBlock: 100, - lastInputCheckBlock: 99, - })}, - wantOutput: []int64{6}, - wantPostForeclosure: []int64{6}, - }, { name: "foreclosed DaveConsensus app with epoch cursor behind gets sealed-epoch catch-up", apps: []appContracts{planApp(7, planAppConfig{ @@ -167,7 +151,6 @@ func requireNoDuplicatePlanTargets(plan blockScanPlan) error { type planAppConfig struct { status ApplicationStatus consensus Consensus - withoutInputBoxDA bool forecloseBlock uint64 lastInputCheckBlock uint64 lastEpochCheckBlock uint64 @@ -182,17 +165,12 @@ func planApp(id int64, cfg planAppConfig) appContracts { if consensus == "" { consensus = Consensus_Authority } - dataAvailability := DataAvailability_InputBox[:] - if cfg.withoutInputBoxDA { - dataAvailability = []byte{0xff} - } - return appContracts{application: &Application{ ID: id, + IInputBoxAddress: common.BigToAddress(big.NewInt(id)), Enabled: true, Status: status, ConsensusType: consensus, - DataAvailability: dataAvailability, ForecloseBlock: cfg.forecloseBlock, LastInputCheckBlock: cfg.lastInputCheckBlock, LastEpochCheckBlock: cfg.lastEpochCheckBlock, diff --git a/internal/evmreader/dave_consensus_test.go b/internal/evmreader/dave_consensus_test.go index e15de64b6..91b05b93d 100644 --- a/internal/evmreader/dave_consensus_test.go +++ b/internal/evmreader/dave_consensus_test.go @@ -15,23 +15,26 @@ import ( "github.com/stretchr/testify/mock" ) -// makeSealedEpochResult constructs the anonymous struct returned by GetCurrentSealedEpoch. +const daveTestApplicationName = "test-app" + +// Keep scan boundaries, InputBox identity, and application status in each test. +func newDaveAppContracts(inputSource InputSourceAdapter, daveConsensus DaveConsensusAdapter) appContracts { + return appContracts{ + application: &Application{ + ID: 1, Name: daveTestApplicationName, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + }, + inputSource: inputSource, daveConsensus: daveConsensus, + } +} + +// makeSealedEpochResult constructs the node-owned GetCurrentSealedEpoch result. func makeSealedEpochResult( epochNum int64, lowerBound, upperBound uint64, tournament common.Address, -) struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address -} { - return struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address - }{ +) DaveCurrentSealedEpoch { + return DaveCurrentSealedEpoch{ EpochNumber: big.NewInt(epochNum), InputIndexLowerBound: new(big.Int).SetUint64(lowerBound), InputIndexUpperBound: new(big.Int).SetUint64(upperBound), @@ -72,19 +75,10 @@ func (s *SealedEpochsSuite) TestSealedEpochsEndToEndTwoTransitions() { tournamentAddr0 := common.HexToAddress("0xA000") tournamentAddr1 := common.HexToAddress("0xA001") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: inputBoxBlock, - LastEpochCheckBlock: searchStart, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = inputBoxBlock + app.application.LastEpochCheckBlock = searchStart // Oracle: GetCurrentSealedEpoch returns epoch number based on block s.dave.On("GetCurrentSealedEpoch", @@ -119,10 +113,6 @@ func (s *SealedEpochsSuite) TestSealedEpochsEndToEndTwoTransitions() { makeSealedEpochEvent(1, 0, 0, sealBlock1, tournamentAddr1), }, nil) - // No previous sealed epochs in DB - s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). - Return(nil, nil) - // Epoch 0: first lookup → nil (new epoch) s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). Return(nil, nil).Once() @@ -190,18 +180,9 @@ func (s *SealedEpochsSuite) TestOpenEpochHappyPathCreatesNewEpoch() { mostRecentBlock uint64 = 200 ) - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 // Last sealed epoch s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). @@ -277,18 +258,9 @@ func (s *SealedEpochsSuite) TestOpenEpochHappyPathCreatesNewEpoch() { func (s *SealedEpochsSuite) TestOpenEpochExistingEpochAccumulatesInputs() { const mostRecentBlock uint64 = 300 - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 // Last sealed epoch s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). @@ -367,19 +339,10 @@ func (s *SealedEpochsSuite) TestOpenEpochScansBoundaryBlockAfterIntraTickCursorA mostRecentBlock uint64 = 200 ) - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - LastInputCheckBlock: previousInputCursor, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastInputCheckBlock = previousInputCursor s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). Return(&Epoch{ @@ -395,7 +358,7 @@ func (s *SealedEpochsSuite) TestOpenEpochScansBoundaryBlockAfterIntraTickCursorA s.repository.On("GetEventLastCheckBlock", mock.Anything, int64(1), MonitoredEvent_InputAdded, - ).Return(mostRecentBlock, nil) + ).Return(mostRecentBlock-1, nil) s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything). Return(uint64(4), nil) @@ -449,19 +412,10 @@ func (s *SealedEpochsSuite) TestMultipleSealedEpochsInOneBlock() { tournamentAddr0 := common.HexToAddress("0xA000") tournamentAddr1 := common.HexToAddress("0xA001") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - LastEpochCheckBlock: 50, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastEpochCheckBlock = 50 // Oracle: before sealBlock → -1, from sealBlock → 1 (both sealed at same block) s.dave.On("GetCurrentSealedEpoch", @@ -484,10 +438,6 @@ func (s *SealedEpochsSuite) TestMultipleSealedEpochsInOneBlock() { makeSealedEpochEvent(1, 0, 0, sealBlock, tournamentAddr1), }, nil) - // No previous sealed epochs - s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). - Return(nil, nil) - // Epoch 0: self-lookup → nil (new) s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). Return(nil, nil).Once() @@ -538,46 +488,23 @@ func (s *SealedEpochsSuite) TestMultipleSealedEpochsInOneBlock() { s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 2) } -// --- Test 5: initializeNewApplicationSealedEpochSync sets checkpoint --- -// On first run, the deployment block is fetched from DaveConsensus and the -// LastEpochCheckBlock is set to deploymentBlock - 1. -func (s *SealedEpochsSuite) TestInitializeSealedEpochSyncSetsCheckpoint() { - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - }, - daveConsensus: s.dave, - } +// The deployment floor does not certify a successful scan or change its cursor. +func (s *SealedEpochsSuite) TestInitialSealedEpochSearchBlockDoesNotSetCheckpoint() { + app := newDaveAppContracts(nil, s.dave) // DaveConsensus deployed at block 50 s.dave.On("GetDeploymentBlockNumber", mock.Anything). Return(big.NewInt(50), nil) - s.repository.On("UpdateEventLastCheckBlock", - mock.Anything, []int64{int64(1)}, MonitoredEvent_EpochSealed, uint64(49), - ).Return(nil) - - err := s.evmReader.initializeNewApplicationSealedEpochSync(s.ctx, &app, 200) + block, err := s.evmReader.initialSealedEpochSearchBlock(s.ctx, app, 200) s.Require().NoError(err) - - // LastEpochCheckBlock set to deploymentBlock - 1 - s.Require().Equal(uint64(49), app.application.LastEpochCheckBlock) - s.repository.AssertExpectations(s.T()) + s.Equal(uint64(50), block) + s.Zero(app.application.LastEpochCheckBlock) + s.Empty(s.repository.Calls) } func (s *SealedEpochsSuite) TestInitializeSealedEpochSyncSkipsBeforeConsensusDeployment() { - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - }, - daveConsensus: s.dave, - } + app := newDaveAppContracts(nil, s.dave) s.dave.On("GetDeploymentBlockNumber", mock.MatchedBy(func(opts *bind.CallOpts) bool { @@ -586,7 +513,8 @@ func (s *SealedEpochsSuite) TestInitializeSealedEpochSyncSkipsBeforeConsensusDep ).Return(new(big.Int), bind.ErrNoCode).Once() err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, 90) - s.Require().NoError(err) + s.Require().ErrorIs(err, errContractNotDeployedAtBlock) + s.Require().ErrorIs(err, bind.ErrNoCode) s.Require().Zero(app.application.LastEpochCheckBlock) s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) } diff --git a/internal/evmreader/dave_readiness_test.go b/internal/evmreader/dave_readiness_test.go new file mode 100644 index 000000000..08aae4a85 --- /dev/null +++ b/internal/evmreader/dave_readiness_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveReadinessDistinguishesDeploymentWaitFromScanFailure(t *testing.T) { + const head uint64 = 100 + for _, test := range []struct { + name string + deploymentWait bool + initialStatus model.ApplicationStatus + statusWriteErr error + }{ + {name: "deployment not yet visible", deploymentWait: true}, + {name: "missing sealed epoch"}, + {name: "existing integrity failure", initialStatus: model.ApplicationStatus_Diverged}, + {name: "corruption status write fails", statusWriteErr: errors.New("database unavailable")}, + {name: "execution terminal escalation fails", initialStatus: model.ApplicationStatus_MachineHalted, + statusWriteErr: errors.New("database unavailable")}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + client := newMockEthClient() + app := &model.Application{ + ID: 1, Name: "dave-readiness", Enabled: true, Status: model.ApplicationStatus_OK, + ConsensusType: model.Consensus_PRT, IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, + LastEpochCheckBlock: head, LastInputCheckBlock: head - 1, + LastOutputCheckBlock: head, LastForecloseCheckBlock: head, + } + if test.initialStatus != "" { + app.Status = test.initialStatus + } + client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil) + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + adapters := newMockAdapterFactory() + adapters.On("CreateAdapters", app). + Return(newMockApplicationContract(), newMockInputBox(), dave, nil).Once() + if test.deploymentWait { + app.LastEpochCheckBlock = 0 + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int), bind.ErrNoCode) + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil) + if app.Status != model.ApplicationStatus_Diverged { + statusWrite := repo.On("UpdateApplicationStatus", t.Context(), app.ID, + model.ApplicationStatus_Corrupted, mock.Anything).Return(test.statusWriteErr) + if test.statusWriteErr == nil { + statusWrite.Once() + } + } + } + reader := &Service{client: client, repository: repo, + defaultBlock: model.DefaultBlock_Finalized, readyMaxStaleness: time.Hour} + reader.Logger = testLogger(t) + reader.resolver = newApplicationAdapterResolver(reader.Logger, adapters) + + for cycle := 1; cycle <= maxConsecutiveScanFailures+1; cycle++ { + reschedule, err := reader.Tick(t.Context()) + require.NoError(t, err) + require.False(t, reschedule) + require.Equal(t, test.statusWriteErr == nil || cycle < maxConsecutiveScanFailures, reader.Ready()) + } + if test.deploymentWait { + require.Zero(t, reader.consecutiveScanFailures.Load()) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "GetLastNonOpenEpoch", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + if test.statusWriteErr == nil { + require.Zero(t, reader.consecutiveScanFailures.Load()) + if test.initialStatus == model.ApplicationStatus_Diverged { + require.Equal(t, test.initialStatus, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) + } + } else { + require.EqualValues(t, maxConsecutiveScanFailures, reader.consecutiveScanFailures.Load()) + if test.initialStatus != "" { + require.Equal(t, test.initialStatus, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + } + } + repo.AssertExpectations(t) + dave.AssertExpectations(t) + client.AssertExpectations(t) + adapters.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/daveconsensus_adapter.go b/internal/evmreader/daveconsensus_adapter.go index c013b85b6..22628a0f0 100644 --- a/internal/evmreader/daveconsensus_adapter.go +++ b/internal/evmreader/daveconsensus_adapter.go @@ -20,18 +20,21 @@ import ( // Interface for DaveConsensus reading type DaveConsensusAdapter interface { GetInputBox(opts *bind.CallOpts) (common.Address, error) - GetCurrentSealedEpoch(opts *bind.CallOpts) (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address - }, error) + GetCurrentSealedEpoch(opts *bind.CallOpts) (DaveCurrentSealedEpoch, error) GetApplicationContract(opts *bind.CallOpts) (common.Address, error) GetTournamentFactory(opts *bind.CallOpts) (common.Address, error) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) RetrieveSealedEpochs(opts *bind.FilterOpts) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) } +// DaveCurrentSealedEpoch contains the fields used to observe sealed epochs. +type DaveCurrentSealedEpoch struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address +} + // DaveConsensus Wrapper type DaveConsensusAdapterImpl struct { daveConsensus *idaveconsensus.IDaveConsensus @@ -88,13 +91,17 @@ func (d *DaveConsensusAdapterImpl) GetInputBox(opts *bind.CallOpts) (common.Addr return d.daveConsensus.GetInputBox(opts) } -func (d *DaveConsensusAdapterImpl) GetCurrentSealedEpoch(opts *bind.CallOpts) (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address -}, error) { - return d.daveConsensus.GetCurrentSealedEpoch(opts) +func (d *DaveConsensusAdapterImpl) GetCurrentSealedEpoch(opts *bind.CallOpts) (DaveCurrentSealedEpoch, error) { + result, err := d.daveConsensus.GetCurrentSealedEpoch(opts) + if err != nil { + return DaveCurrentSealedEpoch{}, err + } + return DaveCurrentSealedEpoch{ + EpochNumber: result.EpochNumber, + InputIndexLowerBound: result.InputIndexLowerBound, + InputIndexUpperBound: result.InputIndexUpperBound, + Tournament: result.Tournament, + }, nil } func (d *DaveConsensusAdapterImpl) GetApplicationContract(opts *bind.CallOpts) (common.Address, error) { diff --git a/internal/evmreader/edge_cases_test.go b/internal/evmreader/edge_cases_test.go index 0f604f374..b8dbfdaa2 100644 --- a/internal/evmreader/edge_cases_test.go +++ b/internal/evmreader/edge_cases_test.go @@ -122,7 +122,6 @@ func (s *EvmReaderSuite) sameTransactionScanApp() (appContracts, common.Hash) { Name: "test-app", IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 10, @@ -461,7 +460,6 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { s.client.EnqueueNewHead(101).Once() called := newCallNotification(s.client.EnqueueNewHead(102)) - s.evmReader.inputReaderEnabled = false s.evmReader.defaultBlock = DefaultBlock_Latest addr := common.HexToAddress("0x4444444444444444444444444444444444444444") @@ -477,6 +475,7 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { IConsensusAddress: consensusAddr1, IInputBoxAddress: inputBoxAddr, LastOutputCheckBlock: 999, // > header block → skip output check + LastInputCheckBlock: 999, // input scan is already current for this adapter test LastForecloseCheckBlock: 999, }}, uint64(1), nil).Once() // Header 2: consensus address changed → cache invalidation @@ -487,6 +486,7 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { IConsensusAddress: consensusAddr2, IInputBoxAddress: inputBoxAddr, LastOutputCheckBlock: 999, + LastInputCheckBlock: 999, LastForecloseCheckBlock: 999, }}, uint64(1), nil).Once() // Header 3: same config as header 2 → cache hit @@ -497,6 +497,7 @@ func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { IConsensusAddress: consensusAddr2, IInputBoxAddress: inputBoxAddr, LastOutputCheckBlock: 999, + LastInputCheckBlock: 999, LastForecloseCheckBlock: 999, }}, uint64(1), nil).Once() // Catch-all for sentinel header diff --git a/internal/evmreader/error_paths_test.go b/internal/evmreader/error_paths_test.go index 291239548..d2ffd85bf 100644 --- a/internal/evmreader/error_paths_test.go +++ b/internal/evmreader/error_paths_test.go @@ -29,7 +29,7 @@ func (s *EvmReaderSuite) TestCreateEpochsAndInputsErrorDoesNotAdvanceCheckpoint( app := &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, EpochLength: 10, @@ -62,8 +62,8 @@ func (s *EvmReaderSuite) TestCreateEpochsAndInputsErrorDoesNotAdvanceCheckpoint( s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) - require.ErrorIs(err, errScanIncomplete) // per-app failure is reported after scanning + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) + require.ErrorContains(err, "store inputs and epochs: database connection lost") // CreateEpochsAndInputs was attempted repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) @@ -78,18 +78,9 @@ func (s *SealedEpochsSuite) TestSealedEpochInputCountMismatchReturnsError() { const sealBlock uint64 = 200 tournamentAddr := common.HexToAddress("0xAAAA") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 // Sealed event expects 2 inputs (indices 3 to 5), but only 1 exists on-chain. event := &idaveconsensus.IDaveConsensusEpochSealed{ @@ -151,17 +142,8 @@ func (s *SealedEpochsSuite) TestSealedEpochInputCountMismatchReturnsError() { func (s *SealedEpochsSuite) TestSealedEpochFirstBlockMismatchReturnsError() { tournamentAddr := common.HexToAddress("0xBBBB") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr // Epoch 1 sealed. Previous epoch (0) has LastBlock=100 → firstBlock should be 100. event := &idaveconsensus.IDaveConsensusEpochSealed{ @@ -203,7 +185,7 @@ func (s *SealedEpochsSuite) TestOpenEpochWithNoNonOpenEpochSetsCorrupted() { app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, }, inputSource: s.inputBox, @@ -232,7 +214,7 @@ func (s *SealedEpochsSuite) TestSealedEpoch0WithNoInputBoxBlockReturnsError() { app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, IInputBoxBlock: 0, // misconfigured @@ -278,7 +260,7 @@ func (s *EvmReaderSuite) TestUpdateOutputsExecutionErrorDoesNotAdvanceCheckpoint app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, }, applicationContract: appContract, @@ -308,10 +290,9 @@ func (s *EvmReaderSuite) TestUpdateOutputsExecutionErrorDoesNotAdvanceCheckpoint // misconfiguration), scanIConsensusInputs must not write to the database. func (s *EvmReaderSuite) TestBlockRegressionDoesNotWriteToDb() { app := &Application{ - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, LastInputCheckBlock: 100, } @@ -337,16 +318,8 @@ func (s *EvmReaderSuite) TestBlockRegressionDoesNotWriteToDb() { // When mostRecentBlockNumber < LastEpochCheckBlock, processApplicationSealedEpochs // must skip processing and not write to the database. func (s *SealedEpochsSuite) TestSealedEpochBlockRegressionDoesNotWriteToDb() { - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - LastEpochCheckBlock: 200, - }, - daveConsensus: s.dave, - } + app := newDaveAppContracts(nil, s.dave) + app.application.LastEpochCheckBlock = 200 // mostRecentBlockNumber (150) < LastEpochCheckBlock (200) → regression err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, 150) @@ -365,7 +338,7 @@ func (s *EvmReaderSuite) TestOutputBlockRegressionDoesNotWriteToDb() { app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, LastOutputCheckBlock: 100, }, @@ -395,7 +368,7 @@ func (s *EvmReaderSuite) TestOutputExecutionSyncSkipsBeforeApplicationDeployment app := appContracts{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: app1Addr, }, applicationContract: appContract, @@ -421,10 +394,9 @@ func (s *EvmReaderSuite) TestIConsensusInputCountMismatchSkipsApp() { inputSrc := &MockInputBox{} app := &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, LastInputCheckBlock: 100, } @@ -452,8 +424,8 @@ func (s *EvmReaderSuite) TestIConsensusInputCountMismatchSkipsApp() { Return(uint64(0), nil) s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) - s.Require().ErrorIs(err, errScanIncomplete) // per-app failure is reported after scanning + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) + s.Require().ErrorContains(err, "input count mismatch") // App was skipped: counter says 2 new, but only 1 fetched → no DB writes repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) @@ -471,10 +443,9 @@ func (s *EvmReaderSuite) TestIConsensusInputCountValidationUsesObservedEndCount( inputSrc := &MockInputBox{} app := &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, LastInputCheckBlock: 100, } @@ -513,10 +484,10 @@ func (s *EvmReaderSuite) TestIConsensusInputCountValidationUsesObservedEndCount( }).Return(nil).Once() s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 105, []appContracts{{ + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 105, appContracts{ application: app, inputSource: inputSrc, - }}) + }) s.Require().NoError(err) repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) @@ -539,10 +510,9 @@ func (s *EvmReaderSuite) TestEpochLengthZeroSetsAppCorrupted() { apps := []appContracts{{ application: &Application{ ID: 1, - Name: "test-app", + Name: daveTestApplicationName, IApplicationAddress: addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 0, // will trigger corrupted LastInputCheckBlock: 100, }, @@ -550,23 +520,19 @@ func (s *EvmReaderSuite) TestEpochLengthZeroSetsAppCorrupted() { }} repo := newMockRepository() - repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). - Return(uint64(0), nil) repo.On("UpdateApplicationStatus", mock.Anything, int64(1), ApplicationStatus_Corrupted, mock.Anything, ).Return(nil) - repo.On("UpdateEventLastCheckBlock", - mock.Anything, mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil) s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) s.Require().NoError(err) // App must be set inoperable repo.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 1) // No epochs or inputs should be stored repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) } // --- Sealed epoch CreateEpochsAndInputs failure prevents checkpoint advance --- @@ -580,19 +546,10 @@ func (s *SealedEpochsSuite) TestSealedEpochDBFailurePreventsCheckpointAdvance() ) tournamentAddr := common.HexToAddress("0xEEEE") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - LastEpochCheckBlock: 50, - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastEpochCheckBlock = 50 // Oracle: before sealBlock → -1, from sealBlock → 0 s.dave.On("GetCurrentSealedEpoch", @@ -613,10 +570,6 @@ func (s *SealedEpochsSuite) TestSealedEpochDBFailurePreventsCheckpointAdvance() makeSealedEpochEvent(0, 0, 0, sealBlock, tournamentAddr), }, nil) - // No previous sealed epochs - s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). - Return(nil, nil) - // Epoch 0 doesn't exist s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). Return(nil, nil) diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index 8eaf35863..b15888398 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log/slog" "math/big" "time" @@ -16,6 +17,7 @@ import ( "github.com/ethereum/go-ethereum/rpc" "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/errutil" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -47,11 +49,14 @@ type EvmReaderRepository interface { blockNumber uint64, ) error GetNumberOfWithdrawals(ctx context.Context, appID int64) (uint64, error) - ListApplications(ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool) ([]*Application, uint64, error) + ListApplications( + ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool, + ) ([]*Application, uint64, error) UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error UpdateEventLastCheckBlock(ctx context.Context, appIDs []int64, event MonitoredEvent, blockNumber uint64) error GetEventLastCheckBlock(ctx context.Context, appID int64, event MonitoredEvent) (uint64, error) + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) @@ -97,8 +102,13 @@ func (r *Service) setApplicationDiverged(ctx context.Context, app *Application, return appstatus.SetDivergedf(ctx, r.Logger, r.repository, app, reasonFmt, args...) } +// The status helper logs its reason and any failed status write. Outer scan +// loops can consume this marker without repeating those logs or continuing work. +var errApplicationStatusReported = errors.New("application status already reported") + func (r *Service) setApplicationCorrupted(ctx context.Context, app *Application, reasonFmt string, args ...any) error { - return appstatus.SetCorruptedf(ctx, r.Logger, r.repository, app, reasonFmt, args...) + err := appstatus.SetCorruptedf(ctx, r.Logger, r.repository, app, reasonFmt, args...) + return fmt.Errorf("%w: %w", errApplicationStatusReported, err) } func (r *Service) Tick(ctx context.Context) (bool, error) { @@ -142,8 +152,6 @@ func (r *Service) Ready() bool { // A few failed cycles tolerate transient errors without hiding persistent stalls. const maxConsecutiveScanFailures = 3 -var errScanIncomplete = errors.New("one or more applications failed to scan or persist progress") - func (r *Service) processBlockHead( ctx context.Context, blockNumber uint64, @@ -152,7 +160,11 @@ func (r *Service) processBlockHead( r.Logger.Debug("Retrieving enabled applications") observableApps, _, err := listEnabledApplications(ctx, r.repository) if err != nil { - r.Logger.Error("Error retrieving L1-observable applications", "error", err) + level := slog.LevelError + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Error retrieving L1-observable applications", "error", err) return false } @@ -181,20 +193,37 @@ func (r *Service) processBlockHead( // Known input corruption with a persisted integrity status is application-local // degradation; observation still runs on later ticks. An idle scan is healthy. // Always run remaining scanners so one application's stall cannot block others. +// Stop dispatching new work when the service context ends; the active scanner +// has already handled its operation's error. func (r *Service) runBlockScanners( ctx context.Context, apps []appContracts, blockNumber uint64, ) bool { + if ctx.Err() != nil { + return false + } // Detect foreclosure first so later scanners use the marker observed in this same tick. success := r.checkForForeclosure(ctx, apps, blockNumber) + if ctx.Err() != nil { + return false + } plan := buildBlockScanPlan(apps) success = r.scanDaveConsensusEpochsAndInputs(ctx, plan.daveEpochTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } success = r.scanIConsensusInputs(ctx, plan.iConsensusInputTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } success = r.checkForOutputExecution(ctx, plan.outputTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } // Post-foreclosure observation dispatches to drive-prove discovery or withdrawal indexing. success = r.checkPostForeclosure(ctx, plan.postForeclosureTargets, blockNumber) && success @@ -248,7 +277,9 @@ type DefaultAdapterFactory struct { Filter ethutil.Filter } -func (f *DefaultAdapterFactory) CreateAdapters(app *Application) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { +func (f *DefaultAdapterFactory) CreateAdapters( + app *Application, +) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { if app == nil { return nil, nil, nil, fmt.Errorf("application reference is nil, should never happen") } @@ -258,12 +289,9 @@ func (f *DefaultAdapterFactory) CreateAdapters(app *Application) (ApplicationCon return nil, nil, nil, fmt.Errorf("error building application contract: %w", err) } - var inputSource InputSourceAdapter - if app.HasDataAvailabilitySelector(DataAvailability_InputBox) { - inputSource, err = NewInputSourceAdapter(app.IInputBoxAddress, f.Client, f.Filter) - if err != nil { - return nil, nil, nil, fmt.Errorf("error building inputbox contract: %w", err) - } + inputSource, err := NewInputSourceAdapter(app.IInputBoxAddress, f.Client, f.Filter) + if err != nil { + return nil, nil, nil, fmt.Errorf("error building inputbox contract: %w", err) } var daveConsensus DaveConsensusAdapter diff --git a/internal/evmreader/evmreader_test.go b/internal/evmreader/evmreader_test.go index d4cc5f359..9b45193b5 100644 --- a/internal/evmreader/evmreader_test.go +++ b/internal/evmreader/evmreader_test.go @@ -63,7 +63,7 @@ func (s *EvmReaderSuite) SetupTest() { LogLevel: logLevel, EvmReaderReadyMaxStaleness: 200 * time.Millisecond, BlockchainDefaultBlock: DefaultBlock_Latest, - FeatureInputReaderEnabled: true, + BlockchainId: 1, EvmReaderPollingInterval: 100 * time.Millisecond, }, Repository: s.repository, @@ -257,7 +257,7 @@ func (s *EvmReaderSuite) TestTickScansWithServiceContext() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1).Run(assertValidContext) + ).Return(nil).Times(3).Run(assertValidContext) s.repository.On( "UpdateEventLastCheckBlock", mock.Anything, @@ -272,7 +272,7 @@ func (s *EvmReaderSuite) TestTickScansWithServiceContext() { s.Require().NoError(err) s.client.AssertCalled(s.T(), "HeaderByNumber", mock.Anything, mock.Anything) - s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 5) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 7) } func (s *EvmReaderSuite) TestFetchMostRecentHeaderReturnsErrorWhenHeaderNumberIsNil() { diff --git a/internal/evmreader/fixtures_test.go b/internal/evmreader/fixtures_test.go index 81a2bae02..3f428542e 100644 --- a/internal/evmreader/fixtures_test.go +++ b/internal/evmreader/fixtures_test.go @@ -25,7 +25,6 @@ var ( header0 = makeHeader(0x11) header1 = makeHeader(0x12) header2 = makeHeader(0x13) - header3 = makeHeader(0x33) ) // Test input events — all target app1. @@ -36,14 +35,13 @@ var ( inputAddedEvent3 = makeInputEvent(app1Addr, 3, 0x13) ) -// applications defines the two-app setup used by most tests. -// app1: InputBox DA (inputs are read), app2: non-InputBox DA (inputs filtered out). +// applications defines two applications that share one InputBox. var applications = []*Application{{ + ID: 1, Name: "my-app-1", IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 0x01, @@ -51,11 +49,11 @@ var applications = []*Application{{ LastInputCheckBlock: 0x00, LastOutputCheckBlock: 0x00, }, { + ID: 2, Name: "my-app-2", IApplicationAddress: app2Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: []byte{0x11, 0x32, 0x45, 0x56}, Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 0x01, diff --git a/internal/evmreader/foreclosure.go b/internal/evmreader/foreclosure.go index 3d12acd82..ffa56325a 100644 --- a/internal/evmreader/foreclosure.go +++ b/internal/evmreader/foreclosure.go @@ -9,6 +9,7 @@ import ( "fmt" "math/big" + "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) @@ -199,6 +200,7 @@ func (r *Service) checkForForeclosure( "address", app.application.IApplicationAddress, "foreclose_block", block, "foreclose_transaction", txHash) + appstatus.WarnBlockedForeclosure(r.Logger, app.application) } return success } diff --git a/internal/evmreader/foreclosure_test.go b/internal/evmreader/foreclosure_test.go index c60d754d0..16b4f2a94 100644 --- a/internal/evmreader/foreclosure_test.go +++ b/internal/evmreader/foreclosure_test.go @@ -4,11 +4,13 @@ package evmreader import ( + "bytes" "context" "errors" "log/slog" "math/big" "os" + "strings" "testing" . "github.com/cartesi/rollups-node/internal/model" @@ -23,6 +25,34 @@ import ( "github.com/stretchr/testify/require" ) +func TestCheckForForeclosureWarnsForFailedApplicationOnce(t *testing.T) { + s, contract, repo := newForeclosureServiceFixture(t) + defer contract.AssertExpectations(t) + defer repo.AssertExpectations(t) + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app := foreclosureTestApp(1) + app.Status = ApplicationStatus_Failed + app.Reason = new("machine process crashed") + app.LastForecloseCheckBlock = 10 + const head, foreclosure = uint64(100), uint64(80) + txHash := common.HexToHash("0xfeed") + contract.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + contract.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(foreclosure, txHash)}, nil).Once() + repo.On("UpdateApplicationForeclosure", mock.Anything, app.ID, foreclosure, txHash, head).Return(nil).Once() + + apps := []appContracts{foreclosureAppContracts(app, contract)} + require.True(t, s.checkForForeclosure(t.Context(), apps, head)) + require.True(t, s.checkForForeclosure(t.Context(), apps, head+1)) + + require.Equal(t, 1, strings.Count(logs.String(), "failure blocks foreclosure drain")) + require.Contains(t, logs.String(), "foreclose_block=80") + require.Contains(t, logs.String(), "machine process crashed") + require.Contains(t, logs.String(), "repair the cause before clearing FAILED") + require.Equal(t, ApplicationStatus_Failed, app.Status, "the diagnostic must not clear the health gate") +} + // newForeclosureServiceFixture builds the smallest Service surface that // checkForForeclosure / foreclosureSearchStartBlock reach for, plus the // mocks bound to it. This avoids the full EvmReaderSuite bootstrap which diff --git a/internal/evmreader/input.go b/internal/evmreader/input.go index c9846eb97..c7cca3c99 100644 --- a/internal/evmreader/input.go +++ b/internal/evmreader/input.go @@ -7,9 +7,11 @@ import ( "context" "errors" "fmt" + "log/slog" "math" "math/big" + "github.com/cartesi/rollups-node/internal/errutil" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -42,23 +44,13 @@ func (r *Service) initializeNewApplicationInputSync( "current_block", mostRecentBlockNumber, ) if app.application.IInputBoxBlock == 0 { - r.Logger.Error("Application has no InputBox block number defined", - "application", app.application.Name, - "inputbox", app.application.IInputBoxAddress, - "iinputbox_block", app.application.IInputBoxBlock, - ) return 0, errors.New("application has no InputBox block number defined") } lastInputCheckBlock := app.application.IInputBoxBlock - 1 err := r.repository.UpdateEventLastCheckBlock(ctx, []int64{app.application.ID}, MonitoredEvent_InputAdded, lastInputCheckBlock) if err != nil { - r.Logger.Error("Failed to update application LastInputCheckBlock", - "application", app.application.Name, - "last_input_check_block", lastInputCheckBlock, - "error", err, - ) - return 0, err + return 0, fmt.Errorf("initialize input cursor at block %d: %w", lastInputCheckBlock, err) } r.Logger.Debug("Application input sync initialized", "application", app.application.Name, @@ -77,15 +69,13 @@ func (r *Service) scanIConsensusInputs( applications []appContracts, mostRecentBlockNumber uint64, ) bool { - success := true - if !r.inputReaderEnabled { - return success - } - r.Logger.Debug("Checking for new inputs") units, success := r.buildIConsensusInputScanUnits(ctx, applications, mostRecentBlockNumber) for _, unit := range units { + if ctx.Err() != nil { + return false + } success = r.scanIConsensusInputUnit(ctx, unit) && success } return success @@ -99,9 +89,6 @@ func (r *Service) buildIConsensusInputScanUnits( success := true appsByInputBox := map[common.Address][]appContracts{} for _, app := range applications { - if !app.application.HasDataAvailabilitySelector(DataAvailability_InputBox) { - continue - } key := app.application.IInputBoxAddress appsByInputBox[key] = append(appsByInputBox[key], app) } @@ -115,6 +102,9 @@ func (r *Service) buildIConsensusInputScanUnits( appsByLastInputCheckBlock := make(map[iConsensusInputScanRange][]appContracts) for _, app := range inputBoxApps { + if ctx.Err() != nil { + return nil, false + } lastInputCheckBlock := app.application.LastInputCheckBlock if lastInputCheckBlock == 0 { // New application. Find a safe start block to scan for inputs var err error @@ -125,17 +115,19 @@ func (r *Service) buildIConsensusInputScanUnits( ) if err != nil { success = false - r.Logger.Error("Failed to initialize application input sync", - "application", app.application.Name, - "most_recent_block", endBlock, - "error", err, - ) + r.reportInputScanError(ctx, app, 0, endBlock, err) continue } } scanEndBlock := foreclosureBoundedEndBlock(app.application, endBlock) if lastInputCheckBlock > scanEndBlock { - r.Logger.Warn( + level := slog.LevelWarn + if app.application.IInputBoxBlock != 0 && lastInputCheckBlock == app.application.IInputBoxBlock-1 { + // Registration can initialize the cursor before deployment is + // visible at the configured observation block. + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Input search skipped: most recent block is lower than the last processed one", "application", app.application.Name, "last_processed_block", lastInputCheckBlock, @@ -192,19 +184,15 @@ func (r *Service) scanIConsensusInputUnit( "most_recent_block", unit.endBlock, ) - err := r.readAndStoreInputs(ctx, - unit.lastInputCheckBlock, - unit.endBlock, - unit.apps, - ) - if err != nil { - success = false - r.Logger.Error("Error reading inputs", - "apps", appAddresses, - "last_processed_block", unit.lastInputCheckBlock, - "most_recent_block", unit.endBlock, - "error", err, - ) + for _, app := range unit.apps { + if ctx.Err() != nil { + return false + } + err := r.readAndStoreApplicationInputs(ctx, unit.lastInputCheckBlock, unit.endBlock, app) + if err != nil { + success = false + r.reportInputScanError(ctx, app, unit.lastInputCheckBlock, unit.endBlock, err) + } } return success } @@ -227,6 +215,25 @@ func (r *Service) scanIConsensusInputUnit( return success } +// The application scan owns operational error logging. Helpers return the cause; +// status transitions retain their existing, separately reported diagnostics. +func (r *Service) reportInputScanError(ctx context.Context, app appContracts, from, to uint64, err error) { + if errors.Is(err, errApplicationStatusReported) { + return + } + level := slog.LevelError + if ctx.Err() == context.Canceled && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Application input scan interrupted", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "last_processed_block", from, + "most_recent_block", to, + "error", err, + ) +} + // ErrInputForNonOpenEpoch indicates that an input was received for an epoch // that is not open — a state that should never occur during normal operation. var ErrInputForNonOpenEpoch = errors.New("received input for non-open epoch") @@ -300,285 +307,128 @@ func indexInputsIntoEpochs( return epochInputMap, nil } -// recordInputCorruption separates a known application-local integrity failure -// from failure to persist its status. Status helpers update app.Status only after -// a successful write and preserve previously persisted integrity terminals. -// Observation must continue on later ticks, including for terminal applications. +// recordInputCorruption keeps a recorded integrity fault local to the application. +// A failed status write still fails the scan. Status helpers update app.Status +// only after a successful write and preserve existing integrity terminals. func (r *Service) recordInputCorruption( ctx context.Context, app *Application, reasonFmt string, args ...any, -) bool { +) error { reason := fmt.Sprintf(reasonFmt, args...) - // setApplicationCorrupted always returns non-nil (the reason text itself). - // The DB error case is already logged inside setApplicationStatus. - // TODO: consider returning only DB errors instead of always returning an error. - _ = r.setApplicationCorrupted(ctx, app, "%s", reason) + err := r.setApplicationCorrupted(ctx, app, "%s", reason) if app.Status == ApplicationStatus_Corrupted || app.Status == ApplicationStatus_Diverged { r.Logger.Warn("Input observation degraded for application", "application", app.Name, "reason", reason) - return true + return nil } - return false + return err } -// readAndStoreInputs reads, inputs from the InputSource given specific filter options, indexes -// them into epochs and store the indexed inputs and epochs -func (r *Service) readAndStoreInputs( +// readAndStoreApplicationInputs completes one application's scan before its +// cursor can advance. A failed fetch, epoch lookup, or write leaves it retryable. +func (r *Service) readAndStoreApplicationInputs( ctx context.Context, lastProcessedBlock uint64, mostRecentBlockNumber uint64, - apps []appContracts, + app appContracts, ) error { - if len(apps) == 0 { - r.Logger.Warn("No valid running applications") - return nil + address := app.application.IApplicationAddress + epochLength := app.application.EpochLength + if epochLength == 0 { + return r.recordInputCorruption(ctx, app.application, "Application has epoch length of zero") } - // Retrieve Inputs from blockchain - nextSearchBlock := lastProcessedBlock + 1 - appInputsMap, err := r.readInputsFromBlockchain(ctx, apps, nextSearchBlock, mostRecentBlockNumber) + inputs, err := r.readApplicationInputs(ctx, app, lastProcessedBlock+1, mostRecentBlockNumber) if err != nil { - return fmt.Errorf("failed to read inputs from block %v to block %v. %w", - nextSearchBlock, - mostRecentBlockNumber, - err) + return err } - - var scanIncomplete bool - - if len(appInputsMap) != len(apps) { - scanIncomplete = true + currentEpoch, err := r.repository.GetEpoch(ctx, address.String(), calculateEpochIndex(epochLength, lastProcessedBlock)) + if err != nil { + return fmt.Errorf("retrieve current epoch: %w", err) } - addrToApp := mapAddressToApp(apps) - - // Index Inputs into epochs and handle epoch finalization - for address, inputs := range appInputsMap { - - app, exists := addrToApp[address] - if !exists { - r.Logger.Error("Application address on input not found", - "address", address) - scanIncomplete = true - continue - } - - epochLength := app.application.EpochLength - if epochLength == 0 { - ok := r.recordInputCorruption(ctx, app.application, - "Application has epoch length of zero") - scanIncomplete = scanIncomplete || !ok - continue - } - - // Retrieves last open epoch from DB - currentEpoch, err := r.repository.GetEpoch(ctx, address.String(), calculateEpochIndex(epochLength, lastProcessedBlock)) - if err != nil { - // Shutdown cancels the ctx mid-query; downgrade to Debug - // for the graceful-stop case. DeadlineExceeded would still - // flow through the Error branch. - if errors.Is(err, context.Canceled) { - r.Logger.Debug("GetEpoch canceled during shutdown", - "application", app.application.Name, - "address", address, - "error", err, - ) - } else { - r.Logger.Error("Error retrieving existing current epoch", - "application", app.application.Name, - "address", address, - "error", err, - ) - } - scanIncomplete = true - continue + epochInputMap, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, ErrInputForNonOpenEpoch) { + return r.recordInputCorruption(ctx, app.application, "Should never happen. %v", err) } + return fmt.Errorf("index inputs: %w", err) + } - // Index inputs into epochs using pure function - epochInputMap, err := indexInputsIntoEpochs( - epochLength, currentEpoch, inputs, mostRecentBlockNumber) + if len(epochInputMap) == 0 { + // No epoch needs to be stored or closed. Only this application's + // successfully processed range is eligible for the cursor-only update. + err = r.repository.UpdateEventLastCheckBlock( + ctx, []int64{app.application.ID}, MonitoredEvent_InputAdded, mostRecentBlockNumber) if err != nil { - if errors.Is(err, ErrInputForNonOpenEpoch) { - ok := r.recordInputCorruption(ctx, app.application, - "Should never happen. %v", err) - scanIncomplete = scanIncomplete || !ok - continue - } - return fmt.Errorf("error indexing inputs: %w", err) + return fmt.Errorf("update input cursor: %w", err) } + return nil + } - for epoch, epochInputs := range epochInputMap { - if epoch.Status == EpochStatus_Closed { - r.Logger.Info("Closing epoch", - "application", app.application.Name, - "address", address, - "epoch_index", epoch.Index, - "start", epoch.FirstBlock, - "end", epoch.LastBlock) - } - for _, input := range epochInputs { - r.Logger.Info("Found new Input", - "application", app.application.Name, - "address", address, - "index", input.Index, - "block", input.BlockNumber, - "epoch_index", epoch.Index) - } + // This transaction stores inputs, closes epochs, and advances the cursor. + // An epoch closure with no new inputs must use the same atomic write. + err = r.repository.CreateEpochsAndInputs(ctx, address.String(), epochInputMap, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.recordInputCorruption(ctx, app.application, + "stored input L1 log identity conflicts with rescanned chain data"+ + " (possible reorg past the input cursor); operator reset required. %v", err) } + return fmt.Errorf("store inputs and epochs: %w", err) + } - // Store everything - if len(epochInputMap) > 0 { - err = r.repository.CreateEpochsAndInputs( - ctx, - address.String(), - epochInputMap, - mostRecentBlockNumber, - ) - if err != nil { - if errors.Is(err, repository.ErrInputLogIdentityConflict) { - ok := r.recordInputCorruption(ctx, app.application, - "stored input L1 log identity conflicts with rescanned chain data"+ - " (possible reorg past the input cursor); operator reset required. %v", err) - scanIncomplete = scanIncomplete || !ok - continue - } - r.Logger.Error("Error storing inputs and epochs", - "application", app.application.Name, - "address", address, - "error", err, - ) - scanIncomplete = true - continue - } - r.Logger.Debug("Inputs and epochs stored successfully", + for epoch, epochInputs := range epochInputMap { + if epoch.Status == EpochStatus_Closed { + r.Logger.Info("Closing epoch", "application", app.application.Name, "address", address, - "start_block", nextSearchBlock, - "end_block", mostRecentBlockNumber, - "epoch_count", len(epochInputMap), - "input_count", len(inputs), - ) - } else { - r.Logger.Debug("No inputs or epochs to store") - } - - } - - // Update LastInputCheckBlock for applications that were successfully scanned - // but didn't have any inputs. - // For apps WITH inputs, LastInputCheckBlock is already updated atomically inside - // CreateEpochsAndInputs (same DB transaction as the epoch/input inserts). - // This separate call for no-input apps is NOT in the same transaction. If the process - // crashes between the two, no-input apps will re-scan the block range on restart. - // This is benign: the re-scan finds no inputs and updates the checkpoint idempotently. - // Only apps present in appInputsMap were successfully scanned. Apps that failed - // to fetch are absent from the map and their checkpoint must NOT advance, - // otherwise inputs in the failed block range would be permanently skipped. - appsToUpdate := []int64{} - for _, app := range apps { - appAddress := app.application.IApplicationAddress - if inputs, exists := appInputsMap[appAddress]; exists && len(inputs) == 0 { - appsToUpdate = append(appsToUpdate, app.application.ID) + "epoch_index", epoch.Index, + "start", epoch.FirstBlock, + "end", epoch.LastBlock) } - } - // Update LastInputCheckBlock for applications without inputs - if len(appsToUpdate) > 0 { - err := r.repository.UpdateEventLastCheckBlock(ctx, appsToUpdate, MonitoredEvent_InputAdded, mostRecentBlockNumber) - if err != nil { - // Shutdown cancels the ctx mid-update; downgrade to Debug - // for the graceful-stop case. DeadlineExceeded would still - // flow through the Error branch. - if errors.Is(err, context.Canceled) { - r.Logger.Debug("UpdateEventLastCheckBlock canceled during shutdown", - "app_ids", appsToUpdate, - "block_number", mostRecentBlockNumber, - "error", err, - ) - } else { - r.Logger.Error("Failed to update LastInputCheckBlock for applications without inputs", - "app_ids", appsToUpdate, - "block_number", mostRecentBlockNumber, - "error", err, - ) - } - scanIncomplete = true - } else { - r.Logger.Debug("Updated LastInputCheckBlock for applications without inputs", - "app_ids", appsToUpdate, - "block_number", mostRecentBlockNumber, - ) + for _, input := range epochInputs { + r.Logger.Info("Found new Input", + "application", app.application.Name, + "address", address, + "index", input.Index, + "block", input.BlockNumber, + "epoch_index", epoch.Index) } } - - if scanIncomplete { - return errScanIncomplete - } + r.Logger.Debug("Inputs and epochs stored successfully", + "application", app.application.Name, + "address", address, + "start_block", lastProcessedBlock+1, + "end_block", mostRecentBlockNumber, + "epoch_count", len(epochInputMap), + "input_count", len(inputs), + ) return nil } -// readInputsFromBlockchain fetches inputs for each application independently. -// On per-app failure, the failing app is omitted from the returned map (not -// present as a key) and processing continues for the remaining apps. The error -// return is reserved for fatal failures that prevent any work. -// Callers must use map-key presence to distinguish success (key exists, possibly -// with an empty slice) from failure (key absent) — apps absent from the map must -// NOT have their checkpoint advanced. -func (r *Service) readInputsFromBlockchain( +// readApplicationInputs preserves the failure cause for the scan caller. It +// validates the fetched logs against the counter observed in the same walk. +func (r *Service) readApplicationInputs( ctx context.Context, - apps []appContracts, + app appContracts, startBlock, endBlock uint64, -) (map[common.Address][]*Input, error) { - - // Initialize app input map - var appInputsMap = make(map[common.Address][]*Input) - - for _, app := range apps { - inputCount, err := r.repository.GetNumberOfInputs( - ctx, app.application.IApplicationAddress.String()) - if err != nil { - r.Logger.Error("Error getting input count for application", - "application", app.application.Name, - "error", err.Error(), - ) - continue - } - prevValue := new(big.Int).SetUint64(inputCount) - inputs, endCount, err := r.fetchInputs( - ctx, app, startBlock, endBlock, - prevValue, 0, math.MaxUint64) - if err != nil { - r.Logger.Error("Error fetching inputs for application", - "application", app.application.Name, - "start_block", startBlock, - "end_block", endBlock, - "error", err.Error(), - ) - continue - } - - // Validate input count: the on-chain counter delta observed during the - // transition walk should match the number of inputs fetched. This mirrors - // the DaveConsensus sealed epoch validation at sealedepochs.go and guards - // against silent input loss from FindTransitions missing a transition. - expectedNew := endCount - inputCount - if uint64(len(inputs)) != expectedNew { - r.Logger.Error( - "Input count mismatch: on-chain delta does not match fetched inputs", - "application", app.application.Name, - "db_count", inputCount, - "on_chain_end_count", endCount, - "expected_new", expectedNew, - "got", len(inputs), - "start_block", startBlock, - "end_block", endBlock, - ) - continue - } - - appInputsMap[app.application.IApplicationAddress] = inputs +) ([]*Input, error) { + inputCount, err := r.repository.GetNumberOfInputs(ctx, app.application.IApplicationAddress.String()) + if err != nil { + return nil, fmt.Errorf("get stored input count: %w", err) } - - return appInputsMap, nil + inputs, endCount, err := r.fetchInputs( + ctx, app, startBlock, endBlock, new(big.Int).SetUint64(inputCount), 0, math.MaxUint64) + if err != nil { + return nil, fmt.Errorf("fetch inputs: %w", err) + } + expectedNew := endCount - inputCount + if uint64(len(inputs)) != expectedNew { + return nil, fmt.Errorf("input count mismatch: stored %d, on-chain %d, expected %d new inputs, got %d", + inputCount, endCount, expectedNew, len(inputs)) + } + return inputs, nil } // fetchInputs locates blocks where new inputs were added via FindTransitions diff --git a/internal/evmreader/input_deployment_test.go b/internal/evmreader/input_deployment_test.go new file mode 100644 index 000000000..3afbe0bf4 --- /dev/null +++ b/internal/evmreader/input_deployment_test.go @@ -0,0 +1,111 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestInputScanLowerHeadLogsOnlyDeploymentWaitAtDebug(t *testing.T) { + const head uint64 = 90 + for _, test := range []struct { + name string + inputBoxBlock uint64 + cursor uint64 + wantLevel slog.Level + }{ + {name: "new deployment", inputBoxBlock: 100, wantLevel: slog.LevelDebug}, + {name: "deployment floor on later tick", inputBoxBlock: 100, cursor: 99, wantLevel: slog.LevelDebug}, + {name: "previous scan above deployment", inputBoxBlock: 100, cursor: 101, wantLevel: slog.LevelWarn}, + {name: "head regressed after deployment", inputBoxBlock: 10, cursor: 100, wantLevel: slog.LevelWarn}, + {name: "no deployment evidence", cursor: 100, wantLevel: slog.LevelWarn}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + app := inputUnitApp(1, inputBoxAddr, test.cursor) + app.application.IInputBoxBlock = test.inputBoxBlock + if test.cursor == 0 { + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, test.inputBoxBlock-1).Return(nil).Once() + } + + units, success := r.buildIConsensusInputScanUnits(t.Context(), []appContracts{app}, head) + require.True(t, success) + require.Empty(t, units) + require.Contains(t, output.String(), "level="+test.wantLevel.String()+` msg="Input search skipped: most recent block`) + if test.wantLevel == slog.LevelDebug { + require.NotContains(t, output.String(), "level=WARN") + } + repo.AssertExpectations(t) + }) + } +} + +func TestIConsensusPredeploymentObservationDoesNotCorrupt(t *testing.T) { + const ( + head uint64 = 90 + epochLength uint64 = 10 + ) + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + for _, test := range []struct { + name string + inputBoxBlock uint64 + }{ + {name: "InputBox not yet visible", inputBoxBlock: 100}, + {name: "existing InputBox with no inputs", inputBoxBlock: 10}, + } { + t.Run(consensus.String()+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + applicationContract := newMockApplicationContract() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "new-app", Enabled: true, Status: model.ApplicationStatus_OK, ConsensusType: consensus, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, IInputBoxBlock: test.inputBoxBlock, EpochLength: epochLength, + }, + applicationContract: applicationContract, inputSource: inputBox, + } + // Registration saw the application at latest, but neither foreclosure + // nor output observation can see its deployment at this configured head. + applicationContract.On("GetDeploymentBlockNumber", blockRange(head, head+1)). + Return(new(big.Int), bind.ErrNoCode).Twice() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, test.inputBoxBlock-1).Return(nil).Once() + if test.inputBoxBlock <= head { + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(test.inputBoxBlock, head+1), app1Addr). + Return(new(big.Int), nil).Twice() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), (test.inputBoxBlock-1)/epochLength).Return(nil, nil).Once() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, head).Return(nil).Once() + } + + require.True(t, r.runBlockScanners(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Zero(t, app.application.LastOutputCheckBlock) + require.Zero(t, app.application.LastForecloseCheckBlock) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + inputBox.AssertNotCalled(t, "RetrieveInputs", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + applicationContract.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/input_scan_test.go b/internal/evmreader/input_scan_test.go new file mode 100644 index 000000000..fed23db24 --- /dev/null +++ b/internal/evmreader/input_scan_test.go @@ -0,0 +1,278 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + scanInitializeCursor = "initialize cursor" + scanStoredCount = "stored count" + scanChainCounter = "chain counter" + scanInputLogs = "input logs" + scanEpochLookup = "epoch lookup" + scanEpochWrite = "epoch write" + scanCursorWrite = "cursor write" +) + +// Exercise the scan caller, not just its error classifier. Cancellation occurs +// inside the failing operation, as it does when shutdown interrupts a DB query. +func TestInputScanErrorReporting(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, operation := range []string{ + scanInitializeCursor, scanStoredCount, scanChainCounter, scanInputLogs, scanEpochLookup, scanEpochWrite, scanCursorWrite, + } { + t.Run(operation, func(t *testing.T) { + for _, test := range []struct { + name string + err error + cancel bool + quiet bool + }{ + {name: "shutdown", err: fmt.Errorf("query: %w", context.Canceled), cancel: true, quiet: true}, + {name: "joined shutdown", err: errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), + cancel: true, quiet: true}, + {name: "active context cancellation", err: context.Canceled}, + {name: "database failure", err: dbErr}, + {name: "database failure during shutdown", err: dbErr, cancel: true}, + {name: "timeout", err: context.DeadlineExceeded}, + {name: "timeout during shutdown", err: context.DeadlineExceeded, cancel: true}, + {name: "mixed failure", err: errors.Join(context.Canceled, dbErr), cancel: true}, + {name: "mixed timeout", err: errors.Join(context.Canceled, context.DeadlineExceeded), cancel: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, output := inputScanFixture(t, operation, test.err, func(mock.Arguments) { + if test.cancel { + cancel() + } + }) + require.False(t, reader.scanIConsensusInputs(ctx, []appContracts{app}, 110)) + logs := output.String() + require.Equal(t, 1, strings.Count(logs, `"msg":"Application input scan interrupted"`), logs) + if test.quiet { + require.NotContains(t, logs, `"level":"ERROR"`) + } else { + require.Equal(t, 1, strings.Count(logs, `"level":"ERROR"`), logs) + } + require.Contains(t, logs, `"application":"input-scan-app"`) + require.Contains(t, logs, `"address":"`+strings.ToLower(app1Addr.Hex())+`"`) + require.Contains(t, logs, `"most_recent_block":110`) + if errors.Is(test.err, dbErr) { + require.Contains(t, logs, dbErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs, context.DeadlineExceeded.Error()) + } + if operation != scanInitializeCursor && operation != scanCursorWrite { + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + } + if operation != scanEpochWrite { + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 0) + } + }) + } + }) + } +} + +// Set up exactly one failing operation. Unreached operations have no expectation: +// an attempt to write after a failed read fails the test immediately. +func inputScanFixture(t *testing.T, operation string, failure error, during func(mock.Arguments)) ( + *Service, appContracts, *MockRepository, *bytes.Buffer, +) { + t.Helper() + app := appContracts{application: &model.Application{ + ID: 1, Name: "input-scan-app", IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 90, EpochLength: 10, LastInputCheckBlock: 100, ConsensusType: model.Consensus_Authority, + Enabled: true, Status: model.ApplicationStatus_OK, + }} + repo := newMockRepository() + input := newMockInputBox() + app.inputSource = input + output := new(bytes.Buffer) + reader := &Service{repository: repo, defaultBlock: model.DefaultBlock_Latest} + reader.Logger = slog.New(slog.NewJSONHandler(output, &slog.HandlerOptions{Level: slog.LevelDebug})) + t.Cleanup(func() { + repo.AssertExpectations(t) + input.AssertExpectations(t) + }) + if operation == scanInitializeCursor { + app.application.LastInputCheckBlock = 0 + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{1}, model.MonitoredEvent_InputAdded, uint64(89)). + Run(during).Return(failure).Once() + return reader, app, repo, output + } + count := repo.On("GetNumberOfInputs", mock.Anything, app1Addr.Hex()).Return(uint64(0), nil).Once() + if operation == scanStoredCount { + count.Run(during).Return(uint64(0), failure) + return reader, app, repo, output + } + chainCount := input.On("GetNumberOfInputs", mock.Anything, app1Addr).Return(big.NewInt(0), nil) + if operation == scanChainCounter { + chainCount.Run(during).Return((*big.Int)(nil), failure).Once() + return reader, app, repo, output + } + if operation == scanInputLogs { + chainCount.Return(big.NewInt(1), nil) + input.On("RetrieveInputs", mock.Anything, mock.Anything, mock.Anything). + Run(during).Return([]iinputbox.IInputBoxInputAdded(nil), failure).Once() + return reader, app, repo, output + } + epoch := repo.On("GetEpoch", mock.Anything, app1Addr.Hex(), uint64(10)).Return((*model.Epoch)(nil), nil).Once() + switch operation { + case scanEpochLookup: + epoch.Run(during).Return((*model.Epoch)(nil), failure) + case scanEpochWrite: + // Closing an epoch with no new inputs still requires an atomic epoch+cursor write. + epoch.Return(&model.Epoch{Index: 10, FirstBlock: 100, LastBlock: 109, Status: model.EpochStatus_Open}, nil) + repo.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), mock.Anything, uint64(110)). + Run(during).Return(failure).Once() + case scanCursorWrite: + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{1}, model.MonitoredEvent_InputAdded, uint64(110)). + Run(during).Return(failure).Once() + default: + t.Fatalf("unknown input-scan operation %q", operation) + } + return reader, app, repo, output +} + +func TestInputScanHelperPreservesError(t *testing.T) { + for _, operation := range []string{scanStoredCount, scanChainCounter, scanInputLogs, scanEpochLookup, scanEpochWrite, scanCursorWrite} { + t.Run(operation, func(t *testing.T) { + cause := errors.New("operation failed") + reader, app, _, logs := inputScanFixture(t, operation, cause, func(mock.Arguments) {}) + err := reader.readAndStoreApplicationInputs(t.Context(), 100, 110, app) + require.ErrorIs(t, err, cause) + require.NotEqual(t, cause.Error(), err.Error(), "the operation must add context") + require.NotContains(t, logs.String(), `"level":"ERROR"`, "the caller owns operational error logging") + require.NotContains(t, logs.String(), "Application input scan interrupted") + }) + } +} + +func TestInputScanClosesEmptyEpochAtomically(t *testing.T) { + reader, app, repo, _ := inputScanFixture(t, scanEpochWrite, nil, func(args mock.Arguments) { + epochs := args.Get(2).(map[*model.Epoch][]*model.Input) + require.Len(t, epochs, 1) + for epoch, inputs := range epochs { + require.Equal(t, model.EpochStatus_Closed, epoch.Status) + require.EqualValues(t, 10, epoch.Index) + require.Empty(t, inputs) + } + }) + require.True(t, reader.scanIConsensusInputs(t.Context(), []appContracts{app}, 110)) + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 1) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) +} + +func TestInputScanStatusFailureReportedOnce(t *testing.T) { + repo := newMockRepository() + repo.On("UpdateApplicationStatus", mock.Anything, int64(1), model.ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + app := appContracts{application: &model.Application{ + ID: 1, Name: "invalid-epoch-length", IApplicationAddress: app1Addr, + Status: model.ApplicationStatus_OK, LastInputCheckBlock: 100, + }} + var logs bytes.Buffer + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + require.True(t, reader.scanIConsensusInputs(t.Context(), []appContracts{app}, 110), + "the recorded integrity fault is local to this application") + require.Contains(t, logs.String(), "Application has epoch length of zero") + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + require.NotContains(t, logs.String(), "Application input scan interrupted") + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + repo.AssertExpectations(t) +} + +func TestInputScanContinuesAfterApplicationFailure(t *testing.T) { + reader, failed, repo, logs := inputScanFixture(t, scanEpochLookup, errors.New("epoch lookup failed"), func(mock.Arguments) {}) + healthy := appContracts{application: &model.Application{ + ID: 2, Name: "healthy", IApplicationAddress: app2Addr, EpochLength: 10, LastInputCheckBlock: 100, + }} + input := newMockInputBox() + input.On("GetNumberOfInputs", mock.Anything, app2Addr).Return(big.NewInt(0), nil) + healthy.inputSource = input + repo.On("GetNumberOfInputs", mock.Anything, app2Addr.Hex()).Return(uint64(0), nil).Once() + repo.On("GetEpoch", mock.Anything, app2Addr.Hex(), uint64(10)).Return((*model.Epoch)(nil), nil).Once() + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{2}, model.MonitoredEvent_InputAdded, uint64(110)). + Return(nil).Once() + require.False(t, reader.scanIConsensusInputUnit(t.Context(), iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: []appContracts{failed, healthy}, + })) + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 1) + input.AssertExpectations(t) +} + +func TestInputScanShutdownStopsLaterWork(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, logs := inputScanFixture(t, scanEpochLookup, context.Canceled, func(mock.Arguments) { cancel() }) + app.application.LastForecloseCheckBlock = 110 + app.application.LastOutputCheckBlock = 100 + + // Output observation would query the repository if dispatch continued after shutdown. + require.False(t, reader.runBlockScanners(ctx, []appContracts{app}, 110)) + repo.AssertNumberOfCalls(t, "GetNumberOfPendingExecutableOutputs", 0) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + require.NotContains(t, logs.String(), `"level":"ERROR"`) +} + +func TestInputScanFailureThenShutdownKeepsEarlierError(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, failed, repo, logs := inputScanFixture(t, scanStoredCount, errors.New("database unavailable"), func(mock.Arguments) {}) + canceled := appContracts{application: &model.Application{ + ID: 2, Name: "canceled", IApplicationAddress: app2Addr, EpochLength: 10, + }} + repo.On("GetNumberOfInputs", mock.Anything, app2Addr.Hex()). + Run(func(mock.Arguments) { cancel() }).Return(uint64(0), context.Canceled).Once() + // The third scan has no expectations. It must not start after cancellation. + require.False(t, reader.scanIConsensusInputUnit(ctx, iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: []appContracts{failed, canceled, failed}, + })) + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + require.Contains(t, logs.String(), "database unavailable") +} + +func TestInputScanCancellationDoesNotRefreshReadiness(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, logs := inputScanFixture(t, scanEpochLookup, context.Canceled, func(mock.Arguments) { cancel() }) + app.application.LastForecloseCheckBlock = 110 + app.application.LastOutputCheckBlock = 100 + client := newMockEthClient() + client.On("HeaderByNumber", mock.Anything, mock.Anything).Return(&types.Header{Number: big.NewInt(110)}, nil).Once() + reader.client = client + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app.application}, uint64(1), nil).Once() + reader.resolver = newApplicationAdapterResolver(reader.Logger, + newMockAdapterFactory().SetupDefaultBehaviorSingleApp(newMockApplicationContract(), app.inputSource.(*MockInputBox))) + previous := time.Now().Add(-time.Minute) + reader.lastSuccessfulPoll.Store(&previous) + reader.consecutiveScanFailures.Store(2) + _, err := reader.Tick(ctx) + require.NoError(t, err) + require.Equal(t, previous, *reader.lastSuccessfulPoll.Load()) + require.EqualValues(t, 2, reader.consecutiveScanFailures.Load()) + require.NotContains(t, logs.String(), `"level":"ERROR"`) +} diff --git a/internal/evmreader/input_scan_units_test.go b/internal/evmreader/input_scan_units_test.go index aae2fce02..d5bc2cb03 100644 --- a/internal/evmreader/input_scan_units_test.go +++ b/internal/evmreader/input_scan_units_test.go @@ -33,8 +33,8 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { { name: "same input box and cursor share one unit", apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitApp(2, inputBoxA, 10, true), + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxA, 10), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 20}: {1, 2}}, @@ -43,8 +43,8 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { { name: "same input box and different cursor produce separate units", apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitApp(2, inputBoxA, 11, true), + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxA, 11), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 20}: {1}, {11, 20}: {2}}, @@ -53,28 +53,18 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { { name: "different input boxes produce separate units", apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitApp(2, inputBoxB, 10, true), + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxB, 10), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 20}: {1}}, inputBoxB: {{10, 20}: {2}}, }, }, - { - name: "non-InputBox data availability apps are excluded", - apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitApp(2, inputBoxA, 10, false), - }, - units: map[common.Address]map[iConsensusInputScanRange][]int64{ - inputBoxA: {{10, 20}: {1}}, - }, - }, { name: "foreclosed app scans only through the foreclose block", apps: []appContracts{ - inputUnitAppWithForeclose(1, inputBoxA, 10, true, 15), + inputUnitAppWithForeclose(1, inputBoxA, 10, 15), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 15}: {1}}, @@ -83,15 +73,15 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { { name: "foreclosed app already checked through foreclosure is excluded", apps: []appContracts{ - inputUnitAppWithForeclose(1, inputBoxA, 15, true, 15), + inputUnitAppWithForeclose(1, inputBoxA, 15, 15), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{}, }, { name: "same input box and cursor split when foreclosure changes end block", apps: []appContracts{ - inputUnitApp(1, inputBoxA, 10, true), - inputUnitAppWithForeclose(2, inputBoxA, 10, true, 15), + inputUnitApp(1, inputBoxA, 10), + inputUnitAppWithForeclose(2, inputBoxA, 10, 15), }, units: map[common.Address]map[iConsensusInputScanRange][]int64{ inputBoxA: {{10, 20}: {1}, {10, 15}: {2}}, @@ -101,7 +91,8 @@ func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - units, _ := reader.buildIConsensusInputScanUnits(ctx, tt.apps, 20) + units, success := reader.buildIConsensusInputScanUnits(ctx, tt.apps, 20) + require.True(t, success) require.Equal(t, tt.units, inputScanUnitIDs(units)) }) } @@ -119,11 +110,12 @@ func TestBuildIConsensusInputScanUnits_InitializesBeforeGrouping(t *testing.T) { repository: repo, } inputBox := common.HexToAddress("0x00000000000000000000000000000000000000a1") - app := inputUnitApp(1, inputBox, 0, true) + app := inputUnitApp(1, inputBox, 0) app.application.IInputBoxBlock = 7 - units, _ := reader.buildIConsensusInputScanUnits(ctx, []appContracts{app}, 20) + units, success := reader.buildIConsensusInputScanUnits(ctx, []appContracts{app}, 20) + require.True(t, success) require.Equal(t, map[common.Address]map[iConsensusInputScanRange][]int64{ inputBox: {{6, 20}: {1}}, }, inputScanUnitIDs(units)) @@ -139,9 +131,9 @@ func TestBuildIConsensusInputScanUnits_FailedInitializationExcludesOnlyThatApp(t }, } inputBox := common.HexToAddress("0x00000000000000000000000000000000000000a1") - broken := inputUnitApp(1, inputBox, 0, true) + broken := inputUnitApp(1, inputBox, 0) broken.application.IInputBoxBlock = 0 - good := inputUnitApp(2, inputBox, 10, true) + good := inputUnitApp(2, inputBox, 10) units, success := reader.buildIConsensusInputScanUnits(ctx, []appContracts{broken, good}, 20) require.False(t, success) @@ -151,28 +143,22 @@ func TestBuildIConsensusInputScanUnits_FailedInitializationExcludesOnlyThatApp(t }, inputScanUnitIDs(units)) } -func inputUnitApp(id int64, inputBox common.Address, cursor uint64, hasInputBoxDA bool) appContracts { - return inputUnitAppWithForeclose(id, inputBox, cursor, hasInputBoxDA, 0) +func inputUnitApp(id int64, inputBox common.Address, cursor uint64) appContracts { + return inputUnitAppWithForeclose(id, inputBox, cursor, 0) } func inputUnitAppWithForeclose( id int64, inputBox common.Address, cursor uint64, - hasInputBoxDA bool, forecloseBlock uint64, ) appContracts { - dataAvailability := []byte{0xff} - if hasInputBoxDA { - dataAvailability = DataAvailability_InputBox[:] - } return appContracts{application: &Application{ ID: id, Name: "app", IApplicationAddress: common.BigToAddress(big.NewInt(id)), IInputBoxAddress: inputBox, IInputBoxBlock: 1, - DataAvailability: dataAvailability, Enabled: true, Status: ApplicationStatus_OK, LastInputCheckBlock: cursor, diff --git a/internal/evmreader/input_test.go b/internal/evmreader/input_test.go index 5fbf8184f..48073b816 100644 --- a/internal/evmreader/input_test.go +++ b/internal/evmreader/input_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func (s *EvmReaderSuite) TestItReadsInputsFromNewBlocksFilteredByDA() { +func (s *EvmReaderSuite) TestItReadsInputsFromDirectInputBox() { s.client.EnqueueNewHead(0x11).Once() s.client.EnqueueNewHead(0x12).Once() s.client.EnqueueNewHead(0x13).Once() @@ -26,7 +26,13 @@ func (s *EvmReaderSuite) TestItReadsInputsFromNewBlocksFilteredByDA() { s.Require().True(waitNotification(called), "evmreader did not read new header") s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 3) - s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 9) + s.repository.AssertCalled(s.T(), "CreateEpochsAndInputs", + mock.Anything, applications[0].IApplicationAddress.String(), mock.Anything, uint64(0x13)) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 13) + s.repository.AssertCalled(s.T(), "UpdateEventLastCheckBlock", + mock.Anything, []int64{applications[0].ID}, MonitoredEvent_InputAdded, uint64(0)) + s.repository.AssertCalled(s.T(), "UpdateEventLastCheckBlock", + mock.Anything, []int64{applications[1].ID}, MonitoredEvent_InputAdded, uint64(0x13)) s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) s.repository.AssertExpectations(s.T()) @@ -47,31 +53,19 @@ func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(2) + ).Return(nil).Times(8) // Two initial cursors, then one update per app per block. s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, MonitoredEvent_OutputExecuted, mock.Anything, ).Return(nil).Times(4) - s.repository.On("UpdateEventLastCheckBlock", - mock.Anything, - mock.Anything, - MonitoredEvent_InputAdded, - mock.Anything, - ).Once().Return(nil) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, MonitoredEvent_OutputExecuted, mock.Anything, ).Return(nil).Times(2) - s.repository.On("UpdateEventLastCheckBlock", - mock.Anything, - mock.Anything, - MonitoredEvent_InputAdded, - mock.Anything, - ).Once().Return(nil) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -83,7 +77,7 @@ func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything, - ).Return(uint64(0), nil).Times(3) + ).Return(uint64(0), nil).Times(6) s.repository.Unset("CreateEpochsAndInputs") // Prepare sequence of inputs @@ -156,7 +150,6 @@ func (s *EvmReaderSuite) TestItReadsMultipleInputsFromSingleNewBlock() { IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 0x10, @@ -247,7 +240,6 @@ func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock( IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 0x10, @@ -302,7 +294,6 @@ func (s *EvmReaderSuite) TestCatchUpForeclosedInputsScansThroughForecloseBlock() IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 1, @@ -360,7 +351,6 @@ func (s *EvmReaderSuite) TestCatchUpForeclosedInputsStoresSameBlockInput() { IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], Enabled: true, Status: ApplicationStatus_OK, IInputBoxBlock: 1, @@ -437,7 +427,7 @@ func (s *EvmReaderSuite) TestCatchUpForeclosedInputsStoresSameBlockInput() { } // TestCheckpointNotAdvancedOnFetchFailure is a regression test for a bug where -// readInputsFromBlockchain swallowed per-app fetch errors, and the caller then +// the input fetch swallowed per-app errors, and the caller then // advanced LastInputCheckBlock for failed apps — permanently skipping their inputs. func (s *EvmReaderSuite) TestCheckpointNotAdvancedOnFetchFailure() { require := require.New(s.T()) @@ -509,8 +499,9 @@ func (s *EvmReaderSuite) TestCheckpointNotAdvancedOnFetchFailure() { s.evmReader.repository = repo - err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) - require.ErrorIs(err, errScanIncomplete) + require.False(s.evmReader.scanIConsensusInputUnit(s.ctx, iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: apps, + })) repo.AssertExpectations(s.T()) } diff --git a/internal/evmreader/mocks_test.go b/internal/evmreader/mocks_test.go index a0ad09e04..76dfe61f2 100644 --- a/internal/evmreader/mocks_test.go +++ b/internal/evmreader/mocks_test.go @@ -76,7 +76,7 @@ func newMockEthClient() *MockEthClient { } func (m *MockEthClient) SetupDefaultBehavior() *MockEthClient { - m.On("ChainID", mock.Anything).Return(big.NewInt(0), nil) + m.On("ChainID", mock.Anything).Return(big.NewInt(1), nil) return m } @@ -123,32 +123,34 @@ func (m *MockInputBox) SetupDefaultBehavior() *MockInputBox { // RetrieveInputs: matched by Start block, not call order. m.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), - mock.Anything, + []common.Address{app1Addr}, mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) m.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x12 }), - mock.Anything, + []common.Address{app1Addr}, mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent1}, nil) m.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), - mock.Anything, + []common.Address{app1Addr}, mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent2, inputAddedEvent3}, nil) // GetNumberOfInputs: block-based matching models the on-chain state. // Each range returns the input count at that point in the blockchain. - m.On("GetNumberOfInputs", blockRange(0, 0x11), mock.Anything). + m.On("GetNumberOfInputs", blockRange(0, 0x11), app1Addr). Return(new(big.Int).SetUint64(0), nil) - m.On("GetNumberOfInputs", blockRange(0x11, 0x12), mock.Anything). + m.On("GetNumberOfInputs", blockRange(0x11, 0x12), app1Addr). Return(new(big.Int).SetUint64(1), nil) - m.On("GetNumberOfInputs", blockRange(0x12, 0x13), mock.Anything). + m.On("GetNumberOfInputs", blockRange(0x12, 0x13), app1Addr). Return(new(big.Int).SetUint64(2), nil) - m.On("GetNumberOfInputs", blockFrom(0x13), mock.Anything). + m.On("GetNumberOfInputs", blockFrom(0x13), app1Addr). Return(new(big.Int).SetUint64(4), nil) + m.On("GetNumberOfInputs", mock.Anything, app2Addr). + Return(new(big.Int).SetUint64(0), nil) return m } @@ -191,9 +193,7 @@ func newMockRepository() *MockRepository { func (m *MockRepository) SetupDefaultBehavior() *MockRepository { m.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). - Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound) - m.On("SaveNodeConfigRaw", mock.Anything, EvmReaderConfigKey, mock.Anything). - Return(nil) + Return([]byte(`{"DefaultBlock":"LATEST","ChainID":1}`), time.Time{}, time.Time{}, nil) apps := copyApplications(applications) m.On("ListApplications", @@ -205,6 +205,7 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 apps[0].LastOutputCheckBlock = 0x11 apps[1].LastOutputCheckBlock = 0x11 m.On("ListApplications", @@ -216,6 +217,7 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 apps[0].LastOutputCheckBlock = 0x12 apps[1].LastOutputCheckBlock = 0x12 m.On("ListApplications", @@ -230,7 +232,7 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) m.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -243,18 +245,10 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { mock.Anything, ).Return(nil).Maybe() - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(0), nil) - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(1), nil) - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(2), nil) + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(0), nil) + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(1), nil) + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(2), nil) + m.On("GetNumberOfInputs", mock.Anything, app2Addr.String()).Return(uint64(0), nil).Times(3) m.On("GetNumberOfExecutedOutputs", mock.Anything, @@ -266,14 +260,8 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { mock.Anything, mock.Anything).Return(nil) - m.On("GetEpoch", - mock.Anything, - mock.Anything, - uint64(0)).Return(nil, nil).Once() - m.On("GetEpoch", - mock.Anything, - mock.Anything, - uint64(1)).Return( + m.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(0)).Return(nil, nil).Once() + m.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(1)).Return( &Epoch{ Index: 1, FirstBlock: 11, @@ -282,6 +270,8 @@ func (m *MockRepository) SetupDefaultBehavior() *MockRepository { TxBufferDataBlock: nil, ClaimTransactionHash: nil, }, nil).Twice() + m.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(0)).Return(nil, nil).Once() + m.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(1)).Return(nil, nil).Twice() // Catch-all: returns empty list for extra headers. m.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). @@ -304,6 +294,11 @@ func (m *MockRepository) ListApplications( return args.Get(0).([]*Application), args.Get(1).(uint64), args.Error(2) } +func (m *MockRepository) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + func (m *MockRepository) SaveNodeConfigRaw( ctx context.Context, key string, rawJSON []byte, ) error { @@ -589,19 +584,9 @@ func (m *MockDaveConsensus) GetInputBox( func (m *MockDaveConsensus) GetCurrentSealedEpoch( opts *bind.CallOpts, -) (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address -}, error) { +) (DaveCurrentSealedEpoch, error) { args := m.Called(opts) - return args.Get(0).(struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address - }), args.Error(1) + return args.Get(0).(DaveCurrentSealedEpoch), args.Error(1) } func (m *MockDaveConsensus) GetApplicationContract( @@ -687,7 +672,7 @@ func (m *MockAdapterFactory) SetupDefaultBehavior( mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[1].IApplicationAddress }), - ).Return(appContract2, nil, nil, nil) + ).Return(appContract2, inputBox1, nil, nil) return m } diff --git a/internal/evmreader/observation_shutdown_test.go b/internal/evmreader/observation_shutdown_test.go new file mode 100644 index 000000000..d19262ca2 --- /dev/null +++ b/internal/evmreader/observation_shutdown_test.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestObservationDatabaseErrorReporting(t *testing.T) { + const ( + applicationList = "application list" + pendingOutputs = "pending outputs" + ) + dbErr := errors.New("database unavailable") + for _, operation := range []string{applicationList, pendingOutputs} { + t.Run(operation, func(t *testing.T) { + for _, test := range []struct { + name string + err error + cancel bool + quiet bool + }{ + {name: "shutdown", err: fmt.Errorf("query: %w", context.Canceled), cancel: true, quiet: true}, + {name: "joined shutdown", err: errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), + cancel: true, quiet: true}, + {name: "active context cancellation", err: context.Canceled}, + {name: "database failure", err: dbErr}, + {name: "database failure during shutdown", err: dbErr, cancel: true}, + {name: "timeout", err: context.DeadlineExceeded}, + {name: "timeout during shutdown", err: context.DeadlineExceeded, cancel: true}, + {name: "mixed failure", err: errors.Join(context.Canceled, dbErr), cancel: true}, + {name: "mixed timeout", err: errors.Join(context.Canceled, context.DeadlineExceeded), cancel: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var logs bytes.Buffer + repo := newMockRepository() + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + fail := func(mock.Arguments) { + if test.cancel { + cancel() + } + } + switch operation { + case applicationList: + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Run(fail).Return([]*model.Application(nil), uint64(0), test.err).Once() + require.False(t, reader.processBlockHead(ctx, 110, nil)) + case pendingOutputs: + app := appContracts{application: &model.Application{ + ID: 1, Name: "output-scan-app", IApplicationAddress: app1Addr, LastOutputCheckBlock: 100, + }} + repo.Unset("GetNumberOfPendingExecutableOutputs") + repo.On("GetNumberOfPendingExecutableOutputs", mock.Anything, app1Addr.Hex()). + Run(fail).Return(uint64(0), test.err).Once() + require.False(t, reader.checkForOutputExecution(ctx, []appContracts{app}, 110)) + require.Contains(t, logs.String(), `"application":"output-scan-app"`) + } + require.Equal(t, 1, strings.Count(logs.String(), `"error":`), logs.String()) + if test.quiet { + require.NotContains(t, logs.String(), `"level":"ERROR"`) + require.Contains(t, logs.String(), `"level":"DEBUG"`) + } else { + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`), logs.String()) + } + if errors.Is(test.err, dbErr) { + require.Contains(t, logs.String(), dbErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs.String(), context.DeadlineExceeded.Error()) + } + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + repo.AssertNumberOfCalls(t, "UpdateOutputsExecution", 0) + repo.AssertExpectations(t) + }) + } + }) + } +} + +func TestOutputScanStopsAfterShutdown(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := newMockRepository() + repo.Unset("GetNumberOfPendingExecutableOutputs") + repo.On("GetNumberOfPendingExecutableOutputs", mock.Anything, app1Addr.Hex()). + Run(func(mock.Arguments) { cancel() }).Return(uint64(0), context.Canceled).Once() + reader := &Service{repository: repo} + reader.Logger = testLogger(t) + apps := []appContracts{ + {application: &model.Application{ID: 1, IApplicationAddress: app1Addr, LastOutputCheckBlock: 100}}, + {application: &model.Application{ID: 2, IApplicationAddress: app2Addr, LastOutputCheckBlock: 100}}, + } + require.False(t, reader.checkForOutputExecution(ctx, apps, 110)) + repo.AssertExpectations(t) +} diff --git a/internal/evmreader/openepoch_integrity_test.go b/internal/evmreader/openepoch_integrity_test.go new file mode 100644 index 000000000..d2fdf6120 --- /dev/null +++ b/internal/evmreader/openepoch_integrity_test.go @@ -0,0 +1,123 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveOpenEpochChecksInputCountBeforePublication(t *testing.T) { + const head uint64 = 100 + for _, foreclosed := range []bool{false, true} { + name := "active" + if foreclosed { + name = "foreclosed" + } + for _, test := range []struct { + name string + storedCount uint64 + lowerBound uint64 + endCount uint64 + logIndices []uint64 + wantIndices []uint64 + mismatch bool + }{ + {name: "missing first log", endCount: 2, logIndices: []uint64{1}, mismatch: true}, + {name: "missing all logs", endCount: 1, mismatch: true}, + {name: "existing open epoch misses a log", storedCount: 4, lowerBound: 2, endCount: 6, + logIndices: []uint64{5}, mismatch: true}, + {name: "overlap block includes sealed inputs", storedCount: 2, lowerBound: 2, endCount: 3, + logIndices: []uint64{0, 1, 2}, wantIndices: []uint64{2}}, + {name: "no new inputs", storedCount: 2, lowerBound: 2, endCount: 2}, + {name: "existing open inputs", storedCount: 4, lowerBound: 2, endCount: 5, + logIndices: []uint64{4}, wantIndices: []uint64{4}}, + } { + t.Run(name+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(inputBox, nil) + app.application.Status = model.ApplicationStatus_OK + app.application.LastEpochCheckBlock = head + app.application.LastInputCheckBlock = head - 1 + observedHead := head + if foreclosed { + app.application.ForecloseBlock = head + observedHead += 10 + } + sealed := &model.Epoch{Index: 0, LastBlock: head, + InputIndexUpperBound: test.lowerBound, Status: model.EpochStatus_Closed} + var open *model.Epoch + if test.storedCount > test.lowerBound { + sealed.LastBlock = head - 2 + open = &model.Epoch{Index: 1, FirstBlock: sealed.LastBlock, LastBlock: head - 1, + InputIndexLowerBound: test.lowerBound, InputIndexUpperBound: test.storedCount, + Status: model.EpochStatus_Open} + } + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(sealed, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(open, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(test.storedCount, nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), app1Addr). + Return(new(big.Int).SetUint64(test.endCount), nil).Once() + if test.endCount > test.storedCount { + events := make([]iinputbox.IInputBoxInputAdded, 0, len(test.logIndices)) + for _, index := range test.logIndices { + events = append(events, makeInputEvent(app1Addr, index, head)) + } + inputBox.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + }), []common.Address{app1Addr}, mock.Anything).Return(events, nil).Once() + } + if !test.mismatch { + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, head). + Run(func(args mock.Arguments) { + epochs := args.Get(2).(map[*model.Epoch][]*model.Input) + require.Len(t, epochs, 1) + for epoch, inputs := range epochs { + require.Equal(t, uint64(1), epoch.Index) + require.Equal(t, sealed.LastBlock, epoch.FirstBlock) + require.Equal(t, head, epoch.LastBlock) + require.Equal(t, test.lowerBound, epoch.InputIndexLowerBound) + require.Equal(t, test.endCount, epoch.InputIndexUpperBound) + require.Equal(t, model.EpochStatus_Open, epoch.Status) + require.Len(t, inputs, len(test.wantIndices)) + for i, index := range test.wantIndices { + require.Equal(t, index, inputs[i].Index) + } + } + app.application.LastInputCheckBlock = head + }).Return(nil).Once() + } + + require.Equal(t, !test.mismatch, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, observedHead)) + if test.mismatch { + require.Equal(t, head-1, app.application.LastInputCheckBlock) + if open != nil { + require.Equal(t, test.storedCount, open.InputIndexUpperBound) + require.Equal(t, head-1, open.LastBlock) + } + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + require.Equal(t, head, app.application.LastInputCheckBlock) + } + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/output.go b/internal/evmreader/output.go index ef10604aa..04e9b2c0e 100644 --- a/internal/evmreader/output.go +++ b/internal/evmreader/output.go @@ -9,8 +9,10 @@ import ( "encoding/hex" "errors" "fmt" + "log/slog" "math/big" + "github.com/cartesi/rollups-node/internal/errutil" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -95,6 +97,9 @@ func (r *Service) checkForOutputExecution( r.Logger.Debug("Checking for new Output Executed Events", "apps", appAddresses) for _, app := range apps { + if ctx.Err() != nil { + return false + } lastOutputCheck := app.application.LastOutputCheckBlock if lastOutputCheck == 0 { // New application. Find a safe start block to scan for outputs var err error @@ -114,12 +119,21 @@ func (r *Service) checkForOutputExecution( } if mostRecentBlockNumber > lastOutputCheck { - pending, err := r.hasPendingExecutableOutputs(ctx, app) + pending, err := r.repository.GetNumberOfPendingExecutableOutputs(ctx, app.application.IApplicationAddress.Hex()) if err != nil { + level := slog.LevelError + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Error counting pending executable outputs", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err, + ) success = false continue } - if !pending { + if pending == 0 { r.Logger.Debug("Not reading output execution: no pending executable outputs", "application", app.application.Name, "address", app.application.IApplicationAddress, "last_output_check_block", lastOutputCheck, @@ -154,19 +168,6 @@ func (r *Service) checkForOutputExecution( return success } -func (r *Service) hasPendingExecutableOutputs(ctx context.Context, app appContracts) (bool, error) { - pending, err := r.repository.GetNumberOfPendingExecutableOutputs(ctx, app.application.IApplicationAddress.String()) - if err != nil { - r.Logger.Error("Error counting pending executable outputs", - "application", app.application.Name, - "address", app.application.IApplicationAddress, - "error", err, - ) - return false, err - } - return pending > 0, nil -} - func (r *Service) readAndUpdateOutputs( ctx context.Context, app appContracts, lastOutputCheck, mostRecentBlockNumber uint64) bool { diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go index 502f5cde1..b200c6159 100644 --- a/internal/evmreader/output_test.go +++ b/internal/evmreader/output_test.go @@ -66,7 +66,7 @@ func (s *EvmReaderSuite) setupOutputExecution() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -550,6 +550,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 apps[0].LastOutputCheckBlock = 0x0F apps[1].LastOutputCheckBlock = 0x11 s.repository.On("ListApplications", @@ -561,6 +562,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 apps[0].LastOutputCheckBlock = 0x0F apps[1].LastOutputCheckBlock = 0x12 s.repository.On("ListApplications", @@ -588,7 +590,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -654,6 +656,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 apps[0].LastOutputCheckBlock = 0x0F apps[1].LastOutputCheckBlock = 0x11 s.repository.On("ListApplications", @@ -665,6 +668,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { apps = copyApplications(applications) apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 apps[0].LastOutputCheckBlock = 0x0F apps[1].LastOutputCheckBlock = 0x12 s.repository.On("ListApplications", @@ -691,7 +695,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -729,12 +733,11 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { s.contractFactory = newMockAdapterFactory() s.evmReader = &Service{ - client: s.client, - repository: s.repository, - defaultBlock: DefaultBlock_Latest, - adapterFactory: s.contractFactory, - hasEnabledApps: true, - inputReaderEnabled: true, + client: s.client, + repository: s.repository, + defaultBlock: DefaultBlock_Latest, + adapterFactory: s.contractFactory, + hasEnabledApps: true, } logLevel, err := config.GetLogLevel() @@ -764,6 +767,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { ).Return(apps, uint64(2), nil).Once() apps = copyApplications(applications[1:2]) + apps[0].LastInputCheckBlock = 0x11 apps[0].LastOutputCheckBlock = 0x11 apps[0].LastForecloseCheckBlock = 0x100 s.repository.On("ListApplications", @@ -774,6 +778,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { ).Return(apps, uint64(1), nil).Once() apps = copyApplications(applications[1:2]) + apps[0].LastInputCheckBlock = 0x12 apps[0].LastOutputCheckBlock = 0x12 apps[0].LastForecloseCheckBlock = 0x100 s.repository.On("ListApplications", @@ -791,7 +796,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, MonitoredEvent_InputAdded, mock.Anything, - ).Return(nil).Times(1) + ).Return(nil).Times(5) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, @@ -804,10 +809,8 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, ).Return(nil).Maybe() - s.repository.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(0), nil) + s.repository.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(0), nil) + s.repository.On("GetNumberOfInputs", mock.Anything, app2Addr.String()).Return(uint64(0), nil).Times(3) s.repository.On("GetNumberOfExecutedOutputs", mock.Anything, @@ -820,10 +823,9 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, mock.Anything).Return(nil).Once() - s.repository.On("GetEpoch", - mock.Anything, - mock.Anything, - uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(1)).Return(nil, nil).Twice() output := &Output{ Index: 1, @@ -864,15 +866,17 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { s.inputBox.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), - mock.Anything, + []common.Address{app1Addr}, mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) // On-chain: 0 inputs before block 0x11, 1 from block 0x11 - s.inputBox.On("GetNumberOfInputs", blockRange(0, 0x11), mock.Anything). + s.inputBox.On("GetNumberOfInputs", blockRange(0, 0x11), app1Addr). Return(new(big.Int).SetUint64(0), nil) - s.inputBox.On("GetNumberOfInputs", blockFrom(0x11), mock.Anything). + s.inputBox.On("GetNumberOfInputs", blockFrom(0x11), app1Addr). Return(new(big.Int).SetUint64(1), nil) + s.inputBox.On("GetNumberOfInputs", mock.Anything, app2Addr). + Return(new(big.Int).SetUint64(0), nil) s.contractFactory.On("CreateAdapters", mock.MatchedBy(func(app *Application) bool { @@ -883,7 +887,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[1].IApplicationAddress }), - ).Return(s.applicationContract2, nil, nil, nil) + ).Return(s.applicationContract2, s.inputBox, nil, nil) } func (s *EvmReaderSuite) TestCheckOutputFailsWhenOutputMismatches() { diff --git a/internal/evmreader/readiness_test.go b/internal/evmreader/readiness_test.go index 2bff483ba..6854ba0a3 100644 --- a/internal/evmreader/readiness_test.go +++ b/internal/evmreader/readiness_test.go @@ -7,13 +7,13 @@ import ( "context" "errors" "fmt" - "github.com/cartesi/rollups-node/internal/repository" "log/slog" "math/big" "testing" "time" . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -21,12 +21,24 @@ import ( "github.com/stretchr/testify/require" ) +func TestDaveScanReportsMissingInputBox(t *testing.T) { + reader := &Service{} + reader.Logger = slog.Default() + apps := []appContracts{{application: &Application{ + ID: 1, Name: "missing-input-box", IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + ConsensusType: Consensus_PRT, + }}} + + require.False(t, reader.scanDaveConsensusEpochsAndInputs(t.Context(), apps, 110), + "a missing InputBox adapter must count as a failed observation cycle") +} + func TestReadinessTracksScanFailures(t *testing.T) { for _, failure := range []string{"application list", "input counter", "input logs", "input cursor", "input store", "output query", "foreclosure query", "drive cursor", "withdrawal store"} { t.Run(failure, func(t *testing.T) { app := &Application{ ID: 1, Name: "app", Enabled: true, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, + ConsensusType: Consensus_Authority, EpochLength: 10, Status: ApplicationStatus_OK, LastInputCheckBlock: 100, LastOutputCheckBlock: 110, LastForecloseCheckBlock: 110, } @@ -89,7 +101,7 @@ func TestReadinessTracksScanFailures(t *testing.T) { recovered = []any{false, nil} repo.On("UpdateApplicationLastForecloseCheckBlock", mock.Anything, mock.Anything, mock.Anything).Return(nil) } - r := &Service{client: client, repository: repo, inputReaderEnabled: true, + r := &Service{client: client, repository: repo, defaultBlock: DefaultBlock_Latest, readyMaxStaleness: time.Hour} r.Logger = slog.Default() r.resolver = newApplicationAdapterResolver(r.Logger, newMockAdapterFactory().SetupDefaultBehaviorSingleApp(contract, input)) @@ -119,16 +131,19 @@ func TestReadinessTracksScanFailures(t *testing.T) { } func TestReadinessSeparatesInputCorruptionFromSharedFailures(t *testing.T) { - for _, failure := range []string{"recorded conflict", "status write", "RPC", "store", "epoch query", "zero epoch", "zero epoch status write", "non-open epoch", "non-open epoch status write"} { + for _, failure := range []string{ + "recorded conflict", "status write", "RPC", "store", "epoch query", + "zero epoch", "zero epoch status write", "non-open epoch", "non-open epoch status write", + } { t.Run(failure, func(t *testing.T) { bad := &Application{ID: 1, Name: "degraded", Enabled: true, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], EpochLength: 10, + ConsensusType: Consensus_Authority, EpochLength: 10, Status: ApplicationStatus_OK, LastInputCheckBlock: 100, LastOutputCheckBlock: 110, LastForecloseCheckBlock: 110} good := *bad good.ID, good.Name, good.IApplicationAddress = 2, "healthy", common.HexToAddress("0x2222") - // Terminal execution status must not prevent healthy observation. + // An integrity-terminal status must not prevent healthy observation. good.Status = ApplicationStatus_Corrupted repo := newMockRepository() list := repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). @@ -169,7 +184,7 @@ func TestReadinessSeparatesInputCorruptionFromSharedFailures(t *testing.T) { } client := newMockEthClient() client.On("HeaderByNumber", mock.Anything, mock.Anything).Return(&types.Header{Number: big.NewInt(110)}, nil) - r := &Service{client: client, repository: repo, inputReaderEnabled: true, + r := &Service{client: client, repository: repo, defaultBlock: DefaultBlock_Latest, readyMaxStaleness: time.Hour} r.Logger = slog.Default() r.resolver = newApplicationAdapterResolver(r.Logger, diff --git a/internal/evmreader/sealedepochs.go b/internal/evmreader/sealedepochs.go index 0948e0768..3a411f26d 100644 --- a/internal/evmreader/sealedepochs.go +++ b/internal/evmreader/sealedepochs.go @@ -11,17 +11,18 @@ import ( "math/big" . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) -func (r *Service) initializeNewApplicationSealedEpochSync( +func (r *Service) initialSealedEpochSearchBlock( ctx context.Context, - app *appContracts, + app appContracts, mostRecentBlockNumber uint64, -) error { - r.Logger.Info("Initializing application sealed epoch sync", +) (uint64, error) { + r.Logger.Debug("Initializing application sealed epoch sync", "application", app.application.Name, "current_block", mostRecentBlockNumber, ) @@ -38,48 +39,22 @@ func (r *Service) initializeNewApplicationSealedEpochSync( "consensus_address", app.application.IConsensusAddress, "block", mostRecentBlockNumber, ) - return fmt.Errorf("%w: consensus %s at block %d: %w", + return 0, fmt.Errorf("%w: consensus %s at block %d: %w", errContractNotDeployedAtBlock, app.application.IConsensusAddress, mostRecentBlockNumber, err) } - r.Logger.Error("Error retrieving dave consensus deployment block number", - "application", app.application.Name, - "address", app.application.IApplicationAddress, - "consensus_address", app.application.IConsensusAddress, - "error", err, - ) - return fmt.Errorf("failed to retrieve DaveConsensus deployment block: %w", err) + return 0, fmt.Errorf("failed to retrieve DaveConsensus deployment block: %w", err) } - if deploymentBlock.Sign() <= 0 { - r.Logger.Error("Invalid dave consensus deployment block number retrieved", - "application", app.application.Name, - "address", app.application.IApplicationAddress, - "consensus_address", app.application.IConsensusAddress, - "block_number", deploymentBlock.Uint64(), - ) - return errors.New("invalid dave consensus deployment block number retrieved") + if deploymentBlock == nil || !deploymentBlock.IsUint64() || deploymentBlock.Sign() == 0 || + deploymentBlock.Uint64() > mostRecentBlockNumber { + return 0, fmt.Errorf("invalid DaveConsensus deployment block %v at head %d", deploymentBlock, mostRecentBlockNumber) } - lastEpochCheckBlock := deploymentBlock.Uint64() - 1 - err = r.repository.UpdateEventLastCheckBlock(ctx, []int64{app.application.ID}, MonitoredEvent_EpochSealed, lastEpochCheckBlock) - if err != nil { - r.Logger.Error("Failed to update application LastEpochCheckBlock", - "application", app.application.Name, - "last_epoch_check_block", lastEpochCheckBlock, - "error", err, - ) - return err - } - r.Logger.Debug("Application sealed epoch sync initialized", - "application", app.application.Name, - "deployment_block", deploymentBlock.Uint64(), - "next_search_block", lastEpochCheckBlock+1, - "current_block", mostRecentBlockNumber, - ) - app.application.LastEpochCheckBlock = lastEpochCheckBlock - return nil + // This is a local search floor, not evidence of a completed scan. Persisting + // it before the constructor seal is stored could leave a cursor with no epoch. + return deploymentBlock.Uint64(), nil } func (r *Service) scanDaveConsensusEpochsAndInputs( @@ -88,24 +63,16 @@ func (r *Service) scanDaveConsensusEpochsAndInputs( mostRecentBlockNumber uint64, ) bool { success := true - if !r.inputReaderEnabled { - return success - } - r.Logger.Debug("Checking for new epochs and inputs", "apps", applications) // Process each application individually since each has its own DaveConsensus contract for _, app := range applications { if app.inputSource == nil { - // Alpha.6 permits data-availability encodings that this node does not - // support. A missing adapter is therefore a capability mismatch, not - // evidence that the application's persisted state is corrupted. success = false - r.Logger.Error("Cannot scan DaveConsensus epochs: configured input source is unsupported", + r.Logger.Error("Cannot scan DaveConsensus epochs: InputBox adapter is missing", "application", app.application.Name, "address", app.application.IApplicationAddress, "input_box", app.application.IInputBoxAddress, - "data_availability", app.application.DataAvailability, ) continue } @@ -119,6 +86,19 @@ func (r *Service) scanDaveConsensusEpochsAndInputs( if errors.Is(err, context.Canceled) { return false // shutting down } + if errors.Is(err, errContractNotDeployedAtBlock) { + // Registration can precede deployment visibility at the configured + // block. Neither sealed nor open epochs can be observed yet. + continue + } + if errors.Is(err, errApplicationStatusReported) { + // Only a recorded integrity status makes this fault app-local. + // A failed escalation from an execution terminal still fails the scan. + if app.application.Status != ApplicationStatus_Corrupted && app.application.Status != ApplicationStatus_Diverged { + success = false + } + continue + } success = false r.Logger.Error("Error processing application sealed epochs", "application", app.application.Name, @@ -135,6 +115,12 @@ func (r *Service) scanDaveConsensusEpochsAndInputs( if errors.Is(err, context.Canceled) { return false // shutting down } + if errors.Is(err, errApplicationStatusReported) { + if app.application.Status != ApplicationStatus_Corrupted && app.application.Status != ApplicationStatus_Diverged { + success = false + } + continue + } success = false r.Logger.Error("Error processing application open epoch", "application", app.application.Name, @@ -151,23 +137,23 @@ func (r *Service) processApplicationSealedEpochs( app appContracts, mostRecentBlockNumber uint64, ) error { - // Find the starting block for epoch search - if app.application.LastEpochCheckBlock == 0 { - err := r.initializeNewApplicationSealedEpochSync(ctx, &app, mostRecentBlockNumber) + lastEpochCheckBlock := app.application.LastEpochCheckBlock + initializing := lastEpochCheckBlock == 0 + if initializing { + deploymentBlock, err := r.initialSealedEpochSearchBlock(ctx, app, mostRecentBlockNumber) if err != nil { - if errors.Is(err, errContractNotDeployedAtBlock) { - return nil - } - r.Logger.Error("Failed to initialize application sealed epoch sync", - "application", app.application.Name, - "most_recent_block", mostRecentBlockNumber, - "error", err, - ) return fmt.Errorf("failed to determine start block for epoch search: %w", err) } + lastEpochCheckBlock = deploymentBlock - 1 } - if mostRecentBlockNumber < app.application.LastEpochCheckBlock { + if mostRecentBlockNumber < lastEpochCheckBlock { + if app.application.ForecloseBlock != 0 && app.application.ForecloseBlock == mostRecentBlockNumber { + r.Logger.Debug("Sealed epoch scan already covers foreclosure block", + "application", app.application.Name, "foreclose_block", mostRecentBlockNumber, + "last_epoch_check_block", lastEpochCheckBlock) + return nil // The independent input cursor can still require a drain. + } r.Logger.Warn( "Not reading sealed epochs: most recent block is lower than the last processed one", "application", app.application.Name, "address", app.application.IApplicationAddress, @@ -175,7 +161,7 @@ func (r *Service) processApplicationSealedEpochs( "most_recent_block", mostRecentBlockNumber, ) return nil - } else if mostRecentBlockNumber == app.application.LastEpochCheckBlock { + } else if mostRecentBlockNumber == lastEpochCheckBlock { r.Logger.Debug("Not reading sealed epochs: already checked the most recent blocks", "application", app.application.Name, "address", app.application.IApplicationAddress, "last_epoch_check_block", app.application.LastEpochCheckBlock, @@ -184,10 +170,10 @@ func (r *Service) processApplicationSealedEpochs( return nil } - nextSearchBlock := app.application.LastEpochCheckBlock + 1 + nextSearchBlock := lastEpochCheckBlock + 1 r.Logger.Debug("Checking sealed epochs for application", "application", app.application.Name, - "last_epoch_check_block", app.application.LastEpochCheckBlock, + "last_epoch_check_block", lastEpochCheckBlock, "next_search_block", nextSearchBlock, "most_recent_block", mostRecentBlockNumber, ) @@ -208,31 +194,27 @@ func (r *Service) processApplicationSealedEpochs( return sealedEpoch.EpochNumber, nil } - // Create onHit function that processes epoch transitions - onHit := func(block uint64) error { - r.Logger.Debug("Epoch transition found", "application", app.application.Name, "block", block) - return r.processEpochTransition(ctx, app, block) - } - + // A failed window may already have stored a prefix of its epochs. Seed from + // the completed scan boundary, not the newest row, so that prefix is replayed. prevValue := big.NewInt(-1) - lastEpoch, err := r.repository.GetLastNonOpenEpoch(ctx, app.application.IApplicationAddress.String()) - if err != nil { - return fmt.Errorf("failed to get last non open epoch: %w", err) + if !initializing { + var err error + prevValue, err = oracle(ctx, lastEpochCheckBlock) + if err != nil { + return err + } } - if lastEpoch != nil { - prevValue = new(big.Int).SetUint64(lastEpoch.Index) - // assert that the last epoch's last block is less than the next search block - if lastEpoch.LastBlock > nextSearchBlock { - return r.setApplicationCorrupted(ctx, app.application, - "application last non open epoch last block %d is greater than next search block %d", - lastEpoch.LastBlock, - nextSearchBlock, - ) + previousTransitionValue := prevValue + onHit := func(block uint64) error { + value, err := r.processEpochTransition(ctx, app, block, previousTransitionValue) + if err == nil { + previousTransitionValue = value } + return err } // Use FindTransitions to find epoch transitions - _, err = ethutil.FindTransitions(ctx, nextSearchBlock, mostRecentBlockNumber, prevValue, oracle, onHit) + _, err := ethutil.FindTransitions(ctx, nextSearchBlock, mostRecentBlockNumber, prevValue, oracle, onHit) if err != nil { return fmt.Errorf("failed to walk epoch transitions: %w", err) } @@ -242,6 +224,12 @@ func (r *Service) processApplicationSealedEpochs( if err != nil { return fmt.Errorf("failed to update last epoch check block: %w", err) } + if initializing { + r.Logger.Info("Application sealed epoch sync initialized", + "application", app.application.Name, "consensus_address", app.application.IConsensusAddress, + "deployment_block", nextSearchBlock, + "last_epoch_check_block", mostRecentBlockNumber) + } r.Logger.Debug("Sealed epoch search completed", "application", app.application.Name, "most_recent_block", mostRecentBlockNumber) @@ -252,7 +240,8 @@ func (r *Service) processEpochTransition( ctx context.Context, app appContracts, transitionBlock uint64, -) error { + previousEpochNumber *big.Int, +) (*big.Int, error) { r.Logger.Debug("Processing epoch transition", "application", app.application.Name, "block", transitionBlock) // Get the sealed epoch information at this block @@ -263,17 +252,9 @@ func (r *Service) processEpochTransition( sealedEpoch, err := app.daveConsensus.GetCurrentSealedEpoch(callOpts) if err != nil { - return fmt.Errorf("failed to get sealed epoch at transition block %d: %w", transitionBlock, err) + return nil, fmt.Errorf("failed to get sealed epoch at transition block %d: %w", transitionBlock, err) } - r.Logger.Info("Found sealed epoch event", - "application", app.application.Name, - "block", transitionBlock, - "epoch_number", sealedEpoch.EpochNumber, - "input_lower_bound", sealedEpoch.InputIndexLowerBound, - "input_upper_bound", sealedEpoch.InputIndexUpperBound, - "tournament", sealedEpoch.Tournament) - // Retrieve the actual EpochSealed events for this transition filterOpts := &bind.FilterOpts{ Context: ctx, @@ -283,22 +264,45 @@ func (r *Service) processEpochTransition( sealedEvents, err := app.daveConsensus.RetrieveSealedEpochs(filterOpts) if err != nil { - return fmt.Errorf("failed to retrieve sealed epoch events at block %d: %w", transitionBlock, err) + return nil, fmt.Errorf("failed to retrieve sealed epoch events at block %d: %w", transitionBlock, err) + } + + // Validate the whole block before storing any event. Several epochs can seal + // in one block, so a nonempty page can still omit part of the transition. + if sealedEpoch.EpochNumber == nil || !sealedEpoch.EpochNumber.IsUint64() { + return nil, fmt.Errorf("invalid sealed epoch number at block %d: %v", transitionBlock, sealedEpoch.EpochNumber) + } + expectedCount := new(big.Int).Sub(sealedEpoch.EpochNumber, previousEpochNumber) + if expectedCount.Sign() <= 0 || !expectedCount.IsUint64() || expectedCount.Uint64() != uint64(len(sealedEvents)) { + return nil, fmt.Errorf("sealed epoch event count mismatch at block %d: expected %s, got %d", + transitionBlock, expectedCount, len(sealedEvents)) + } + expectedEpoch := new(big.Int).Set(previousEpochNumber) + for _, event := range sealedEvents { + expectedEpoch.Add(expectedEpoch, big.NewInt(1)) + if event == nil || event.EpochNumber == nil || event.EpochNumber.Cmp(expectedEpoch) != 0 || + event.Raw.BlockNumber != transitionBlock { + return nil, fmt.Errorf("invalid sealed epoch event sequence at block %d: expected epoch %s", transitionBlock, expectedEpoch) + } + } + lastEvent := sealedEvents[len(sealedEvents)-1] + if lastEvent.InputIndexLowerBound.Cmp(sealedEpoch.InputIndexLowerBound) != 0 || + lastEvent.InputIndexUpperBound.Cmp(sealedEpoch.InputIndexUpperBound) != 0 || lastEvent.Tournament != sealedEpoch.Tournament { + return nil, fmt.Errorf("last sealed epoch event does not match current sealed epoch at block %d", transitionBlock) } + r.Logger.Info("Found sealed epoch events", + "application", app.application.Name, "block", transitionBlock, + "epoch_number", sealedEpoch.EpochNumber, "count", len(sealedEvents)) // Process each sealed epoch event for _, event := range sealedEvents { err := r.processSealedEpochEvent(ctx, app, event) if err != nil { - r.Logger.Error("Error processing sealed epoch event", - "epoch_number", event.EpochNumber, - "block", transitionBlock, - "error", err) - return fmt.Errorf("failed to process sealed epoch event at block %d: %w", transitionBlock, err) + return nil, fmt.Errorf("failed to process sealed epoch event at block %d: %w", transitionBlock, err) } } - return nil + return sealedEpoch.EpochNumber, nil } func (r *Service) processSealedEpochEvent( @@ -312,6 +316,11 @@ func (r *Service) processSealedEpochEvent( "input_upper_bound", event.InputIndexUpperBound, "tournament", event.Tournament) + // A seal is emitted by a transaction, so it cannot occur at genesis. + if event.Raw.BlockNumber == 0 { + return errors.New("sealed epoch event has block number zero") + } + firstBlock := uint64(0) epochNumber := event.EpochNumber.Uint64() if epochNumber == 0 { @@ -362,6 +371,15 @@ func (r *Service) processSealedEpochEvent( if epoch.FirstBlock != firstBlock || epoch.InputIndexLowerBound != event.InputIndexLowerBound.Uint64() { return fmt.Errorf("epoch %d data mismatch with sealed event", epoch.Index) } + if epoch.Status != EpochStatus_Open { + if epoch.LastBlock != event.Raw.BlockNumber || epoch.InputIndexUpperBound != event.InputIndexUpperBound.Uint64() || + epoch.TournamentAddress == nil || *epoch.TournamentAddress != event.Tournament { + return fmt.Errorf("sealed epoch %d data mismatch with replayed event", epoch.Index) + } + // The epoch and its inputs were stored atomically. Preserve any later + // claim/proof progress when replaying a partially completed scan. + return nil + } epoch.LastBlock = event.Raw.BlockNumber epoch.InputIndexUpperBound = event.InputIndexUpperBound.Uint64() epoch.TournamentAddress = &event.Tournament @@ -396,7 +414,9 @@ func (r *Service) processSealedEpochEvent( len(inputs)) } } - // Store epoch and inputs + // The seal covers every input before its transaction, but later inputs in + // the same block belong to the next epoch. Publish only complete blocks; + // the open-epoch scan must finish this boundary block before drain is safe. epochInputMap := map[*Epoch][]*Input{epoch: inputs} r.Logger.Debug("Storing sealed epoch", "application", app.application.Name, "epoch_number", epoch.Index) @@ -405,9 +425,13 @@ func (r *Service) processSealedEpochEvent( ctx, app.application.IApplicationAddress.String(), epochInputMap, - event.Raw.BlockNumber, + event.Raw.BlockNumber-1, ) if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.setApplicationCorrupted(ctx, app.application, + "sealed epoch %d input L1 log identity conflicts with stored data; operator reset required: %v", epoch.Index, err) + } return fmt.Errorf("failed to store epoch and inputs: %w", err) } @@ -425,10 +449,8 @@ func (r *Service) processApplicationOpenEpoch( app appContracts, mostRecentBlockNumber uint64, ) error { - // This guard uses the tick-start application snapshot. Sealed-epoch - // processing earlier in this same tick can advance the DB input cursor to - // mostRecentBlockNumber, but the open epoch may still need to scan that - // boundary block. The fresh DB cursor is read below and scanned inclusively. + // The input cursor covers complete blocks. A sealed batch in this tick + // can advance it only to the block before its seal. if mostRecentBlockNumber < app.application.LastInputCheckBlock { r.Logger.Warn( "Not checking for inputs on current open epoch: most recent block is lower than the last processed one", @@ -481,6 +503,9 @@ func (r *Service) processApplicationOpenEpoch( if err != nil { return fmt.Errorf("failed to get last input check block: %w", err) } + if lastInputCheckBlock >= mostRecentBlockNumber { + return nil + } // Fetch inputs for this epoch from the InputBox inputCount, err := r.repository.GetNumberOfInputs( @@ -490,13 +515,17 @@ func (r *Service) processApplicationOpenEpoch( "failed to get number of inputs from repository: %w", err) } prevValue := new(big.Int).SetUint64(inputCount) - inputs, _, err := r.fetchInputs(ctx, app, - lastInputCheckBlock, mostRecentBlockNumber, + inputs, endCount, err := r.fetchInputs(ctx, app, + lastInputCheckBlock+1, mostRecentBlockNumber, prevValue, openEpoch.InputIndexLowerBound, math.MaxUint64) if err != nil { return fmt.Errorf("failed to fetch inputs for epoch %d: %w", openEpoch.Index, err) } + if endCount < inputCount || endCount-inputCount != uint64(len(inputs)) { + return fmt.Errorf("open epoch %d input count mismatch: stored %d, on-chain %d, got %d", + openEpoch.Index, inputCount, endCount, len(inputs)) + } // increase the upper bound according to the number of fetched inputs openEpoch.InputIndexUpperBound += uint64(len(inputs)) @@ -518,6 +547,10 @@ func (r *Service) processApplicationOpenEpoch( ) if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.setApplicationCorrupted(ctx, app.application, + "open epoch %d input L1 log identity conflicts with stored data; operator reset required: %v", openEpoch.Index, err) + } return fmt.Errorf("failed to store epoch and inputs: %w", err) } diff --git a/internal/evmreader/sealedepochs_checkpoint_test.go b/internal/evmreader/sealedepochs_checkpoint_test.go new file mode 100644 index 000000000..38a4472df --- /dev/null +++ b/internal/evmreader/sealedepochs_checkpoint_test.go @@ -0,0 +1,168 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "io" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// A sealed batch contains only inputs before the seal transaction, not the +// later inputs in that block. A failed open scan must not publish the block as +// complete, even if the sealed scan has finished and the reader restarts. +func TestDaveInputCheckpointAfterFailedOpenScan(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + ctx := context.Background() + release, err := db.LockTestPostgres(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(release) + + for _, test := range []struct { + name string + sealedSizes []uint64 + cancelWrite bool + }{ + {name: "empty seal then RPC failure", sealedSizes: []uint64{0}}, + {name: "input seal input then RPC failure", sealedSizes: []uint64{1}}, + {name: "two seals then cancelled write", sealedSizes: []uint64{1, 1}, cancelWrite: true}, + } { + t.Run(test.name, func(t *testing.T) { + const ( + previousSealBlock uint64 = 50 + forecloseBlock uint64 = 100 + ) + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { repo.Close() }) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(ctx, t, repo) + address := app.IApplicationAddress.Hex() + previousEpoch := &Epoch{Index: 0, FirstBlock: 10, LastBlock: previousSealBlock, Status: EpochStatus_Closed} + require.NoError(t, repo.CreateEpochsAndInputs(ctx, address, + map[*Epoch][]*Input{previousEpoch: nil}, forecloseBlock-2)) + require.NoError(t, repo.UpdateEventLastCheckBlock(ctx, []int64{app.ID}, + MonitoredEvent_EpochSealed, forecloseBlock-2)) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + + var sealedInputs uint64 + var events []*idaveconsensus.IDaveConsensusEpochSealed + for i, size := range test.sealedSizes { + events = append(events, makeSealedEpochEvent(int64(i+1), sealedInputs, sealedInputs+size, + forecloseBlock, repotest.UniqueAddress())) + sealedInputs += size + } + lastEvent := events[len(events)-1] + dave := newMockDaveConsensus() + dave.On("GetCurrentSealedEpoch", blockRange(0, forecloseBlock)). + Return(makeSealedEpochResult(0, 0, 0, common.Address{}), nil) + dave.On("GetCurrentSealedEpoch", blockFrom(forecloseBlock)).Return(makeSealedEpochResult( + lastEvent.EpochNumber.Int64(), lastEvent.InputIndexLowerBound.Uint64(), sealedInputs, lastEvent.Tournament), nil) + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == forecloseBlock && opts.End != nil && *opts.End == forecloseBlock + })).Return(events, nil).Once() + + inputBox := newMockInputBox() + inputBox.On("GetNumberOfInputs", blockRange(0, forecloseBlock), app.IApplicationAddress). + Return(big.NewInt(0), nil).Maybe() + inputBox.On("GetNumberOfInputs", blockFrom(forecloseBlock), app.IApplicationAddress). + Return(new(big.Int).SetUint64(sealedInputs+1), nil) + inputEvents := make([]iinputbox.IInputBoxInputAdded, 0, sealedInputs+1) + for index := uint64(0); index <= sealedInputs; index++ { + inputEvents = append(inputEvents, makeInputEvent(app.IApplicationAddress, index, forecloseBlock)) + } + inputBlock := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == forecloseBlock && opts.End != nil && *opts.End == forecloseBlock + }) + inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything). + Return(inputEvents, nil) + contracts := appContracts{application: app, inputSource: inputBox, daveConsensus: dave} + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + require.NoError(t, reader.processApplicationSealedEpochs(ctx, contracts, forecloseBlock)) + + // Interrupt the open scan either at its RPC read or before the input + // transaction starts. No open epoch or final input may be published. + inputBox.Unset("RetrieveInputs") + openCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + failedRead := inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything).Once() + if test.cancelWrite { + failedRead.Run(func(mock.Arguments) { cancel() }).Return(inputEvents, nil) + } else { + failedRead.Return([]iinputbox.IInputBoxInputAdded{}, context.Canceled) + } + require.ErrorIs(t, reader.processApplicationOpenEpoch(openCtx, contracts, forecloseBlock), context.Canceled) + count, err := repo.GetNumberOfInputs(ctx, address) + require.NoError(t, err) + require.Equal(t, sealedInputs, count) + openEpochIndex := lastEvent.EpochNumber.Uint64() + 1 + openEpoch, err := repo.GetEpoch(ctx, address, openEpochIndex) + require.NoError(t, err) + require.Nil(t, openEpoch) + + // Recreate both the repository connection and reader, then discover + // foreclosure in the same block. No tick-start snapshot survives. + repo.Close() + repo, err = factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + require.NoError(t, repo.UpdateApplicationForeclosure(ctx, app.ID, forecloseBlock, + repotest.UniqueHash(), forecloseBlock)) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + assert.Equal(t, forecloseBlock, app.LastEpochCheckBlock) + assert.Equal(t, forecloseBlock-1, app.LastInputCheckBlock, "the seal does not cover the whole block") + assert.False(t, app.ForeclosureScanCaughtUp(), "drain must wait for the missing open input") + + inputBox.Unset("RetrieveInputs") + inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything). + Return(inputEvents, nil).Once() + contracts.application = app + reader = &Service{repository: repo} + reader.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + reader.scanDaveConsensusEpochsAndInputs(ctx, []appContracts{contracts}, forecloseBlock+10) + + input, err := repo.GetInput(ctx, address, sealedInputs) + require.NoError(t, err) + require.NotNil(t, input, "retry must fetch the input after the last seal") + assert.Equal(t, openEpochIndex, input.EpochIndex) + assert.Equal(t, forecloseBlock, input.BlockNumber) + openEpoch, err = repo.GetEpoch(ctx, address, openEpochIndex) + require.NoError(t, err) + require.NotNil(t, openEpoch) + assert.Equal(t, forecloseBlock, openEpoch.FirstBlock) + assert.Equal(t, forecloseBlock, openEpoch.LastBlock) + assert.Equal(t, sealedInputs, openEpoch.InputIndexLowerBound) + assert.Equal(t, sealedInputs+1, openEpoch.InputIndexUpperBound) + assert.Equal(t, EpochStatus_Open, openEpoch.Status) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + assert.Equal(t, forecloseBlock, app.LastInputCheckBlock) + assert.True(t, app.ForeclosureScanCaughtUp()) + undrained, err := repo.HasUndrainedEpochsBeforeBlock(ctx, app.ID, forecloseBlock) + require.NoError(t, err) + assert.True(t, undrained, "the stored input must remain visible to the machine drain") + dave.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_cursor_test.go b/internal/evmreader/sealedepochs_cursor_test.go new file mode 100644 index 000000000..70f11773e --- /dev/null +++ b/internal/evmreader/sealedepochs_cursor_test.go @@ -0,0 +1,92 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestOpenEpochSkipsAlreadyScannedFreshCursor(t *testing.T) { + const head uint64 = 200 + for _, test := range []struct { + name string + cursor uint64 + }{ + {name: "equal to head", cursor: head}, + {name: "above head", cursor: head + 1}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, IApplicationAddress: app1Addr, LastInputCheckBlock: head - 1, + }, + inputSource: inputBox, + } + sealed := &model.Epoch{Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed} + open := &model.Epoch{Index: 1, FirstBlock: sealed.LastBlock, LastBlock: test.cursor, Status: model.EpochStatus_Open} + before := *open + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(sealed, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), open.Index).Return(open, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(test.cursor, nil).Once() + + require.NoError(t, r.processApplicationOpenEpoch(t.Context(), app, head)) + require.Equal(t, before, *open, "the stored epoch must not be rewritten to an older head") + require.Empty(t, inputBox.Calls) + repo.AssertNotCalled(t, "GetNumberOfInputs", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func TestForeclosureInputDrainRunsBelowSealedCursor(t *testing.T) { + const ( + sealedCursor uint64 = 200 + foreclosure uint64 = 180 + inputCursor uint64 = 150 + ) + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(inputBox, dave) + app.application.Status = model.ApplicationStatus_MachineHalted + app.application.ForecloseBlock = foreclosure + app.application.LastEpochCheckBlock = sealedCursor + app.application.LastInputCheckBlock = inputCursor + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: 100, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(inputCursor, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(inputCursor+1, foreclosure+1), app1Addr). + Return(new(big.Int), nil).Twice() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch, inputs := range epochs { + return len(epochs) == 1 && epoch.LastBlock == foreclosure && epoch.Status == model.EpochStatus_Open && len(inputs) == 0 + } + return false + }), foreclosure).Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, sealedCursor+10)) + require.Equal(t, model.ApplicationStatus_MachineHalted, app.application.Status) + require.Empty(t, dave.Calls, "the sealed cursor already covers foreclosure") + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) +} diff --git a/internal/evmreader/sealedepochs_deployment_test.go b/internal/evmreader/sealedepochs_deployment_test.go new file mode 100644 index 000000000..0af52939f --- /dev/null +++ b/internal/evmreader/sealedepochs_deployment_test.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func (s *SealedEpochsSuite) TestDaveScanWaitsForDeploymentAndResumes() { + const ( + beforeDeployment uint64 = 90 + deploymentBlock uint64 = 100 + ) + var output bytes.Buffer + s.evmReader.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "new-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, IInputBoxBlock: 10, + }, + inputSource: s.inputBox, daveConsensus: s.dave, + } + s.dave.On("GetDeploymentBlockNumber", blockRange(beforeDeployment, beforeDeployment+1)). + Return(new(big.Int), bind.ErrNoCode).Once() + + // The absent contract must not prevent a later application from being scanned. + otherInputBox := newMockInputBox() + other := appContracts{ + application: &model.Application{ + ID: 2, Name: "existing-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app2Addr, LastEpochCheckBlock: beforeDeployment, + LastInputCheckBlock: beforeDeployment - 1, + }, + inputSource: otherInputBox, + } + s.repository.On("GetLastNonOpenEpoch", s.ctx, app2Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: beforeDeployment - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + s.repository.On("GetEpoch", s.ctx, app2Addr.Hex(), uint64(1)).Return(nil, nil).Once() + s.repository.On("GetEventLastCheckBlock", s.ctx, other.application.ID, model.MonitoredEvent_InputAdded). + Return(beforeDeployment-1, nil).Once() + s.repository.On("GetNumberOfInputs", s.ctx, app2Addr.Hex()).Return(uint64(0), nil).Once() + otherInputBox.On("GetNumberOfInputs", blockRange(beforeDeployment, beforeDeployment+1), app2Addr). + Return(new(big.Int), nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app2Addr.Hex(), mock.Anything, beforeDeployment). + Return(nil).Once() + + s.True(s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, []appContracts{app, other}, beforeDeployment)) + s.Equal(model.ApplicationStatus_OK, app.application.Status) + s.Zero(app.application.LastEpochCheckBlock) + s.Zero(app.application.LastInputCheckBlock) + s.repository.AssertNotCalled(s.T(), "GetLastNonOpenEpoch", s.ctx, app1Addr.Hex()) + s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + s.repository.AssertCalled(s.T(), "CreateEpochsAndInputs", s.ctx, app2Addr.Hex(), mock.Anything, beforeDeployment) + s.Empty(s.inputBox.Calls) + s.NotContains(output.String(), "level=ERROR") + s.repository.AssertExpectations(s.T()) + s.dave.AssertExpectations(s.T()) + otherInputBox.AssertExpectations(s.T()) + + // The configured head now includes deployment. The same registered app must + // ingest the constructor's sealed epoch and then create its open epoch. + tournament := common.HexToAddress("0x1234") + s.dave.On("GetDeploymentBlockNumber", blockRange(deploymentBlock, deploymentBlock+1)). + Return(new(big.Int).SetUint64(deploymentBlock), nil).Once() + s.repository.On("UpdateEventLastCheckBlock", s.ctx, []int64{app.application.ID}, + model.MonitoredEvent_EpochSealed, deploymentBlock).Return(nil).Once() + sealed := &model.Epoch{ + Index: 0, FirstBlock: app.application.IInputBoxBlock, LastBlock: deploymentBlock, + Status: model.EpochStatus_Closed, TournamentAddress: &tournament, + } + s.repository.On("GetLastNonOpenEpoch", s.ctx, app1Addr.Hex()).Return(sealed, nil).Once() + s.repository.On("GetEpoch", s.ctx, app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", s.ctx, app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + s.dave.On("GetCurrentSealedEpoch", blockRange(deploymentBlock, deploymentBlock+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Twice() + s.dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == deploymentBlock && opts.End != nil && *opts.End == deploymentBlock + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, deploymentBlock, tournament), + }, nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch := range epochs { + return len(epochs) == 1 && epoch.Index == 0 && epoch.Status == model.EpochStatus_Closed + } + return false + }), deploymentBlock-1).Return(nil).Once() + s.repository.On("GetEventLastCheckBlock", s.ctx, app.application.ID, model.MonitoredEvent_InputAdded). + Return(deploymentBlock-1, nil).Once() + s.repository.On("GetNumberOfInputs", s.ctx, app1Addr.Hex()).Return(uint64(0), nil).Once() + s.inputBox.On("GetNumberOfInputs", blockRange(deploymentBlock, deploymentBlock+1), app1Addr). + Return(new(big.Int), nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch := range epochs { + return len(epochs) == 1 && epoch.Index == 1 && epoch.Status == model.EpochStatus_Open + } + return false + }), deploymentBlock).Return(nil).Once() + + s.True(s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, []appContracts{app}, deploymentBlock)) + s.Equal(model.ApplicationStatus_OK, app.application.Status) + s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) + s.repository.AssertExpectations(s.T()) + s.dave.AssertExpectations(s.T()) + s.inputBox.AssertExpectations(s.T()) +} + +func TestFailedSealedInitializationWaitsForDeploymentAgain(t *testing.T) { + const deploymentBlock uint64 = 100 + for _, regressedHead := range []uint64{80, deploymentBlock - 1} { + t.Run(new(big.Int).SetUint64(regressedHead).String(), func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + inputBox := newMockInputBox() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(inputBox, dave) + app.application.IInputBoxBlock = 10 + app.application.Status = model.ApplicationStatus_OK + tournament := common.HexToAddress("0x1234") + dave.On("GetDeploymentBlockNumber", blockRange(deploymentBlock, deploymentBlock+1)). + Return(new(big.Int).SetUint64(deploymentBlock), nil).Twice() + dave.On("GetCurrentSealedEpoch", blockRange(deploymentBlock, deploymentBlock+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil) + filter := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == deploymentBlock && opts.End != nil && *opts.End == deploymentBlock + }) + dave.On("RetrieveSealedEpochs", filter). + Return([]*idaveconsensus.IDaveConsensusEpochSealed{}, errors.New("log retrieval interrupted")).Once() + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, deploymentBlock)) + require.Empty(t, repo.Calls, "a failed first scan must not persist the deployment floor") + require.Zero(t, app.application.LastEpochCheckBlock) + require.NotContains(t, output.String(), "sync initialized") + require.Contains(t, output.String(), "log retrieval interrupted") + output.Reset() + + dave.On("GetDeploymentBlockNumber", blockRange(regressedHead, regressedHead+1)). + Return(new(big.Int), bind.ErrNoCode).Once() + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, regressedHead)) + require.Empty(t, repo.Calls, "the open scan must not run before deployment is visible again") + require.Zero(t, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Empty(t, output.String(), "ordinary deployment lag needs no Info or Error log") + + dave.On("RetrieveSealedEpochs", filter).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, deploymentBlock, tournament), + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, deploymentBlock-1).Return(nil).Once() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_EpochSealed, deploymentBlock).Return(nil).Once() + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, FirstBlock: 10, LastBlock: deploymentBlock, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(deploymentBlock-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(deploymentBlock, deploymentBlock+1), app1Addr). + Return(new(big.Int), nil).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, deploymentBlock).Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, deploymentBlock)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Contains(t, output.String(), "sync initialized") + require.NotContains(t, output.String(), "level=ERROR") + repo.AssertExpectations(t) + dave.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_identity_test.go b/internal/evmreader/sealedepochs_identity_test.go new file mode 100644 index 000000000..b2239fcad --- /dev/null +++ b/internal/evmreader/sealedepochs_identity_test.go @@ -0,0 +1,209 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "fmt" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + daveOpenScan = "open" + daveSealedScan = "sealed" +) + +func TestDaveInputStoreErrorClassification(t *testing.T) { + const head uint64 = 100 + for _, sealed := range []bool{true, false} { + path := daveOpenScan + if sealed { + path = daveSealedScan + } + for _, test := range []struct { + name string + cause error + corrupt bool + }{ + {name: "identity conflict", cause: repository.ErrInputLogIdentityConflict, corrupt: true}, + {name: "retryable database error", cause: errors.New("database unavailable")}, + } { + t.Run(path+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "input-store-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxBlock: head, LastEpochCheckBlock: head - 1, LastInputCheckBlock: head - 1, + }, + inputSource: inputBox, daveConsensus: dave, + } + checkpoint := head + if sealed { + checkpoint-- + tournament := common.HexToAddress("0x1234") + app.application.LastEpochCheckBlock = 0 + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int).SetUint64(head), nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + dave.On("GetCurrentSealedEpoch", blockRange(head, head+1)). + Return(makeSealedEpochResult(0, 0, 1, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 1, head, tournament), + }, nil).Once() + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + } + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), app1Addr).Return(big.NewInt(1), nil).Once() + inputBox.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + }), []common.Address{app1Addr}, mock.Anything). + Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 0, head)}, nil).Once() + storeErr := fmt.Errorf("insert inputs: %w", test.cause) + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, checkpoint). + Return(storeErr).Once() + if test.corrupt { + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, + model.ApplicationStatus_Corrupted, mock.Anything).Return(nil).Once() + } + + var err error + if sealed { + err = r.processApplicationSealedEpochs(t.Context(), app, head) + } else { + err = r.processApplicationOpenEpoch(t.Context(), app, head) + } + require.ErrorContains(t, err, storeErr.Error()) + if test.corrupt { + require.Equal(t, model.ApplicationStatus_Corrupted, app.application.Status) + require.ErrorContains(t, err, path+" epoch") + require.ErrorContains(t, err, "operator reset required") + } else { + require.ErrorIs(t, err, test.cause) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + if sealed { + require.Zero(t, app.application.LastEpochCheckBlock) + } else { + require.Equal(t, head-1, app.application.LastEpochCheckBlock) + } + require.Equal(t, head-1, app.application.LastInputCheckBlock) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } + } +} + +func TestDaveRecordedIntegrityFaultKeepsOtherApplicationsScanning(t *testing.T) { + const head uint64 = 100 + for _, path := range []string{daveSealedScan, daveOpenScan} { + for _, test := range []struct { + name string + status model.ApplicationStatus + statusWriteErr error + }{ + {name: "new fault", status: model.ApplicationStatus_OK}, + {name: "already corrupted", status: model.ApplicationStatus_Corrupted}, + {name: "already diverged", status: model.ApplicationStatus_Diverged}, + {name: "escalate halted machine", status: model.ApplicationStatus_MachineHalted}, + {name: "failed integrity write", status: model.ApplicationStatus_OK, statusWriteErr: errors.New("write unavailable")}, + {name: "failed terminal escalation", status: model.ApplicationStatus_MachineHalted, + statusWriteErr: errors.New("write unavailable")}, + } { + t.Run(path+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + bad := newDaveAppContracts(inputBox, dave) + bad.application.Status = test.status + bad.application.IInputBoxBlock = head + bad.application.LastEpochCheckBlock = head + bad.application.LastInputCheckBlock = head - 1 + good := newDaveAppContracts(inputBox, nil) + good.application.ID = 2 + good.application.IApplicationAddress = app2Addr + good.application.Status = model.ApplicationStatus_OK + good.application.LastEpochCheckBlock = head + good.application.LastInputCheckBlock = head - 1 + + checkpoint := head + if path == daveSealedScan { + checkpoint-- + bad.application.LastEpochCheckBlock = 0 + tournament := common.HexToAddress("0x1234") + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int).SetUint64(head), nil).Once() + dave.On("GetCurrentSealedEpoch", blockRange(head, head+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", mock.Anything).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, head, tournament), + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + } + for _, app := range []appContracts{bad, good} { + if app.application.ID == bad.application.ID && path == daveSealedScan { + continue + } + address := app.application.IApplicationAddress + repo.On("GetLastNonOpenEpoch", t.Context(), address.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), address.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), address.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), address).Return(new(big.Int), nil).Once() + } + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, checkpoint). + Return(repository.ErrInputLogIdentityConflict).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app2Addr.Hex(), mock.Anything, head).Return(nil).Once() + integrityRecorded := test.status == model.ApplicationStatus_Corrupted || test.status == model.ApplicationStatus_Diverged + if !integrityRecorded { + repo.On("UpdateApplicationStatus", t.Context(), bad.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(test.statusWriteErr).Once() + } + + require.Equal(t, test.statusWriteErr == nil, + r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{bad, good}, head)) + if !integrityRecorded && test.statusWriteErr == nil { + require.Equal(t, model.ApplicationStatus_Corrupted, bad.application.Status) + } else { + require.Equal(t, test.status, bad.application.Status) + } + require.Equal(t, model.ApplicationStatus_OK, good.application.Status) + require.Equal(t, head-1, bad.application.LastInputCheckBlock) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/sealedepochs_logging_test.go b/internal/evmreader/sealedepochs_logging_test.go new file mode 100644 index 000000000..0f3e37cfd --- /dev/null +++ b/internal/evmreader/sealedepochs_logging_test.go @@ -0,0 +1,104 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveCorruptionReasonIsNotLoggedAgainOnEveryScan(t *testing.T) { + const head uint64 = 100 + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), nil) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_OK + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil).Twice() + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_Corrupted, app.application.Status) + require.Equal(t, 1, strings.Count(output.String(), "level=ERROR"), "the status helper logs the first corruption once") + require.Contains(t, output.String(), "no non open epochs found") + output.Reset() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.NotContains(t, output.String(), "level=ERROR", "the same terminal reason needs no repeated Error log") + repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestDaveStatusWriteFailureRemainsVisibleAndRetryable(t *testing.T) { + const head uint64 = 100 + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), nil) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_OK + writeErr := errors.New("status database unavailable") + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil).Twice() + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(writeErr).Twice() + + err := r.processApplicationOpenEpoch(t.Context(), app, head) + require.ErrorIs(t, err, writeErr, "the marker must preserve the actual database error") + require.ErrorIs(t, err, errApplicationStatusReported) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + output.Reset() + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Equal(t, 2, strings.Count(output.String(), "level=ERROR"), "retain the reason and the failed-write log") + require.Contains(t, output.String(), "failed to update application status") + require.Contains(t, output.String(), writeErr.Error()) + repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestDaveTerminalApplicationStillLogsObservationFailure(t *testing.T) { + const head uint64 = 100 + for _, sealed := range []bool{true, false} { + name := daveOpenScan + if sealed { + name = daveSealedScan + } + t.Run(name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), dave) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_Corrupted + readErr := errors.New("observation temporarily unavailable") + if sealed { + app.application.LastEpochCheckBlock-- + dave.On("GetCurrentSealedEpoch", blockRange(head-1, head)).Return(DaveCurrentSealedEpoch{}, readErr).Once() + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, readErr).Once() + } + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, 1, strings.Count(output.String(), "level=ERROR")) + require.Contains(t, output.String(), readErr.Error()) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_replay_test.go b/internal/evmreader/sealedepochs_replay_test.go new file mode 100644 index 000000000..f6efa977a --- /dev/null +++ b/internal/evmreader/sealedepochs_replay_test.go @@ -0,0 +1,223 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSealedEpochTransitionValidatesCompletePageBeforeWrites(t *testing.T) { + const block uint64 = 150 + tournament := common.HexToAddress("0x1234") + for _, test := range []struct { + name string + indices []int64 + change func(*idaveconsensus.IDaveConsensusEpochSealed) + }{ + {name: "empty"}, + {name: "missing first", indices: []int64{2, 3}}, + {name: "missing middle", indices: []int64{1, 3}}, + {name: "missing last", indices: []int64{1, 2}}, + {name: "duplicate", indices: []int64{1, 1, 3}}, + {name: "out of order", indices: []int64{2, 1, 3}}, + {name: "wrong block", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.Raw.BlockNumber++ + }}, + {name: "wrong lower bound", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.InputIndexLowerBound.SetUint64(1) + }}, + {name: "wrong upper bound", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.InputIndexUpperBound.SetUint64(1) + }}, + {name: "wrong tournament", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.Tournament = common.HexToAddress("0x5678") + }}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(nil, dave) + app.application.LastEpochCheckBlock = block - 1 + app.application.Status = model.ApplicationStatus_OK + events := make([]*idaveconsensus.IDaveConsensusEpochSealed, 0, len(test.indices)) + for _, index := range test.indices { + events = append(events, makeSealedEpochEvent(index, 0, 0, block, tournament)) + } + if test.change != nil { + test.change(events[len(events)-1]) + } + dave.On("GetCurrentSealedEpoch", blockRange(block-1, block)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Once() + dave.On("GetCurrentSealedEpoch", blockRange(block, block+1)). + Return(makeSealedEpochResult(3, 0, 0, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == block && opts.End != nil && *opts.End == block + })).Return(events, nil).Once() + + require.Error(t, r.processApplicationSealedEpochs(t.Context(), app, block)) + require.Empty(t, repo.Calls, "the entire page must be checked before any repository work") + require.Equal(t, block-1, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + dave.AssertExpectations(t) + }) + } +} + +// This fake makes completed per-epoch writes survive a failed scan. Reads return +// copies, as PostgreSQL does; the test does not rely on shared model pointers. +type sealedEpochReplayRepository struct { + *MockRepository + epochs map[uint64]*model.Epoch + stores []uint64 + failEpoch uint64 + storeErr error +} + +func (r *sealedEpochReplayRepository) GetEpoch(_ context.Context, _ string, index uint64) (*model.Epoch, error) { + if epoch := r.epochs[index]; epoch != nil { + copyEpoch := *epoch + return ©Epoch, nil + } + return nil, nil +} + +func (r *sealedEpochReplayRepository) CreateEpochsAndInputs( + _ context.Context, _ string, epochs map[*model.Epoch][]*model.Input, _ uint64, +) error { + for epoch := range epochs { + if epoch.Index == r.failEpoch && r.storeErr != nil { + return r.storeErr + } + copyEpoch := *epoch + r.epochs[epoch.Index] = ©Epoch + r.stores = append(r.stores, epoch.Index) + } + return nil +} + +func TestSealedEpochScanReplaysCommittedPrefix(t *testing.T) { + const ( + cursor uint64 = 100 + firstBlock uint64 = 150 + secondBlock uint64 = 175 + head uint64 = 200 + ) + tournament := common.HexToAddress("0x1234") + for _, sameBlockFailure := range []bool{true, false} { + name := "later transition page omitted" + if sameBlockFailure { + name = "second seal in same block fails to store" + } + t.Run(name, func(t *testing.T) { + repo := &sealedEpochReplayRepository{ + MockRepository: newMockRepository(), + epochs: map[uint64]*model.Epoch{0: { + Index: 0, FirstBlock: 10, LastBlock: cursor, Status: model.EpochStatus_Closed, TournamentAddress: &tournament, + }}, + } + if sameBlockFailure { + repo.failEpoch = 2 + repo.storeErr = errors.New("second seal write interrupted") + } + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(nil, dave) + app.application.LastEpochCheckBlock = cursor + app.application.IInputBoxBlock = 10 + app.application.Status = model.ApplicationStatus_OK + dave.On("GetCurrentSealedEpoch", blockRange(cursor, firstBlock)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil) + dave.On("GetCurrentSealedEpoch", blockRange(firstBlock, secondBlock)). + Return(makeSealedEpochResult(2, 0, 0, tournament), nil) + dave.On("GetCurrentSealedEpoch", blockRange(secondBlock, head+1)). + Return(makeSealedEpochResult(3, 0, 0, tournament), nil) + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == firstBlock && opts.End != nil && *opts.End == firstBlock + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(1, 0, 0, firstBlock, tournament), + makeSealedEpochEvent(2, 0, 0, firstBlock, tournament), + }, nil).Twice() + secondPage := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == secondBlock && opts.End != nil && *opts.End == secondBlock + }) + if !sameBlockFailure { + dave.On("RetrieveSealedEpochs", secondPage).Return([]*idaveconsensus.IDaveConsensusEpochSealed{}, nil).Once() + } + dave.On("RetrieveSealedEpochs", secondPage).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(3, 0, 0, secondBlock, tournament), + }, nil).Once() + repo.On("UpdateEpochClaimTransactionHash", t.Context(), app1Addr.Hex(), mock.Anything).Return(nil) + + require.Error(t, r.processApplicationSealedEpochs(t.Context(), app, head)) + require.Equal(t, cursor, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.NotNil(t, repo.epochs[1], "the failed scan must have a committed prefix") + // Other services can process that prefix before the scan retries. + repo.epochs[1].Status = model.EpochStatus_ClaimComputed + commitment := common.HexToHash("0x4567") + repo.epochs[1].Commitment = &commitment + repo.epochs[1].CommitmentProof = []common.Hash{common.HexToHash("0x89ab")} + before := *repo.epochs[1] + repo.storeErr = nil + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, model.MonitoredEvent_EpochSealed, head). + Return(nil).Once() + + require.NoError(t, r.processApplicationSealedEpochs(t.Context(), app, head)) + require.Equal(t, before, *repo.epochs[1], "replay must preserve claim and proof progress") + require.Equal(t, []uint64{1, 2, 3}, repo.stores, "already sealed rows must not be re-stored") + require.Equal(t, firstBlock, repo.epochs[2].LastBlock, "the second same-block seal must survive retry") + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } +} + +func TestSealedEpochReplayRejectsConflictingStoredFields(t *testing.T) { + const block uint64 = 100 + tournament := common.HexToAddress("0x1234") + for _, field := range []string{"last block", "upper bound", "missing tournament", "different tournament"} { + t.Run(field, func(t *testing.T) { + repo := newMockRepository() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(nil, nil) + app.application.IInputBoxBlock = 10 + epoch := &model.Epoch{Index: 0, FirstBlock: 10, LastBlock: block, + Status: model.EpochStatus_ClaimComputed, TournamentAddress: &tournament} + switch field { + case "last block": + epoch.LastBlock++ + case "upper bound": + epoch.InputIndexUpperBound++ + case "missing tournament": + epoch.TournamentAddress = nil + case "different tournament": + other := common.HexToAddress("0x5678") + epoch.TournamentAddress = &other + } + before := *epoch + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(epoch, nil).Once() + err := r.processSealedEpochEvent(t.Context(), app, makeSealedEpochEvent(0, 0, 0, block, tournament)) + require.ErrorContains(t, err, "data mismatch with replayed event") + require.Equal(t, before, *epoch) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_test.go b/internal/evmreader/sealedepochs_test.go index f2358aa3e..8818e5be8 100644 --- a/internal/evmreader/sealedepochs_test.go +++ b/internal/evmreader/sealedepochs_test.go @@ -75,22 +75,13 @@ func (s *SealedEpochsSuite) TestProcessSealedEpochFindsInputAtOverlapBlock() { tournamentAddr := common.HexToAddress("0xAAAA") - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - IInputBoxAddress: inputBoxAddr, - IInputBoxBlock: 10, - DataAvailability: DataAvailability_InputBox[:], - }, - inputSource: s.inputBox, - daveConsensus: s.dave, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 // Epoch 0 was already stored with LastBlock=100 and InputIndexUpperBound=3. - // CreateEpochsAndInputs set LastInputCheckBlock=100 for this app. + // An earlier open-epoch scan can already have reached block 100, so its + // input cursor must not determine where this sealed-epoch scan starts. // // Now epoch 1 is sealed at block 200: // FirstBlock = prevEpoch.LastBlock = 100 (PRT overlap) @@ -186,37 +177,16 @@ func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { lastEpochCheckBlock uint64 = 50 forecloseBlock uint64 = 70 ) - s.evmReader.inputReaderEnabled = true - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-prt-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - ConsensusType: Consensus_PRT, - ForecloseBlock: forecloseBlock, - LastEpochCheckBlock: lastEpochCheckBlock, - LastInputCheckBlock: forecloseBlock, - LastOutputCheckBlock: lastEpochCheckBlock, - DataAvailability: DataAvailability_InputBox[:], - }, - daveConsensus: s.dave, - inputSource: s.inputBox, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.Name = "test-prt-app" + app.application.ConsensusType = Consensus_PRT + app.application.ForecloseBlock = forecloseBlock + app.application.LastEpochCheckBlock = lastEpochCheckBlock + app.application.LastInputCheckBlock = forecloseBlock + app.application.LastOutputCheckBlock = lastEpochCheckBlock - s.repository.On("GetLastNonOpenEpoch", mock.Anything, app.application.IApplicationAddress.String()). - Return(&Epoch{ - Index: 2, - LastBlock: lastEpochCheckBlock, - }, nil).Once() - - currentSealedEpoch := struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address - }{ + currentSealedEpoch := DaveCurrentSealedEpoch{ EpochNumber: big.NewInt(2), InputIndexLowerBound: big.NewInt(0), InputIndexUpperBound: big.NewInt(0), @@ -240,24 +210,14 @@ func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { func (s *SealedEpochsSuite) TestTerminalDaveConsensusAppProcessesOpenEpochToForeclosure() { const forecloseBlock uint64 = 70 - s.evmReader.inputReaderEnabled = true - app := appContracts{ - application: &Application{ - ID: 1, - Name: "test-prt-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - ConsensusType: Consensus_PRT, - Status: ApplicationStatus_MachineHalted, - ForecloseBlock: forecloseBlock, - LastEpochCheckBlock: forecloseBlock, - LastInputCheckBlock: forecloseBlock - 1, - DataAvailability: DataAvailability_InputBox[:], - }, - daveConsensus: s.dave, - inputSource: s.inputBox, - } + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.Name = "test-prt-app" + app.application.ConsensusType = Consensus_PRT + app.application.Status = ApplicationStatus_MachineHalted + app.application.ForecloseBlock = forecloseBlock + app.application.LastEpochCheckBlock = forecloseBlock + app.application.LastInputCheckBlock = forecloseBlock - 1 s.repository.On("GetLastNonOpenEpoch", mock.Anything, app.application.IApplicationAddress.String()). @@ -302,20 +262,17 @@ func (s *SealedEpochsSuite) TestTerminalDaveConsensusAppProcessesOpenEpochToFore s.dave.AssertNumberOfCalls(s.T(), "GetCurrentSealedEpoch", 0) } -func (s *SealedEpochsSuite) TestDaveConsensusWithUnsupportedInputSourceDoesNotPanic() { - s.evmReader.inputReaderEnabled = true +func (s *SealedEpochsSuite) TestDaveConsensusWithMissingInputBoxAdapterDoesNotPanic() { var logs bytes.Buffer s.evmReader.Logger = slog.New(slog.NewTextHandler(&logs, nil)) app := appContracts{ application: &Application{ ID: 1, - Name: "unsupported-input-source-prt-app", + Name: "missing-input-box-adapter-prt-app", IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, ConsensusType: Consensus_PRT, Status: ApplicationStatus_OK, - // Alpha.6's abandoned InputBoxAndEspresso experiment. The node - // intentionally supports only the official InputBox source. - DataAvailability: []byte{0x85, 0x79, 0xfd, 0x0c}, }, daveConsensus: s.dave, } @@ -324,8 +281,33 @@ func (s *SealedEpochsSuite) TestDaveConsensusWithUnsupportedInputSourceDoesNotPa s.evmReader.scanDaveConsensusEpochsAndInputs( s.ctx, []appContracts{app}, 100) }) - s.Contains(logs.String(), "configured input source is unsupported") + s.Contains(logs.String(), "InputBox adapter is missing") s.Equal(ApplicationStatus_OK, app.application.Status) s.dave.AssertNotCalled(s.T(), "GetCurrentSealedEpoch") s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) } + +func (s *SealedEpochsSuite) TestSealedEpochAtGenesisDoesNotPublishCursor() { + event := makeSealedEpochEvent(0, 0, 0, 0, common.HexToAddress("0xAAAA")) + err := s.evmReader.processSealedEpochEvent(s.ctx, appContracts{}, event) + s.Require().ErrorContains(err, "sealed epoch event has block number zero") + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +func (s *SealedEpochsSuite) TestSealedEpochAtFirstBlockPublishesZeroCursor() { + app := appContracts{application: &Application{IApplicationAddress: app1Addr, IInputBoxBlock: 1}} + event := makeSealedEpochEvent(0, 0, 0, 1, common.HexToAddress("0xAAAA")) + s.repository.On("GetEpoch", mock.Anything, app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + s.repository.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*Epoch][]*Input) bool { + if len(epochs) != 1 { + return false + } + for epoch, inputs := range epochs { + return epoch.FirstBlock == 1 && epoch.LastBlock == 1 && len(inputs) == 0 + } + return false + }), uint64(0)).Return(nil).Once() + s.Require().NoError(s.evmReader.processSealedEpochEvent(s.ctx, app, event)) + s.repository.AssertExpectations(s.T()) +} diff --git a/internal/evmreader/service.go b/internal/evmreader/service.go index 56d62edb6..287f85001 100644 --- a/internal/evmreader/service.go +++ b/internal/evmreader/service.go @@ -38,7 +38,6 @@ type Service struct { chainID uint64 defaultBlock DefaultBlock hasEnabledApps bool - inputReaderEnabled bool lastBlockNumber atomic.Uint64 lastSuccessfulPoll atomic.Pointer[time.Time] consecutiveScanFailures atomic.Uint32 @@ -47,11 +46,7 @@ type Service struct { const EvmReaderConfigKey = "evm-reader" -type PersistentConfig struct { - DefaultBlock DefaultBlock - InputReaderEnabled bool - ChainID uint64 -} +type PersistentConfig = config.PersistentChainConfig func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { err := ctx.Err() @@ -98,13 +93,12 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro } } - chainId, err := ethClient.ChainID(ctx) + chainID, err := ethClient.ChainID(ctx) if err != nil { return nil, err } - if chainId.Uint64() != c.Config.BlockchainId { - return nil, fmt.Errorf("EthClient chainId mismatch: network %d != provided %d", - chainId.Uint64(), c.Config.BlockchainId) + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err } s.repository = c.Repository @@ -116,15 +110,10 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro if err != nil { return nil, err } - if chainId.Uint64() != nodeConfig.ChainID { - return nil, fmt.Errorf("NodeConfig chainId mismatch: network %d != config %d", - chainId.Uint64(), nodeConfig.ChainID) - } s.client = ethClient s.chainID = nodeConfig.ChainID s.defaultBlock = nodeConfig.DefaultBlock - s.inputReaderEnabled = nodeConfig.InputReaderEnabled s.hasEnabledApps = true if c.AdapterFactory != nil { @@ -157,24 +146,24 @@ func (s *Service) setupPersistentConfig( ctx context.Context, c *config.EvmreaderConfig, ) (*PersistentConfig, error) { + requested := PersistentConfig{DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId} + if err := requested.Validate(); err != nil { + return nil, fmt.Errorf("invalid evm-reader config: %w", err) + } config, err := repository.LoadNodeConfig[PersistentConfig](ctx, s.repository, EvmReaderConfigKey) if config == nil && errors.Is(err, repository.ErrNotFound) { nc := NodeConfig[PersistentConfig]{ - Key: EvmReaderConfigKey, - Value: PersistentConfig{ - DefaultBlock: c.BlockchainDefaultBlock, - InputReaderEnabled: c.FeatureInputReaderEnabled, - ChainID: c.BlockchainId, - }, + Key: EvmReaderConfigKey, + Value: requested, } s.Logger.Info("Initializing evm-reader persistent config", "config", nc.Value) - err = repository.SaveNodeConfig(ctx, s.repository, &nc) - if err != nil { - return nil, err + config, err = repository.InitializeNodeConfig(ctx, s.repository, &nc) + } + if err == nil { + if err := config.Value.CheckRequested(requested); err != nil { + return nil, fmt.Errorf("evm-reader persistent config: %w", err) } - return &nc.Value, nil - } else if err == nil { - s.Logger.Info("Evm-reader was already configured. Using previous persistent config", "config", config.Value) + s.Logger.Info("Evm-reader persistent config matches requested config", "config", config.Value) return &config.Value, nil } diff --git a/internal/evmreader/service_config_test.go b/internal/evmreader/service_config_test.go index b5982c75b..89a9a811d 100644 --- a/internal/evmreader/service_config_test.go +++ b/internal/evmreader/service_config_test.go @@ -51,9 +51,8 @@ func TestCreateAcceptsRequestTimeoutBelowPollingInterval(t *testing.T) { defer client.Close() rawConfig, err := json.Marshal(PersistentConfig{ - DefaultBlock: DefaultBlock_Finalized, - InputReaderEnabled: true, - ChainID: chainID, + DefaultBlock: DefaultBlock_Finalized, + ChainID: chainID, }) require.NoError(t, err) @@ -68,7 +67,6 @@ func TestCreateAcceptsRequestTimeoutBelowPollingInterval(t *testing.T) { BlockchainHttpRequestTimeout: requestTimeout, BlockchainId: chainID, EvmReaderPollingInterval: pollInterval, - FeatureInputReaderEnabled: true, }, EthClient: client, Repository: repo, @@ -115,83 +113,83 @@ func (s *EvmReaderSuite) TestFetchMostRecentHeaderSuccess() { s.Require().Equal(expected.Number.Uint64(), header) } -// --- inputReaderEnabled feature flag tests --- - -func (s *EvmReaderSuite) TestInputReaderDisabledSkipsInputChecks() { - s.evmReader.inputReaderEnabled = false - - app := &Application{ - Name: "test-app", - IApplicationAddress: app1Addr, - IInputBoxAddress: inputBoxAddr, - DataAvailability: DataAvailability_InputBox[:], - EpochLength: 10, - LastInputCheckBlock: 100, - } - apps := []appContracts{{application: app}} - - repo := newMockRepository() - s.evmReader.repository = repo - - s.evmReader.scanIConsensusInputs(s.ctx, apps, 200) - - repo.AssertNumberOfCalls(s.T(), "GetNumberOfInputs", 0) - repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) - repo.AssertNumberOfCalls(s.T(), "GetEpoch", 0) -} - -func (s *EvmReaderSuite) TestInputReaderDisabledSkipsEpochChecks() { - s.evmReader.inputReaderEnabled = false - - apps := []appContracts{{ - application: &Application{ - Name: "test-app", - IApplicationAddress: app1Addr, - IConsensusAddress: consensusAddr, - }, - }} - - repo := newMockRepository() - s.evmReader.repository = repo - - s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, apps, 200) - - repo.AssertNumberOfCalls(s.T(), "GetLastNonOpenEpoch", 0) - repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) -} - // --- setupPersistentConfig tests --- func (s *EvmReaderSuite) TestSetupPersistentConfigFirstRun() { + initial := PersistentConfig{DefaultBlock: DefaultBlock_Finalized, ChainID: 42} + raw, err := json.Marshal(initial) + s.Require().NoError(err) repo := newMockRepository() repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). - Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound) - repo.On("SaveNodeConfigRaw", mock.Anything, EvmReaderConfigKey, mock.Anything). - Return(nil) + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound).Once() + repo.On("InitializeNodeConfigRaw", mock.Anything, EvmReaderConfigKey, raw).Return(nil).Once() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return(raw, time.Time{}, time.Time{}, nil).Once() s.evmReader.repository = repo cfg := &config.EvmreaderConfig{ - BlockchainDefaultBlock: DefaultBlock_Finalized, - FeatureInputReaderEnabled: true, - BlockchainId: 42, + BlockchainDefaultBlock: DefaultBlock_Finalized, + BlockchainId: 42, } result, err := s.evmReader.setupPersistentConfig(s.ctx, cfg) s.Require().NoError(err) s.Require().NotNil(result) s.Require().Equal(DefaultBlock_Finalized, result.DefaultBlock) - s.Require().True(result.InputReaderEnabled) s.Require().Equal(uint64(42), result.ChainID) - repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 1) + repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) + repo.AssertExpectations(s.T()) } -func (s *EvmReaderSuite) TestSetupPersistentConfigExistingConfigWins() { +func (s *EvmReaderSuite) TestSetupPersistentConfigRejectsConcurrentInitializer() { + repo := newMockRepository() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound).Once() + repo.On("InitializeNodeConfigRaw", mock.Anything, EvmReaderConfigKey, mock.Anything).Return(nil).Once() + // A different initializer wins after our first read. Never use our unsaved request. + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return([]byte(`{"DefaultBlock":"LATEST","ChainID":42}`), time.Time{}, time.Time{}, nil).Once() + s.evmReader.repository = repo + result, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) + s.Require().Nil(result) + s.Require().ErrorContains(err, "observation policy mismatch: database=LATEST, configured=FINALIZED") + repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) + repo.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigValidatesSavedFields() { + for _, test := range []struct{ raw, want string }{ + {`{}`, "non-null DefaultBlock and ChainID"}, + {`null`, "non-null DefaultBlock and ChainID"}, + {`{"DefaultBlock":"FINALIZED","ChainID":0}`, "ChainID must be greater than zero"}, + {`{"DefaultBlock":"INVALID","ChainID":42}`, "invalid DefaultBlock"}, + {`{"DefaultBlock":"LATEST","ChainID":42}`, "database=LATEST, configured=FINALIZED"}, + } { + s.Run(test.raw, func() { + repo := newMockRepository() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return([]byte(test.raw), time.Time{}, time.Time{}, nil).Once() + s.evmReader.repository = repo + result, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) + s.Require().Nil(result) + s.Require().ErrorContains(err, test.want) + repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) + repo.AssertNumberOfCalls(s.T(), "InitializeNodeConfigRaw", 0) + repo.AssertExpectations(s.T()) + }) + } +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigRejectsDifferentConfig() { existingJSON, err := json.Marshal(PersistentConfig{ - DefaultBlock: DefaultBlock_Safe, - InputReaderEnabled: false, - ChainID: 99, + DefaultBlock: DefaultBlock_Safe, + ChainID: 99, }) s.Require().NoError(err) @@ -201,20 +199,15 @@ func (s *EvmReaderSuite) TestSetupPersistentConfigExistingConfigWins() { s.evmReader.repository = repo - // Env config has DIFFERENT values — should be ignored + // A restart must not silently use values different from the requested ones. cfg := &config.EvmreaderConfig{ - BlockchainDefaultBlock: DefaultBlock_Latest, - FeatureInputReaderEnabled: true, - BlockchainId: 1, + BlockchainDefaultBlock: DefaultBlock_Latest, + BlockchainId: 1, } result, err := s.evmReader.setupPersistentConfig(s.ctx, cfg) - s.Require().NoError(err) - - // Existing config wins - s.Require().Equal(DefaultBlock_Safe, result.DefaultBlock) - s.Require().False(result.InputReaderEnabled) - s.Require().Equal(uint64(99), result.ChainID) + s.Require().ErrorContains(err, "chain ID mismatch: database=99, configured=1") + s.Require().Nil(result) // SaveNodeConfigRaw must NOT be called repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) @@ -227,7 +220,9 @@ func (s *EvmReaderSuite) TestSetupPersistentConfigDBError() { s.evmReader.repository = repo - _, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{}) + _, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) s.Require().Error(err) s.Require().ErrorContains(err, "database unreachable") } diff --git a/internal/evmreader/util.go b/internal/evmreader/util.go index f8be142a8..aafb6d5dd 100644 --- a/internal/evmreader/util.go +++ b/internal/evmreader/util.go @@ -26,14 +26,6 @@ func appsToAddresses(apps []appContracts) []common.Address { return addresses } -func mapAddressToApp(apps []appContracts) map[common.Address]appContracts { - result := make(map[common.Address]appContracts) - for _, app := range apps { - result[app.application.IApplicationAddress] = app - } - return result -} - // sortByInputIndex is a compare function that orders Inputs // by index field. It is intended to be used with `insertSorted`, see insertSorted() func sortByInputIndex(a, b *Input) int { diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index 9736a06ba..9991660fc 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -205,7 +205,25 @@ type GetMatchAdvanceParams struct { EpochIndex string `json:"epoch_index"` TournamentAddress string `json:"tournament_address"` IDHash string `json:"id_hash"` - Parent string `json:"parent"` + TxHash string `json:"tx_hash"` + LogIndex string `json:"log_index"` +} + +// ListBondEventsParams selects immutable refund and recovery events. +type ListBondEventsParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + TournamentAddress *string `json:"tournament_address,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetBondEventParams identifies an event by its transaction and block log index. +type GetBondEventParams struct { + Application string `json:"application"` + TxHash string `json:"tx_hash"` + LogIndex string `json:"log_index"` } // ListWithdrawalsParams aligns with the OpenRPC specification diff --git a/internal/jsonrpc/api/params_test.go b/internal/jsonrpc/api/params_test.go index 5836a5ca5..8a41d694f 100644 --- a/internal/jsonrpc/api/params_test.go +++ b/internal/jsonrpc/api/params_test.go @@ -198,8 +198,20 @@ func TestPositionalParamsDeclarationOrder(t *testing.T) { }, "GetMatchAdvanceParams": { func() any { return &GetMatchAdvanceParams{} }, - `["app","0x4","tournament","id-hash","parent"]`, - `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","id_hash":"id-hash","parent":"parent"}`, + `["app","0x4","tournament","id-hash","tx-hash","0x4"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"id_hash":"id-hash","tx_hash":"tx-hash","log_index":"0x4"}`, + }, + "ListBondEventsParams": { + func() any { return &ListBondEventsParams{} }, + `["app","0x4","tournament",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"limit":25,"offset":3,"descending":true}`, + }, + "GetBondEventParams": { + func() any { return &GetBondEventParams{} }, + `["app","tx-hash","0x4"]`, + `{"application":"app","tx_hash":"tx-hash","log_index":"0x4"}`, }, "ListWithdrawalsParams": { func() any { return &ListWithdrawalsParams{} }, diff --git a/internal/jsonrpc/application_contract_test.go b/internal/jsonrpc/application_contract_test.go new file mode 100644 index 000000000..7a4e89086 --- /dev/null +++ b/internal/jsonrpc/application_contract_test.go @@ -0,0 +1,29 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiscoverySchemaApplicationInputBox(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + + var spec struct { + Components struct { + Schemas map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + + properties := spec.Components.Schemas["Application"].Properties + require.Contains(t, properties, "iinputbox_address") + require.NotContains(t, properties, "data_availability") +} diff --git a/internal/jsonrpc/batchbudget_test.go b/internal/jsonrpc/batchbudget_test.go index 84c844b79..c12c3303f 100644 --- a/internal/jsonrpc/batchbudget_test.go +++ b/internal/jsonrpc/batchbudget_test.go @@ -53,6 +53,7 @@ func TestBatchListItemLimitSupportsNamedAndPositionalParams(t *testing.T) { "cartesi_listCommitments": `["app",null,null,10000]`, "cartesi_listMatches": `["app",null,null,10000]`, "cartesi_listMatchAdvances": `["app","0x0","tournament","id",10000]`, + "cartesi_listBondEvents": `["app",null,null,10000]`, } for method, positional := range positionalAtLimit { diff --git a/internal/jsonrpc/config_test.go b/internal/jsonrpc/config_test.go new file mode 100644 index 000000000..d76607674 --- /dev/null +++ b/internal/jsonrpc/config_test.go @@ -0,0 +1,85 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http/httptest" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +type savedConfigRepository struct { + repository.Repository + raw []byte + err error +} + +func (r *savedConfigRepository) LoadNodeConfigRaw( + _ context.Context, key string, +) ([]byte, time.Time, time.Time, error) { + if key != evmreader.EvmReaderConfigKey { + panic("unexpected configuration key") + } + return r.raw, time.Time{}, time.Time{}, r.err +} + +func TestNodeInfoAndChainIDValidateSavedConfig(t *testing.T) { + const nullConfigJSON = "null" + for _, method := range []string{"cartesi_getNodeInfo", "cartesi_getChainId"} { + for _, test := range []struct { + name string + raw string + err error + wantCode int + }{ + {"absent", "", repository.ErrNotFound, JSONRPC_RESOURCE_NOT_FOUND}, + {"nil bytes", "", nil, JSONRPC_INTERNAL_ERROR}, + {"null config", nullConfigJSON, nil, JSONRPC_INTERNAL_ERROR}, + {"empty", "{}", nil, JSONRPC_INTERNAL_ERROR}, + {"missing chain", `{"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"missing policy", `{"ChainID":1}`, nil, JSONRPC_INTERNAL_ERROR}, + {"null chain", `{"ChainID":null,"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"null policy", `{"ChainID":1,"DefaultBlock":null}`, nil, JSONRPC_INTERNAL_ERROR}, + {"zero chain", `{"ChainID":0,"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"invalid policy", `{"ChainID":1,"DefaultBlock":"INVALID"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"valid", `{"ChainID":31337,"DefaultBlock":"FINALIZED"}`, nil, 0}, + } { + t.Run(method+"/"+test.name, func(t *testing.T) { + var raw []byte + if test.raw != "" { + raw = []byte(test.raw) + } + s := &Service{ + HTTPServiceTemplate: service.HTTPServiceTemplate{ + BaseTemplate: service.BaseTemplate{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, + }, + repository: &savedConfigRepository{raw: raw, err: test.err}, + handlers: cloneDispatchTable(jsonrpcHandlers), + } + request := httptest.NewRequest("POST", "/rpc", nil) + result, err := s.handlers[method](s, request, RPCRequest{}) + if test.wantCode != 0 { + require.Nil(t, result) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, test.wantCode, rpcErr.Code) + return + } + require.NoError(t, err) + encoded, err := json.Marshal(result) + require.NoError(t, err) + require.Contains(t, string(encoded), "0x7a69") + }) + } + } +} diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index f304b7d17..584da7f5c 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -1526,7 +1526,7 @@ { "name": "cartesi_listMatchAdvances", "summary": "List match advances", - "description": "Returns a paginated list of match advances for the specified match.", + "description": "Returns complete immutable advance records for one match. Application, epoch, tournament, and match are required. Events are ordered by block number and block-global log index. Each record includes its transaction hash and log index for getMatchAdvance. Repeated other-parent hashes remain distinct events.", "params": [ { "name": "application", @@ -1538,7 +1538,7 @@ }, { "name": "epoch_index", - "description": "Filter match advances by a specific epoch index (hex encoded).", + "description": "The epoch index of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/UnsignedInteger" }, @@ -1546,7 +1546,7 @@ }, { "name": "tournament_address", - "description": "Filter match advances by tournament address (hex encoded).", + "description": "The tournament address of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/EthereumAddress" }, @@ -1583,7 +1583,7 @@ }, { "name": "descending", - "description": "if true, the list will be sorted in descending order by epoch index.", + "description": "If true, reverse both block number and log index ordering.", "schema": { "type": "boolean", "default": false @@ -1618,7 +1618,7 @@ { "name": "cartesi_getMatchAdvance", "summary": "Get a specific match advance", - "description": "Fetches a single match advance by application, epoch index, tournament address, ID hash and parent.", + "description": "Returns one complete immutable advance record within the supplied application, epoch, tournament, and match. Use the transaction hash and block-global log index from listMatchAdvances. List and get return the same record fields.", "params": [ { "name": "application", @@ -1630,7 +1630,7 @@ }, { "name": "epoch_index", - "description": "The index of the epoch (hex encoded).", + "description": "The epoch index of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/UnsignedInteger" }, @@ -1638,7 +1638,7 @@ }, { "name": "tournament_address", - "description": "The tournament address (hex encoded).", + "description": "The tournament address of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/EthereumAddress" }, @@ -1646,19 +1646,27 @@ }, { "name": "id_hash", - "description": "The ID hash of the match advance (hex encoded).", + "description": "The ID hash of the match (hex encoded).", "schema": { "$ref": "#/components/schemas/Hash" }, "required": true }, { - "name": "parent", - "description": "The parent hash of the match advance (hex encoded).", + "name": "tx_hash", + "description": "Transaction hash of the event.", "schema": { "$ref": "#/components/schemas/Hash" }, "required": true + }, + { + "name": "log_index", + "description": "Block-global log index of the event, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true } ], "result": { @@ -1688,6 +1696,153 @@ } ] }, + { + "name": "cartesi_listBondEvents", + "summary": "List bond events", + "description": "Returns partial-refund and bond-recovery events, ordered by block number and block-global log index. A failed partial refund records the requested value, not a payment. A failed terminal bond transfer emits no BondRecovered event; it remains recoverable in the tournament snapshot.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Filter bond events by a specific epoch index (hex encoded).", + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/UnsignedInteger"}, + {"type": "null"} + ] + }, + "required": false + }, + { + "name": "tournament_address", + "description": "Filter bond events by tournament address (hex encoded).", + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/EthereumAddress"}, + {"type": "null"} + ] + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of bond events to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of bond events to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "If true, reverse both block number and log index ordering.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BondEventListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getBondEvent", + "summary": "Get a bond event", + "description": "Returns one refund or recovery event by application, transaction hash, and block-global log index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "tx_hash", + "description": "Transaction hash of the event.", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + }, + { + "name": "log_index", + "description": "Block-global log index of the event, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BondEventGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/BondEventNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, { "name": "cartesi_getNodeInfo", "summary": "Get node information", @@ -1809,9 +1964,6 @@ "withdrawal_config": { "$ref": "#/components/schemas/WithdrawalConfig" }, - "data_availability": { - "$ref": "#/components/schemas/ByteArray" - }, "consensus_type": { "$ref": "#/components/schemas/Consensus" }, @@ -2788,38 +2940,65 @@ "height": { "$ref": "#/components/schemas/UnsignedInteger" }, - "winner_commitment": { - "oneOf": [ - { - "$ref": "#/components/schemas/Hash" - }, - { - "type": "null" - } - ] + "created_at": { + "type": "string", + "format": "date-time" }, - "final_state_hash": { + "updated_at": { + "type": "string", + "format": "date-time" + }, + "initial_hash": { + "$ref": "#/components/schemas/Hash" + }, + "base_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "kind": { + "$ref": "#/components/schemas/TournamentKind" + }, + "start_instant": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Tournament creation block." + }, + "allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Initial clock allowance in blocks." + }, + "creation_event": { "oneOf": [ { - "$ref": "#/components/schemas/Hash" + "$ref": "#/components/schemas/TournamentCreationEvent" }, { "type": "null" } ] }, - "finished_at_block": { - "$ref": "#/components/schemas/UnsignedInteger" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" + "snapshot": { + "$ref": "#/components/schemas/TournamentSnapshot" } - } + }, + "description": "Immutable tournament descriptor and parent relation, with a current snapshot read at a stated block. Local epoch acceptance or application failure does not make contract state immutable.", + "required": [ + "epoch_index", + "address", + "parent_tournament_address", + "parent_match_id_hash", + "max_level", + "level", + "log2step", + "height", + "created_at", + "updated_at", + "initial_hash", + "base_cycle", + "kind", + "start_instant", + "allowance", + "creation_event", + "snapshot" + ] }, "TournamentListResult": { "type": "object", @@ -2874,8 +3053,28 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "snapshot": { + "$ref": "#/components/schemas/CommitmentSnapshot" } - } + }, + "description": "Immutable joined commitment and submitter, with current claimer and clocks. Every indexed commitment has joined; retained clocks or a zero claimer do not prove liveness.", + "required": [ + "epoch_index", + "tournament_address", + "commitment", + "final_state_hash", + "submitter_address", + "block_number", + "tx_hash", + "created_at", + "updated_at", + "log_index", + "snapshot" + ] }, "CommitmentListResult": { "type": "object", @@ -2912,13 +3111,13 @@ "$ref": "#/components/schemas/Hash" }, "commitment_one": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "commitment_two": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "left_of_two": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "block_number": { "$ref": "#/components/schemas/UnsignedInteger" @@ -2936,7 +3135,14 @@ "$ref": "#/components/schemas/UnsignedInteger" }, "deletion_tx_hash": { - "$ref": "#/components/schemas/Hash" + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] }, "created_at": { "type": "string", @@ -2945,8 +3151,59 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable MatchCreated deadline, not the current responder deadline." + }, + "leaf_seal": { + "oneOf": [ + { + "$ref": "#/components/schemas/LeafMatchSeal" + }, + { + "type": "null" + } + ] + }, + "deletion_log_index": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnsignedInteger" + }, + { + "type": "null" + } + ] + }, + "snapshot": { + "$ref": "#/components/schemas/MatchSnapshot" } - } + }, + "required": [ + "epoch_index", + "tournament_address", + "id_hash", + "commitment_one", + "commitment_two", + "left_of_two", + "block_number", + "tx_hash", + "winner_commitment", + "deletion_reason", + "deletion_block_number", + "deletion_tx_hash", + "created_at", + "updated_at", + "log_index", + "eliminable_at", + "leaf_seal", + "deletion_log_index", + "snapshot" + ] }, "MatchListResult": { "type": "object", @@ -2983,10 +3240,10 @@ "$ref": "#/components/schemas/Hash" }, "other_parent": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "left_node": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/Hash" }, "block_number": { "$ref": "#/components/schemas/UnsignedInteger" @@ -3001,8 +3258,32 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "segment_start_position": { + "$ref": "#/components/schemas/Uint256" + }, + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable both-sides elimination deadline reported by this advance." } - } + }, + "required": [ + "epoch_index", + "tournament_address", + "id_hash", + "other_parent", + "left_node", + "block_number", + "tx_hash", + "created_at", + "updated_at", + "log_index", + "segment_start_position", + "eliminable_at" + ] }, "MatchAdvancedListResult": { "type": "object", @@ -3025,22 +3306,680 @@ "$ref": "#/components/schemas/MatchAdvanced" } } - } - }, - "errors": { - "InvalidParams": { - "code": -32602, - "message": "Invalid parameters" }, - "InternalError": { - "code": -32603, - "message": "Internal server error" + "TournamentKind": { + "type": "string", + "enum": [ + "LEAF", + "NON_LEAF" + ] }, - "TimeoutError": { - "code": -32070, - "message": "Request timed out" + "TournamentStandingState": { + "type": "string", + "enum": [ + "MATCHES_ACTIVE", + "AWAITING_CLOSURE", + "ROOT_WINNER", + "ROOT_FAILED", + "INNER_WINNER", + "INNER_ELIMINABLE_NO_WINNER", + "INNER_ELIMINABLE_WINNER_EXPIRED" + ] }, - "ResponseSizeLimitExceeded": { + "MatchPhase": { + "type": "string", + "enum": [ + "UNINITIALIZED", + "BISECTING", + "READY_TO_SEAL", + "SEALED" + ] + }, + "CommitmentSide": { + "type": "string", + "enum": [ + "ONE", + "TWO" + ] + }, + "MatchTimeoutOutcome": { + "type": "string", + "enum": [ + "NONE", + "ONE_WINS", + "TWO_WINS", + "ELIMINATE_BOTH" + ] + }, + "InnerTournamentDisposition": { + "type": "string", + "enum": [ + "UNSETTLED", + "WINNER", + "ELIMINABLE" + ] + }, + "BondDisposition": { + "type": "string", + "enum": [ + "TOURNAMENT_RUNNING", + "NO_WINNER", + "RECOVERABLE", + "RECOVERED" + ] + }, + "BondEventType": { + "type": "string", + "enum": [ + "PARTIAL_BOND_REFUND", + "BOND_RECOVERED" + ] + }, + "Uint256": { + "type": "string", + "pattern": "^0x(0|[1-9a-f][0-9a-f]{0,63})$", + "description": "An exact uint256 quantity in canonical hexadecimal form. The maximum is 2^256-1. Never a floating-point JSON number." + }, + "TournamentCreationEvent": { + "type": "object", + "description": "Immutable NewInnerTournament event, emitted by the parent tournament. A root has no such event.", + "properties": { + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + } + }, + "required": [ + "block_number", + "tx_hash", + "log_index" + ] + }, + "LeafMatchSeal": { + "type": "object", + "properties": { + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable deadline from LeafMatchSealed; both sides are eliminable at this block." + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + } + }, + "required": [ + "eliminable_at", + "block_number", + "tx_hash", + "log_index" + ] + }, + "TournamentInnerResult": { + "type": "object", + "properties": { + "disposition": { + "$ref": "#/components/schemas/InnerTournamentDisposition" + }, + "parent_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "paused_allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Remaining carryover allowance in blocks at as_of_block." + } + }, + "required": [ + "disposition", + "parent_commitment", + "paused_allowance" + ] + }, + "TournamentBondRecovery": { + "type": "object", + "description": "Claimer and payment are present only for RECOVERABLE. A present zero payment is valid.", + "properties": { + "disposition": { + "$ref": "#/components/schemas/BondDisposition" + }, + "claimer": { + "oneOf": [ + { + "$ref": "#/components/schemas/EthereumAddress" + }, + { + "type": "null" + } + ] + }, + "payment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Uint256" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "disposition", + "claimer", + "payment" + ] + }, + "TournamentSnapshot": { + "type": "object", + "description": "Current contract state, not historical winner data. An expired inner candidate remains a candidate, but is not a current winner. Inner result is null for a root. Separate API calls do not form one database snapshot.", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Configured-policy block at which every view in this snapshot was read." + }, + "standing": { + "$ref": "#/components/schemas/TournamentStandingState" + }, + "accepts_joins": { + "type": "boolean" + }, + "candidate": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "winner_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "final_state_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "parent_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "finished_at_block": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Completion block, or zero while unfinished. Completion does not stop observation." + }, + "winner_expires_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Expiry block for a live inner winner; zero for other standings." + }, + "inner_result": { + "oneOf": [ + { + "$ref": "#/components/schemas/TournamentInnerResult" + }, + { + "type": "null" + } + ] + }, + "bond_recovery": { + "$ref": "#/components/schemas/TournamentBondRecovery" + } + }, + "required": [ + "as_of_block", + "standing", + "accepts_joins", + "candidate", + "winner_commitment", + "final_state_hash", + "parent_commitment", + "finished_at_block", + "winner_expires_at", + "inner_result", + "bond_recovery" + ] + }, + "CommitmentSnapshot": { + "type": "object", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "claimer": { + "$ref": "#/components/schemas/EthereumAddress", + "description": "Current claimer, which can be zero after elimination or recovery. The original submitter remains an immutable join field." + }, + "clock_running": { + "type": "boolean" + }, + "clock_deadline": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Responder expiry block when running; zero when paused." + }, + "clock_allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Paused allowance in blocks. Clock storage alone does not prove that a commitment is live." + } + }, + "required": [ + "as_of_block", + "claimer", + "clock_running", + "clock_deadline", + "clock_allowance" + ] + }, + "MatchBisectionSnapshot": { + "type": "object", + "description": "Current divergence frontier. Current height is present for BISECTING and null for READY_TO_SEAL. Responder side ONE/TWO is not the deletion winner enum.", + "properties": { + "revealing_parent": { + "$ref": "#/components/schemas/Hash" + }, + "waiting_left": { + "$ref": "#/components/schemas/Hash" + }, + "waiting_right": { + "$ref": "#/components/schemas/Hash" + }, + "segment_start_position": { + "$ref": "#/components/schemas/Uint256" + }, + "segment_start_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "current_height": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnsignedInteger" + }, + { + "type": "null" + } + ] + }, + "responder": { + "$ref": "#/components/schemas/CommitmentSide" + } + }, + "required": [ + "revealing_parent", + "waiting_left", + "waiting_right", + "segment_start_position", + "segment_start_cycle", + "current_height", + "responder" + ] + }, + "MatchSealedSnapshot": { + "type": "object", + "description": "Sealed values are in commitment-side order, not reveal order.", + "properties": { + "agree_state": { + "$ref": "#/components/schemas/Hash" + }, + "divergence_position": { + "$ref": "#/components/schemas/Uint256" + }, + "divergence_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "final_state_one": { + "$ref": "#/components/schemas/Hash" + }, + "final_state_two": { + "$ref": "#/components/schemas/Hash" + } + }, + "required": [ + "agree_state", + "divergence_position", + "divergence_cycle", + "final_state_one", + "final_state_two" + ] + }, + "MatchSnapshot": { + "type": "object", + "description": "Current match state. Deleted matches have UNINITIALIZED phase and no phase payload; deletion facts remain on the match. A sealed non-leaf match has no local timeout outcome because its child controls progress.", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "phase": { + "$ref": "#/components/schemas/MatchPhase" + }, + "bisection": { + "oneOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "type": "null" + } + ] + }, + "sealed": { + "oneOf": [ + { + "$ref": "#/components/schemas/MatchSealedSnapshot" + }, + { + "type": "null" + } + ] + }, + "timeout_outcome": { + "$ref": "#/components/schemas/MatchTimeoutOutcome" + }, + "deferred_charge": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Deferred charge in blocks from the timeout classifier at as_of_block." + } + }, + "required": [ + "as_of_block", + "phase", + "bisection", + "sealed", + "timeout_outcome", + "deferred_charge" + ], + "oneOf": [ + { + "properties": { + "phase": { + "const": "UNINITIALIZED" + }, + "bisection": { + "type": "null" + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "BISECTING" + }, + "bisection": { + "allOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "properties": { + "current_height": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + } + ] + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "READY_TO_SEAL" + }, + "bisection": { + "allOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "properties": { + "current_height": { + "type": "null" + } + } + } + ] + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "SEALED" + }, + "bisection": { + "type": "null" + }, + "sealed": { + "$ref": "#/components/schemas/MatchSealedSnapshot" + } + } + } + ] + }, + "PartialBondRefund": { + "type": "object", + "description": "Value is the requested refund. It was paid only if success is true.", + "properties": { + "recipient": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "value": { + "$ref": "#/components/schemas/Uint256" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "recipient", + "value", + "success" + ] + }, + "BondRecovered": { + "type": "object", + "properties": { + "commitment": { + "$ref": "#/components/schemas/Hash" + }, + "claimer": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "payment": { + "$ref": "#/components/schemas/Uint256" + }, + "burned": { + "$ref": "#/components/schemas/Uint256" + } + }, + "required": [ + "commitment", + "claimer", + "payment", + "burned" + ] + }, + "BondEvent": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tournament_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "type": { + "$ref": "#/components/schemas/BondEventType" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + }, + "refund": { + "oneOf": [ + { + "$ref": "#/components/schemas/PartialBondRefund" + }, + { + "type": "null" + } + ] + }, + "recovery": { + "oneOf": [ + { + "$ref": "#/components/schemas/BondRecovered" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "epoch_index", + "tournament_address", + "type", + "block_number", + "tx_hash", + "log_index", + "refund", + "recovery", + "created_at", + "updated_at" + ], + "oneOf": [ + { + "properties": { + "type": { + "const": "PARTIAL_BOND_REFUND" + }, + "refund": { + "$ref": "#/components/schemas/PartialBondRefund" + }, + "recovery": { + "type": "null" + } + } + }, + { + "properties": { + "type": { + "const": "BOND_RECOVERED" + }, + "refund": { + "type": "null" + }, + "recovery": { + "$ref": "#/components/schemas/BondRecovered" + } + } + } + ] + }, + "BondEventListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BondEvent" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + }, + "required": [ + "data", + "pagination" + ] + }, + "BondEventGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/BondEvent" + } + }, + "required": [ + "data" + ] + } + }, + "errors": { + "InvalidParams": { + "code": -32602, + "message": "Invalid parameters" + }, + "InternalError": { + "code": -32603, + "message": "Internal server error" + }, + "TimeoutError": { + "code": -32070, + "message": "Request timed out" + }, + "ResponseSizeLimitExceeded": { "code": -31003, "message": "Response size limit exceeded" }, @@ -3088,6 +4027,10 @@ "code": -31001, "message": "Match advanced not found" }, + "BondEventNotFound": { + "code": -31001, + "message": "Bond event not found" + }, "NodeConfigNotFound": { "code": -31001, "message": "EVM Reader config not found" diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 93c7546bb..0c387e2a3 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -17,6 +17,8 @@ import ( "runtime/debug" "unicode/utf8" + "github.com/ethereum/go-ethereum/common" + "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/evmreader" "github.com/cartesi/rollups-node/internal/jsonrpc/api" @@ -109,6 +111,8 @@ var jsonrpcHandlers = dispatchTable{ "cartesi_getMatch": handleGetMatch, "cartesi_listMatchAdvances": handleListMatchAdvances, "cartesi_getMatchAdvance": handleGetMatchAdvance, + "cartesi_listBondEvents": handleListBondEvents, + "cartesi_getBondEvent": handleGetBondEvent, "cartesi_getNodeInfo": handleGetNodeInfo, "cartesi_getChainId": handleGetChainID, "cartesi_getNodeVersion": handleGetNodeVersion, @@ -125,6 +129,7 @@ var listParamsTypes = map[string]reflect.Type{ "cartesi_listCommitments": reflect.TypeOf(api.ListCommitmentsParams{}), "cartesi_listMatches": reflect.TypeOf(api.ListMatchesParams{}), "cartesi_listMatchAdvances": reflect.TypeOf(api.ListMatchAdvancesParams{}), + "cartesi_listBondEvents": reflect.TypeOf(api.ListBondEventsParams{}), } func truncatedMethod(method string) string { @@ -1560,26 +1565,17 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } - // Create match advance filter based on params - epochIndex, err := config.ToIndexFromString(params.EpochIndex) + epochIndex, err := parseMatchScope(params.EpochIndex, params.TournamentAddress, params.IDHash) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) - } - - if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) - } - - if _, err := config.ToHashFromString(params.IDHash); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) + return nil, err } pagination := repository.Pagination{ Limit: params.Limit, Offset: params.Offset, } - matchAdvances, total, err := s.repository.ListMatchAdvances(r.Context(), params.Application, epochIndex, - params.TournamentAddress, params.IDHash, pagination, params.Descending) + matchAdvances, total, err := s.repository.ListMatchAdvances(r.Context(), params.Application, + epochIndex, params.TournamentAddress, params.IDHash, pagination, params.Descending) if err != nil { return nil, s.repositoryError(r.Context(), "Unable to retrieve match advances from repository", err) } @@ -1609,46 +1605,145 @@ func handleGetMatchAdvance(s *Service, r *http.Request, req RPCRequest) (any, er return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } - // Validate application parameter - if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + txHash, logIndex, err := parseTournamentEventIdentity(params.Application, params.TxHash, params.LogIndex) + if err != nil { + return nil, err + } + epochIndex, err := parseMatchScope(params.EpochIndex, params.TournamentAddress, params.IDHash) + if err != nil { + return nil, err + } + matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, + epochIndex, params.TournamentAddress, params.IDHash, txHash, logIndex) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve match advanced from repository", err) + } + if matchAdvanced == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found") } - epochIndex, err := config.ToIndexFromString(params.EpochIndex) + return api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}, nil +} + +func parseMatchScope(epoch, tournament, id string) (uint64, error) { + epochIndex, err := config.ToIndexFromString(epoch) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + if _, err := config.ToAddressFromString(tournament); err != nil { + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } + if _, err := config.ToHashFromString(id); err != nil { + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) + } + return epochIndex, nil +} - if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) +func parseTournamentEventIdentity(application, hash, index string) (common.Hash, uint64, error) { + if err := validateNameOrAddress(application); err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + txHash, err := config.ToHashFromString(hash) + if err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid transaction hash: %v", err)) + } + logIndex, err := config.ToIndexFromString(index) + if err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid log index: %v", err)) } + return txHash, logIndex, nil +} - if _, err := config.ToHashFromString(params.IDHash); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) +func parseTournamentEventFilter(epoch, tournament *string) (*uint64, *common.Address, error) { + var epochIndex *uint64 + if epoch != nil { + value, err := config.ToIndexFromString(*epoch) + if err != nil { + return nil, nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + epochIndex = &value + } + var tournamentAddress *common.Address + if tournament != nil { + value, err := config.ToAddressFromString(*tournament) + if err != nil { + return nil, nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + tournamentAddress = &value } + return epochIndex, tournamentAddress, nil +} - parent, err := config.ToHashFromString(params.Parent) +func handleListBondEvents(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListBondEventsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + if params.Limit == 0 { + params.Limit = LIST_ITEM_DEFAULT + } + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + epochIndex, tournamentAddress, err := parseTournamentEventFilter(params.EpochIndex, params.TournamentAddress) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent hash: %v", err)) + return nil, err } - - matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, epochIndex, - params.TournamentAddress, params.IDHash, parent.Hex()[2:]) + filter := repository.BondEventFilter{EpochIndex: epochIndex, TournamentAddress: tournamentAddress} + pagination := repository.Pagination{Limit: params.Limit, Offset: params.Offset} + events, total, err := s.repository.ListBondEvents(r.Context(), params.Application, filter, pagination, params.Descending) if err != nil { - return nil, s.repositoryError(r.Context(), "Unable to retrieve match advanced from repository", err) + return nil, s.repositoryError(r.Context(), "Unable to retrieve bond events from repository", err) } - if matchAdvanced == nil { + if len(events) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found") } + if events == nil { + events = []*model.BondEvent{} + } + return api.ListResponse[*model.BondEvent]{ + Data: events, + Pagination: api.Pagination{TotalCount: total, Limit: params.Limit, Offset: params.Offset}, + }, nil +} - return api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}, nil +func handleGetBondEvent(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetBondEventParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + txHash, logIndex, err := parseTournamentEventIdentity(params.Application, params.TxHash, params.LogIndex) + if err != nil { + return nil, err + } + event, err := s.repository.GetBondEvent(r.Context(), params.Application, txHash, logIndex) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve bond event from repository", err) + } + if event == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Bond event not found") + } + return api.SingleResponse[*model.BondEvent]{Data: event}, nil } func handleGetNodeInfo(s *Service, r *http.Request, _ RPCRequest) (any, error) { - cfg, err := repository.LoadNodeConfig[evmreader.PersistentConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) + cfg, err := repository.LoadNodeConfig[config.PersistentChainConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) if errors.Is(err, repository.ErrNotFound) { return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") } @@ -1664,7 +1759,7 @@ func handleGetNodeInfo(s *Service, r *http.Request, _ RPCRequest) (any, error) { } func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { - config, err := repository.LoadNodeConfig[evmreader.PersistentConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) + cfg, err := repository.LoadNodeConfig[config.PersistentChainConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) if errors.Is(err, repository.ErrNotFound) { return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") } @@ -1672,7 +1767,7 @@ func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { return nil, s.repositoryError(r.Context(), "Unable to retrieve evmreader config from repository", err) } - return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", config.Value.ChainID)}, nil + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", cfg.Value.ChainID)}, nil } func handleGetNodeVersion(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index 626883048..8dda80e4e 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -47,6 +47,8 @@ type jsonrpcSchema struct { } } +const matchDeletionBlock = uint64(101) + // failure: invalid JSON (extra ',' at the end) func TestInvalidJSON(t *testing.T) { s := newTestService(t, t.Name()) @@ -185,6 +187,11 @@ func TestMethod(t *testing.T) { resp := testRPCResponse[model.Application]{} assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, nr, nameToNumber(resp.Result.Data.Name)) + + var raw testRPCResponse[map[string]json.RawMessage] + require.NoError(t, json.Unmarshal(body, &raw)) + require.Contains(t, raw.Result.Data, "iinputbox_address") + require.NotContains(t, raw.Result.Data, "data_availability") }) }) @@ -283,7 +290,7 @@ func TestMethod(t *testing.T) { &model.NodeConfig[evmreader.PersistentConfig]{ Key: evmreader.EvmReaderConfigKey, Value: evmreader.PersistentConfig{ - ChainID: nr, + ChainID: nr, DefaultBlock: model.DefaultBlock_Finalized, }, }, ) @@ -1255,6 +1262,12 @@ func TestMethod(t *testing.T) { assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, 1, len(resp.Result.Data)) assert.Equal(t, numberToName(nr), resp.Result.Data[0].Name) + + var raw testRPCResponse[[]map[string]json.RawMessage] + require.NoError(t, json.Unmarshal(body, &raw)) + require.Len(t, raw.Result.Data, 1) + require.Contains(t, raw.Result.Data[0], "iinputbox_address") + require.NotContains(t, raw.Result.Data[0], "data_availability") }) // success: 1 application is in the database (array params) -> 1 @@ -2365,19 +2378,17 @@ func TestMethod(t *testing.T) { }`, numberToName(app), tnr)) type Result struct { - EpochIndex hex64 `json:"epoch_index"` - Address common.Address `json:"address"` - ParentTournamentAddress *common.Address `json:"parent_tournament_address"` - ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` - MaxLevel hex64 `json:"max_level"` - Level hex64 `json:"level"` - Log2Step hex64 `json:"log2step"` - Height hex64 `json:"height"` - WinnerCommitment *common.Hash `json:"winner_commitment"` - FinalStateHash *common.Hash `json:"final_state_hash"` - FinishedAtBlock hex64 `json:"finished_at_block"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + EpochIndex hex64 `json:"epoch_index"` + Address common.Address `json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel hex64 `json:"max_level"` + Level hex64 `json:"level"` + Log2Step hex64 `json:"log2step"` + Height hex64 `json:"height"` + Snapshot model.TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } resp := testRPCResponse[Result]{} @@ -2868,7 +2879,7 @@ func TestMethod(t *testing.T) { WithCommitmentOne(commitment.Commitment). WithCommitmentTwo(commitment.Commitment). WithWinner(model.WinnerCommitment_NONE). - WithDeletionReason(model.MatchDeletionReason_TIMEOUT). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). Build()) require.NoError(t, err, "failed to create match. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) @@ -2897,8 +2908,8 @@ func TestMethod(t *testing.T) { t.Run("cartesi_getMatchAdvance", func(t *testing.T) { method := getName(t.Name()) - // failure: epoch_index not hex encoded -> invalid param - t.Run("malformedEpochIndex", func(t *testing.T) { + // failure: log_index not hex encoded -> invalid param + t.Run("malformedLogIndex", func(t *testing.T) { testHistogram.inc(method) s := newTestService(t, t.Name()) @@ -2910,18 +2921,22 @@ func TestMethod(t *testing.T) { "method": "cartesi_getMatchAdvance", "params": { "application": "%v", - "epoch_index": "%v" + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "%v" }, "id": 0 - }`, numberToName(app), nr)) + }`, numberToName(app), 0, nr)) resp := testRPCResponse[any]{} assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) - assert.Equal(t, "Invalid epoch index: expected hex encoded value", resp.Error.Message) + assert.Equal(t, "Invalid log index: expected hex encoded value", resp.Error.Message) }) - // failure: epoch not in the database -> resource not found + // failure: event not in the database -> resource not found t.Run("absent", func(t *testing.T) { testHistogram.inc(method) s := newTestService(t, t.Name()) @@ -2942,13 +2957,14 @@ func TestMethod(t *testing.T) { "method": "cartesi_getMatchAdvance", "params": { "application": "%v", - "epoch_index": "0x%020x", - "tournament_address": "0x%040x", - "id_hash": "0x%064x", - "parent": "0x%064x" + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "0x0" }, "id": 0 - }`, numberToName(app), nr+1, 0, 0, 0)) + }`, numberToName(app), 0)) resp := testRPCResponse[any]{} assert.Nil(t, json.Unmarshal(body, &resp)) @@ -2967,13 +2983,14 @@ func TestMethod(t *testing.T) { "method": "cartesi_getMatchAdvance", "params": { "application": "%v", - "epoch_index": "0x%020x", - "tournament_address": "0x%040x", - "id_hash": "0x%064x", - "parent": "0x%064x" + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "0x0" }, "id": 0 - }`, numberToName(nr), 0, 0, 0, 0)) + }`, numberToName(nr), 0)) resp := testRPCResponse[any]{} assert.Nil(t, json.Unmarshal(body, &resp)) @@ -2981,7 +2998,7 @@ func TestMethod(t *testing.T) { assert.Equal(t, "Application not found", resp.Error.Message) }) - // success: commitment is in the database -> retrieve epoch + // success: event is in the database -> retrieve its full identity t.Run("present", func(t *testing.T) { testHistogram.inc(method) s := newTestService(t, t.Name()) @@ -3023,17 +3040,17 @@ func TestMethod(t *testing.T) { WithCommitmentOne(commitment.Commitment). WithCommitmentTwo(commitment.Commitment). WithWinner(model.WinnerCommitment_NONE). - WithDeletionReason(model.MatchDeletionReason_TIMEOUT). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). Build()) require.NoError(t, err, "failed to create match. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) - err = s.repository.CreateMatchAdvanced(ctx, numberToName(app), - repotest.NewMatchAdvancedBuilder(appID). - WithEpochIndex(nr). - WithTournamentAddress(address). - WithIDHash(idHash). - WithOtherParent(parent). - Build()) + advance := repotest.NewMatchAdvancedBuilder(appID). + WithEpochIndex(nr). + WithTournamentAddress(address). + WithIDHash(idHash). + WithOtherParent(parent). + Build() + err = s.repository.CreateMatchAdvanced(ctx, numberToName(app), advance) require.NoError(t, err, "failed to create match advanced. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ @@ -3041,18 +3058,20 @@ func TestMethod(t *testing.T) { "method": "cartesi_getMatchAdvance", "params": { "application": "%v", - "epoch_index": "0x%020x", - "tournament_address": "0x%020x", - "id_hash": "0x%064x", - "parent": "%s" + "epoch_index": "0x%x", + "tournament_address": "%s", + "id_hash": "%s", + "tx_hash": "%s", + "log_index": "0x%x" }, "id": 0 - }`, numberToName(app), nr, address, idHash, parentHex)) + }`, numberToName(app), nr, address.Hex(), idHash.Hex(), advance.TxHash.Hex(), advance.LogIndex)) resp := testRPCResponse[getMatchAdvancedResult]{} assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, idHash, resp.Result.Data.IDHash) assert.Equal(t, hex64(nr), resp.Result.Data.EpochIndex) + assert.Equal(t, advance.TxHash, resp.Result.Data.TxHash) }) }) @@ -3150,7 +3169,7 @@ func TestMethod(t *testing.T) { WithCommitmentOne(commitmentOne). WithCommitmentTwo(commitmentTwo). WithWinner(model.WinnerCommitment_NONE). - WithDeletionReason(model.MatchDeletionReason_TIMEOUT). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). Build()) require.NoError(t, err, "on test case: %v, application: %v, report_index: %v", 0, appID, tnr) } @@ -3345,7 +3364,6 @@ func TestMethod(t *testing.T) { WithCommitmentOne(commitment). WithCommitmentTwo(commitment). WithWinner(model.WinnerCommitment_NONE). - WithDeletionReason(model.MatchDeletionReason_NOT_DELETED). Build()) require.NoError(t, err) @@ -3951,6 +3969,54 @@ func TestMethod(t *testing.T) { }) }) + for _, method := range []string{"cartesi_getBondEvent", "cartesi_listBondEvents"} { + t.Run(method, func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := t.Context() + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID).WithStatus(model.EpochStatus_Closed).Build() + s.createTestEpoch(ctx, t, numberToName(app), epoch) + tournament := repotest.NewTournamentBuilder(appID).Build() + txHash := repotest.UniqueHash() + first := &model.BondEvent{ + ApplicationID: appID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Type: model.BondEventPartialRefund, BlockNumber: tournament.Snapshot.AsOfBlock, TxHash: txHash, + Refund: &model.PartialBondRefund{Recipient: repotest.UniqueAddress(), Success: false}, + } + second := *first + second.LogIndex = 1 + second.Refund = &model.PartialBondRefund{Recipient: repotest.UniqueAddress(), Success: true} + require.NoError(t, s.repository.StoreTournamentEvents(ctx, appID, []*repository.TournamentEventBatch{{ + Tournament: tournament, BondEvents: []*model.BondEvent{first, &second}, + }}, tournament.Snapshot.AsOfBlock)) + params := map[string]any{"application": numberToName(app)} + if method == "cartesi_getBondEvent" { + params["tx_hash"], params["log_index"] = txHash.Hex(), "0x1" + } else { + params["limit"], params["offset"], params["descending"] = 1, 1, false + } + request, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params, "id": 0}) + require.NoError(t, err) + body := s.doRequest(t, 0, request) + if method == "cartesi_getBondEvent" { + var response testRPCResponse[model.BondEvent] + require.NoError(t, json.Unmarshal(body, &response)) + require.Nil(t, response.Error) + require.Equal(t, second.LogIndex, response.Result.Data.LogIndex) + require.Equal(t, second.Refund, response.Result.Data.Refund) + } else { + var response testRPCResponse[[]model.BondEvent] + require.NoError(t, json.Unmarshal(body, &response)) + require.Nil(t, response.Error) + require.Len(t, response.Result.Data, 1) + require.Equal(t, second.LogIndex, response.Result.Data[0].LogIndex) + require.Equal(t, second.Refund, response.Result.Data[0].Refund) + } + }) + } + // tested methods, implemented methods and discover methods must match: data, err := discoverSpec.ReadFile("jsonrpc-discover.json") require.NoError(t, err) @@ -4050,6 +4116,7 @@ func TestListOffsetValidation(t *testing.T) { "cartesi_listCommitments", "cartesi_listMatches", "cartesi_listMatchAdvances", + "cartesi_listBondEvents", } { t.Run(method, func(t *testing.T) { s := newBatchTestService() diff --git a/internal/jsonrpc/match_projection_test.go b/internal/jsonrpc/match_projection_test.go new file mode 100644 index 000000000..3815c974b --- /dev/null +++ b/internal/jsonrpc/match_projection_test.go @@ -0,0 +1,77 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type matchProjectionRepository struct { + repository.Repository + match *Match +} + +func (r *matchProjectionRepository) GetMatch(context.Context, string, uint64, string, string) (*Match, error) { + return r.match, nil +} + +func TestMatchDeletionTransactionProjection(t *testing.T) { + for _, deleted := range []bool{false, true} { + name := "not deleted" + if deleted { + name = "deleted with zero hash" + } + t.Run(name, func(t *testing.T) { + match := &Match{TournamentAddress: common.HexToAddress("0x1"), IDHash: common.HexToHash("0x2"), + Winner: WinnerCommitment_NONE, DeletionReason: MatchDeletionReason_NOT_DELETED} + if deleted { + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = 1 + match.DeletionTxHash = new(common.Hash) + } + params, err := json.Marshal(api.GetMatchParams{Application: "app", EpochIndex: "0x0", + TournamentAddress: match.TournamentAddress.Hex(), IDHash: match.IDHash.Hex()}) + require.NoError(t, err) + s := &Service{repository: &matchProjectionRepository{match: match}} + response, err := handleGetMatch(s, httptest.NewRequest(http.MethodPost, "/", nil), RPCRequest{Params: params}) + require.NoError(t, err) + data, err := json.Marshal(response) + require.NoError(t, err) + var decoded struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(data, &decoded)) + if deleted { + require.JSONEq(t, `"`+(common.Hash{}).Hex()+`"`, string(decoded.Data["deletion_tx_hash"])) + } else { + require.Equal(t, "null", string(decoded.Data["deletion_tx_hash"])) + } + }) + } +} + +func TestDiscoverySchemaNullableMatchDeletionTransaction(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Components struct { + Schemas map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + require.JSONEq(t, `{"oneOf":[{"$ref":"#/components/schemas/Hash"},{"type":"null"}]}`, + string(spec.Components.Schemas["Match"].Properties["deletion_tx_hash"])) +} diff --git a/internal/jsonrpc/prt_observer_contract_test.go b/internal/jsonrpc/prt_observer_contract_test.go new file mode 100644 index 000000000..5a2ef87c6 --- /dev/null +++ b/internal/jsonrpc/prt_observer_contract_test.go @@ -0,0 +1,491 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const ( + observerGetBondEventMethod = "cartesi_getBondEvent" + observerListBondEventsMethod = "cartesi_listBondEvents" + observerGetMatchAdvanceMethod = "cartesi_getMatchAdvance" + observerListMatchAdvancesMethod = "cartesi_listMatchAdvances" + observerTestApplication = "app" + observerUint64Overflow = "0x10000000000000000" + observerApplicationKey = "application" + observerEpochIndexKey = "epoch_index" + observerTournamentAddressKey = "tournament_address" + observerMatchIDKey = "id_hash" + observerLogIndexKey = "log_index" + observerTxHashKey = "tx_hash" +) + +type observerAPIRepository struct { + repository.Repository + app *Application + bond *BondEvent + advance *MatchAdvanced + tournament *Tournament + commitment *Commitment + match *Match + err error + + application string + txHash common.Hash + logIndex uint64 + bondFilter repository.BondEventFilter + epochIndex uint64 + tournamentAddress string + idHash string + pagination repository.Pagination + descending bool +} + +func (r *observerAPIRepository) GetApplication(context.Context, string) (*Application, error) { + return r.app, r.err +} + +func (r *observerAPIRepository) GetBondEvent(_ context.Context, app string, tx common.Hash, index uint64) (*BondEvent, error) { + r.application, r.txHash, r.logIndex = app, tx, index + return r.bond, r.err +} + +func (r *observerAPIRepository) GetMatchAdvanced(_ context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, index uint64, +) (*MatchAdvanced, error) { + r.application, r.txHash, r.logIndex = app, tx, index + r.epochIndex, r.tournamentAddress, r.idHash = epoch, tournament, id + return r.advance, r.err +} + +func (r *observerAPIRepository) ListBondEvents(_ context.Context, app string, f repository.BondEventFilter, + p repository.Pagination, descending bool, +) ([]*BondEvent, uint64, error) { + r.application, r.bondFilter, r.pagination, r.descending = app, f, p, descending + if r.bond == nil || r.err != nil { + return nil, 0, r.err + } + return []*BondEvent{r.bond}, 1, nil +} + +func (r *observerAPIRepository) ListMatchAdvances(_ context.Context, app string, epoch uint64, tournament, id string, + p repository.Pagination, descending bool, +) ([]*MatchAdvanced, uint64, error) { + r.application, r.pagination, r.descending = app, p, descending + r.epochIndex, r.tournamentAddress, r.idHash = epoch, tournament, id + if r.advance == nil || r.err != nil { + return nil, 0, r.err + } + return []*MatchAdvanced{r.advance}, 1, nil +} + +func (r *observerAPIRepository) GetTournament(context.Context, string, string) (*Tournament, error) { + return r.tournament, r.err +} + +func (r *observerAPIRepository) GetCommitment(context.Context, string, uint64, string, string) (*Commitment, error) { + return r.commitment, r.err +} + +func (r *observerAPIRepository) GetMatch(context.Context, string, uint64, string, string) (*Match, error) { + return r.match, r.err +} + +func observerAPIRequest(t *testing.T, repo *observerAPIRepository, method string, params any) (json.RawMessage, error) { + t.Helper() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + encoded, err := json.Marshal(params) + require.NoError(t, err) + result, err := jsonrpcHandlers[method](s, httptest.NewRequest(http.MethodPost, "/", nil), RPCRequest{Params: encoded}) + if err != nil { + return nil, err + } + data, err := json.Marshal(result) + require.NoError(t, err) + return data, nil +} + +func TestTournamentEventAPIIdentityAndErrors(t *testing.T) { + tx := common.HexToHash("0xabc") + tournament, id := common.HexToAddress("0x12"), common.HexToHash("0x34") + for _, method := range []string{observerGetBondEventMethod, observerGetMatchAdvanceMethod} { + t.Run(method, func(t *testing.T) { + repo := &observerAPIRepository{app: &Application{}, + bond: &BondEvent{TxHash: tx, Type: BondEventPartialRefund, Refund: &PartialBondRefund{}}, + advance: &MatchAdvanced{TxHash: tx}} + for _, index := range []string{"0x0", "0xffffffffffffffff"} { + params := map[string]string{ + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: index, + } + if method == observerGetMatchAdvanceMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + _, err := observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.Equal(t, observerTestApplication, repo.application) + require.Equal(t, tx, repo.txHash) + if method == observerGetMatchAdvanceMethod { + require.Equal(t, uint64(7), repo.epochIndex) + require.Equal(t, tournament.Hex(), repo.tournamentAddress) + require.Equal(t, id.Hex(), repo.idHash) + } + if index == "0x0" { + require.Zero(t, repo.logIndex) + } else { + require.Equal(t, ^uint64(0), repo.logIndex) + } + } + for name, params := range map[string]map[string]string{ + "missing hash": {observerApplicationKey: observerTestApplication, observerLogIndexKey: "0x0"}, + "short hash": {observerApplicationKey: observerTestApplication, observerTxHashKey: "0x01", observerLogIndexKey: "0x0"}, + "missing index": {observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex()}, + "decimal index": {observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: "1"}, + "overflow index": { + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), + observerLogIndexKey: observerUint64Overflow, + }, + "legacy payload key": {observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x0", "parent": tx.Hex()}, + } { + t.Run(name, func(t *testing.T) { + _, err := observerAPIRequest(t, &observerAPIRepository{}, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, JSONRPC_INVALID_PARAMS, rpcErr.Code) + }) + } + params := map[string]string{ + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: "0x0", + } + if method == observerGetMatchAdvanceMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + for _, test := range []struct { + name string + repo *observerAPIRepository + code int + }{ + {"unknown application", &observerAPIRepository{}, JSONRPC_APPLICATION_NOT_FOUND}, + {"unknown event", &observerAPIRepository{app: &Application{}}, JSONRPC_RESOURCE_NOT_FOUND}, + {"repository failure", &observerAPIRepository{err: errors.New("read failed")}, JSONRPC_INTERNAL_ERROR}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := observerAPIRequest(t, test.repo, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, test.code, rpcErr.Code) + }) + } + }) + } +} + +func TestTournamentEventAPIListFilters(t *testing.T) { + tournament, id := common.HexToAddress("0x12"), common.HexToHash("0x34") + for _, method := range []string{observerListBondEventsMethod, observerListMatchAdvancesMethod} { + t.Run(method, func(t *testing.T) { + repo := &observerAPIRepository{app: &Application{}} + params := map[string]any{observerApplicationKey: observerTestApplication} + if method == observerListMatchAdvancesMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + response, err := observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.JSONEq(t, `{"data":[],"pagination":{"total_count":0,"limit":50,"offset":0}}`, string(response)) + require.Equal(t, repository.BondEventFilter{}, repo.bondFilter) + params[observerEpochIndexKey], params[observerTournamentAddressKey] = "0x7", tournament.Hex() + params["limit"], params["offset"], params["descending"] = 20000, 3, true + _, err = observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.Equal(t, repository.Pagination{Limit: LIST_ITEM_LIMIT, Offset: 3}, repo.pagination) + require.True(t, repo.descending) + if method == observerListBondEventsMethod { + require.Equal(t, repository.BondEventFilter{EpochIndex: new(uint64(7)), TournamentAddress: &tournament}, repo.bondFilter) + } else { + require.Equal(t, uint64(7), repo.epochIndex) + require.Equal(t, tournament.Hex(), repo.tournamentAddress) + require.Equal(t, id.Hex(), repo.idHash) + } + }) + } +} + +func TestMatchAdvanceAPIRequiresMatchScope(t *testing.T) { + for _, method := range []string{observerListMatchAdvancesMethod, observerGetMatchAdvanceMethod} { + for field, invalidValues := range map[string][]any{ + observerApplicationKey: {nil, json.RawMessage("null"), "", "invalid/name"}, + observerEpochIndexKey: {nil, json.RawMessage("null"), "", "1", observerUint64Overflow}, + observerTournamentAddressKey: {nil, json.RawMessage("null"), "", "not-an-address"}, + observerMatchIDKey: {nil, json.RawMessage("null"), "", "0x01"}, + } { + for _, value := range invalidValues { + t.Run(fmt.Sprintf("%s/%s/%v", method, field, value), func(t *testing.T) { + params := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x0", + observerTournamentAddressKey: (common.Address{}).Hex(), observerMatchIDKey: (common.Hash{}).Hex(), + observerTxHashKey: (common.Hash{}).Hex(), observerLogIndexKey: "0x0"} + if value == nil { + delete(params, field) + } else { + params[field] = value + } + repo := &observerAPIRepository{} + _, err := observerAPIRequest(t, repo, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, JSONRPC_INVALID_PARAMS, rpcErr.Code) + require.Empty(t, repo.application, "invalid scope must not reach the repository") + }) + } + } + } +} + +func TestBondEventAPIKeepsExactOutcomes(t *testing.T) { + maximum, err := Uint256FromBig(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))) + require.NoError(t, err) + for _, success := range []bool{false, true} { + repo := &observerAPIRepository{bond: &BondEvent{Type: BondEventPartialRefund, BlockNumber: 12, LogIndex: 0, + Refund: &PartialBondRefund{Recipient: common.HexToAddress("0x1"), Value: maximum, Success: success}}} + response, err := observerAPIRequest(t, repo, observerGetBondEventMethod, api.GetBondEventParams{ + Application: observerTestApplication, TxHash: repo.bond.TxHash.Hex(), LogIndex: "0x0", + }) + require.NoError(t, err) + var data struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &data)) + require.Equal(t, "null", string(data.Data["recovery"])) + require.JSONEq(t, fmt.Sprintf(`{"recipient":%q,"value":%q,"success":%t}`, + repo.bond.Refund.Recipient.Hex(), "0x"+strings.Repeat("f", 64), success), string(data.Data["refund"])) + require.Equal(t, `"0x0"`, string(data.Data[observerLogIndexKey])) + } + repo := &observerAPIRepository{bond: &BondEvent{Type: BondEventRecovered, + Recovery: &BondRecovered{Payment: Uint256{}, Burned: maximum}}} + response, err := observerAPIRequest(t, repo, observerGetBondEventMethod, api.GetBondEventParams{ + Application: observerTestApplication, TxHash: repo.bond.TxHash.Hex(), LogIndex: "0x0", + }) + require.NoError(t, err) + var result struct { + Data BondEvent `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + require.Nil(t, result.Data.Refund) + require.Equal(t, repo.bond.Recovery, result.Data.Recovery) +} + +func TestTournamentAPISeparatesCurrentStateFromFacts(t *testing.T) { + address := common.HexToAddress("0x12") + candidate := common.HexToHash("0x34") + repo := &observerAPIRepository{tournament: &Tournament{Address: address, Level: 1, + Snapshot: TournamentSnapshot{AsOfBlock: 100, Standing: TournamentStandingInnerEliminableWinnerExpired, + Candidate: &candidate, FinishedAtBlock: 80, + InnerResult: &TournamentInnerResult{Disposition: InnerTournamentEliminable}, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}}}} + response, err := observerAPIRequest(t, repo, "cartesi_getTournament", + api.GetTournamentParams{Application: observerTestApplication, Address: address.Hex()}) + require.NoError(t, err) + var decoded struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &decoded)) + for _, field := range []string{"winner_commitment", "final_state_hash", "finished_at_block"} { + require.NotContains(t, decoded.Data, field, "current result must not have a duplicate top-level field") + } + var snapshot map[string]json.RawMessage + require.NoError(t, json.Unmarshal(decoded.Data["snapshot"], &snapshot)) + require.Equal(t, `"0x64"`, string(snapshot["as_of_block"])) + require.Equal(t, `"`+candidate.Hex()+`"`, string(snapshot["candidate"])) + require.Equal(t, "null", string(snapshot["winner_commitment"])) + require.Equal(t, "null", string(snapshot["final_state_hash"])) + require.Equal(t, "null", string(snapshot["parent_commitment"])) + require.Equal(t, `"0x0"`, string(snapshot["winner_expires_at"])) +} + +func TestMatchAPIPreservesPhasePayloads(t *testing.T) { + for _, phase := range []MatchPhase{MatchPhaseUninitialized, MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed} { + t.Run(string(phase), func(t *testing.T) { + match := &Match{Snapshot: MatchSnapshot{AsOfBlock: 200, Phase: phase, TimeoutOutcome: MatchTimeoutNone}} + switch phase { + case MatchPhaseBisecting, MatchPhaseReadyToSeal: + match.Snapshot.Bisection = &MatchBisectionSnapshot{Responder: CommitmentSideTwo} + if phase == MatchPhaseBisecting { + match.Snapshot.Bisection.CurrentHeight = new(uint64(4)) + } + case MatchPhaseSealed: + match.Snapshot.Sealed = &MatchSealedSnapshot{} + case MatchPhaseUninitialized: + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = 190 + match.DeletionTxHash, match.DeletionLogIndex = new(common.Hash), new(uint64) + } + response, err := observerAPIRequest(t, &observerAPIRepository{match: match}, "cartesi_getMatch", api.GetMatchParams{ + Application: observerTestApplication, EpochIndex: "0x0", + TournamentAddress: match.TournamentAddress.Hex(), IDHash: match.IDHash.Hex(), + }) + require.NoError(t, err) + var result struct { + Data struct { + Snapshot MatchSnapshot `json:"snapshot"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + require.Equal(t, match.Snapshot, result.Data.Snapshot) + }) + } +} + +func TestCommitmentAPIKeepsOriginalSubmitterAfterRecovery(t *testing.T) { + commitment := &Commitment{SubmitterAddress: common.HexToAddress("0x12"), FinalStateHash: common.HexToHash("0x34"), + Snapshot: CommitmentSnapshot{AsOfBlock: 100, ClockAllowance: 50}} + response, err := observerAPIRequest(t, &observerAPIRepository{commitment: commitment}, "cartesi_getCommitment", + api.GetCommitmentParams{Application: observerTestApplication, EpochIndex: "0x0", + TournamentAddress: commitment.TournamentAddress.Hex(), Commitment: commitment.Commitment.Hex()}) + require.NoError(t, err) + var decoded struct { + Data Commitment `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &decoded)) + require.Equal(t, commitment.SubmitterAddress, decoded.Data.SubmitterAddress) + require.Equal(t, commitment.FinalStateHash, decoded.Data.FinalStateHash) + require.Equal(t, common.Address{}, decoded.Data.Snapshot.Claimer) + require.Equal(t, commitment.Snapshot, decoded.Data.Snapshot) +} + +func TestMatchAdvanceAPIKeepsFullWidthPosition(t *testing.T) { + position, err := Uint256FromBig(new(big.Int).Lsh(big.NewInt(1), 200)) + require.NoError(t, err) + advance := &MatchAdvanced{EpochIndex: 7, TournamentAddress: common.HexToAddress("0x12"), + IDHash: common.HexToHash("0x34"), OtherParent: common.HexToHash("0x56"), LeftNode: common.HexToHash("0x78"), + BlockNumber: 100, TxHash: common.HexToHash("0x90"), SegmentStartPosition: position, EliminableAt: 300, LogIndex: 4} + expected, err := json.Marshal(advance) + require.NoError(t, err) + for _, method := range []string{observerGetMatchAdvanceMethod, observerListMatchAdvancesMethod} { + t.Run(method, func(t *testing.T) { + params := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x7", + observerTournamentAddressKey: advance.TournamentAddress.Hex(), observerMatchIDKey: advance.IDHash.Hex()} + if method == observerGetMatchAdvanceMethod { + params[observerTxHashKey], params[observerLogIndexKey] = advance.TxHash.Hex(), "0x4" + } + response, err := observerAPIRequest(t, &observerAPIRepository{advance: advance}, method, params) + require.NoError(t, err) + var result struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + if method == observerListMatchAdvancesMethod { + var records []json.RawMessage + require.NoError(t, json.Unmarshal(result.Data, &records)) + require.Len(t, records, 1) + result.Data = records[0] + } + require.JSONEq(t, string(expected), string(result.Data), "list and get must return the complete same record") + var decoded struct { + Position Uint256 `json:"segment_start_position"` + EliminableAt string `json:"eliminable_at"` + LogIndex string `json:"log_index"` + TxHash common.Hash `json:"tx_hash"` + } + require.NoError(t, json.Unmarshal(result.Data, &decoded)) + require.Equal(t, position, decoded.Position) + require.Equal(t, "0x12c", decoded.EliminableAt) + require.Equal(t, "0x4", decoded.LogIndex) + require.Equal(t, advance.TxHash, decoded.TxHash) + }) + } +} + +func TestDiscoverySchemaPassiveObserver(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Methods []struct { + Name string `json:"name"` + Params []struct { + Name string `json:"name"` + Required bool `json:"required"` + } `json:"params"` + } `json:"methods"` + Components struct { + Schemas map[string]json.RawMessage `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + expected := map[string][]string{ + observerGetBondEventMethod: {observerApplicationKey, observerTxHashKey, observerLogIndexKey}, + observerListBondEventsMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, "limit", "offset", "descending", + }, + observerGetMatchAdvanceMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, observerMatchIDKey, + observerTxHashKey, observerLogIndexKey, + }, + observerListMatchAdvancesMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, observerMatchIDKey, + "limit", "offset", "descending", + }, + } + for _, method := range spec.Methods { + if want, ok := expected[method.Name]; ok { + names := make([]string, 0, len(method.Params)) + requiredCount := len(want) + switch method.Name { + case observerListMatchAdvancesMethod: + requiredCount = 4 + case observerListBondEventsMethod: + requiredCount = 1 + } + for index, param := range method.Params { + names = append(names, param.Name) + require.Equal(t, index < requiredCount, param.Required) + } + require.Equal(t, want, names) + delete(expected, method.Name) + } + } + require.Empty(t, expected, "all observer methods must be documented") + var wide struct { + Pattern string `json:"pattern"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas["Uint256"], &wide)) + pattern, err := regexp.Compile(wide.Pattern) + require.NoError(t, err) + for _, valid := range []string{"0x0", "0x1", observerUint64Overflow, "0x" + strings.Repeat("f", 64)} { + require.True(t, pattern.MatchString(valid), valid) + } + for _, invalid := range []string{"0x", "0x00", "-0x1", "0x" + strings.Repeat("f", 65), "1", "0x1.5"} { + require.False(t, pattern.MatchString(invalid), invalid) + } + for _, name := range []string{"Tournament", "Commitment", "Match"} { + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas[name], &schema)) + require.Contains(t, schema.Properties, "snapshot") + if name == "Tournament" { + require.NotContains(t, schema.Properties, "winner_commitment") + require.NotContains(t, schema.Properties, "finished_at_block") + } + } +} diff --git a/internal/jsonrpc/util_test.go b/internal/jsonrpc/util_test.go index 5c954f09f..54c8e5137 100644 --- a/internal/jsonrpc/util_test.go +++ b/internal/jsonrpc/util_test.go @@ -130,7 +130,6 @@ func (s *Service) newTestApplication(ctx context.Context, t *testing.T, i uint64 app := repotest.NewApplicationBuilder(). WithName(hex). WithAddress(common.HexToAddress(hex)). - WithDataAvailability([]byte{0x00, 0x00, 0x00, 0x00}). Create(ctx, t, s.repository) return app.ID } @@ -240,19 +239,17 @@ func (s *Service) advanceInput( } type listTournamentsResult struct { - EpochIndex hex64 `json:"epoch_index"` - Address common.Address `json:"address"` - ParentTournamentAddress *common.Address `json:"parent_tournament_address"` - ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` - MaxLevel hex64 `json:"max_level"` - Level hex64 `json:"level"` - Log2Step hex64 `json:"log2step"` - Height hex64 `json:"height"` - WinnerCommitment *common.Hash `json:"winner_commitment"` - FinalStateHash *common.Hash `json:"final_state_hash"` - FinishedAtBlock hex64 `json:"finished_at_block"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + EpochIndex hex64 `json:"epoch_index"` + Address common.Address `json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel hex64 `json:"max_level"` + Level hex64 `json:"level"` + Log2Step hex64 `json:"log2step"` + Height hex64 `json:"height"` + Snapshot model.TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type getCommitmentResult struct { @@ -280,7 +277,7 @@ type getMatchResult struct { TxHash common.Hash `json:"tx_hash"` Winner model.WinnerCommitment `json:"winner_commitment"` DeletionReason model.MatchDeletionReason `json:"deletion_reason"` - DeletionTxHash common.Hash `json:"deletion_tx_hash"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -310,7 +307,7 @@ type listMatchesResult struct { TxHash common.Hash `json:"tx_hash"` Winner model.WinnerCommitment `json:"winner_commitment"` DeletionReason model.MatchDeletionReason `json:"deletion_reason"` - DeletionTxHash common.Hash `json:"deletion_tx_hash"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/merkle/builder_test.go b/internal/merkle/builder_test.go index 1ef5da419..65859abe1 100644 --- a/internal/merkle/builder_test.go +++ b/internal/merkle/builder_test.go @@ -208,6 +208,7 @@ func TestBuildRootChildrenAgainstBuilder(t *testing.T) { rootHashBuilder := proofBuilder.BuildRoot() lhsBuilder, rhsBuilder, err := proofBuilder.BuildRootChildren() + require.NoError(t, err) proofSiblings := Proof{ Pos: new(big.Int).SetUint64((1 << 48) - 1), diff --git a/internal/merkle/proof_test.go b/internal/merkle/proof_test.go index 3d7ca8912..3c2263c45 100644 --- a/internal/merkle/proof_test.go +++ b/internal/merkle/proof_test.go @@ -96,7 +96,7 @@ func TestIncorrectCreateProofsLevel(t *testing.T) { func TestComputSiblingsMatrixAssertions(t *testing.T) { var err error - outputs := []common.Hash{common.Hash{}} + outputs := []common.Hash{{}} post := CreatePostContext() // always the same pre := post index := uint64(0) diff --git a/internal/model/enum.go b/internal/model/enum.go new file mode 100644 index 000000000..1c70e145d --- /dev/null +++ b/internal/model/enum.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "fmt" + "slices" +) + +// scanEnum accepts the database representations supported by every string enum. +// An invalid value leaves the receiver unchanged. +func scanEnum[T ~string](dst *T, value any, values []T, name string) error { + // Keep the existing public error text, including these two naming exceptions. + prefix := "invalid value" + if name == "SnapshotPolicy" { + prefix = "invalid scan value" + } + typeName := name + if name == "Consensus" { + typeName = "ConsensusType" + } + var text string + switch value := value.(type) { + case string: + text = value + case []byte: + text = string(value) + default: + return fmt.Errorf("%s for %s enum. Enum value has to be of type string or []byte", prefix, typeName) + } + parsed := T(text) + if !slices.Contains(values, parsed) { + return fmt.Errorf("%s '%s' for %s enum", prefix, text, name) + } + *dst = parsed + return nil +} diff --git a/internal/model/enum_test.go b/internal/model/enum_test.go new file mode 100644 index 000000000..d6c2a16f8 --- /dev/null +++ b/internal/model/enum_test.go @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + enumGoldenNone = "NONE" + enumInvalidValue = "UNKNOWN" +) + +func TestEnumScanners(t *testing.T) { + checkEnumScanner(t, "ApplicationStatus", ApplicationStatusAllValues, (*ApplicationStatus).Scan, + "invalid value", "ApplicationStatus", + []string{"OK", "FAILED", "DIVERGED", "CORRUPTED", "GUEST_EXCEPTION", "MACHINE_HALTED", "MCYCLE_OVERFLOW", "UNEXPECTED_YIELD"}) + checkEnumScanner(t, "Consensus", ConsensusAllValues, (*Consensus).Scan, + "invalid value", "ConsensusType", []string{"AUTHORITY", "QUORUM", "PRT"}) + checkEnumScanner(t, "SnapshotPolicy", SnapshotPolicyAllValues, (*SnapshotPolicy).Scan, + "invalid scan value", "SnapshotPolicy", []string{enumGoldenNone, "EVERY_INPUT", "EVERY_EPOCH"}) + checkEnumScanner(t, "EpochStatus", EpochStatusAllValues, (*EpochStatus).Scan, + "invalid value", "EpochStatus", []string{ + "OPEN", "CLOSED", "INPUTS_PROCESSED", "CLAIM_COMPUTED", "CLAIM_SUBMITTED", "CLAIM_STAGED", + "CLAIM_ACCEPTED", "CLAIM_REJECTED", "CLAIM_FORECLOSED", + }) + checkEnumScanner(t, "InputCompletionStatus", InputCompletionStatusAllValues, (*InputCompletionStatus).Scan, + "invalid value", "InputCompletionStatus", + []string{enumGoldenNone, "ACCEPTED", "REJECTED", "EXCEPTION", "MACHINE_HALTED", "OVERFLOW", "UNEXPECTED_YIELD"}) + checkEnumScanner(t, "DefaultBlock", DefaultBlockAllValues, (*DefaultBlock).Scan, + "invalid value", "DefaultBlock", []string{"FINALIZED", "LATEST", "PENDING", "SAFE"}) + checkEnumScanner(t, "MatchDeletionReason", MatchDeletionReasonAllValues, (*MatchDeletionReason).Scan, + "invalid value", "MatchDeletionReason", []string{"STEP", "TIMEOUT", "CHILD_TOURNAMENT", "NOT_DELETED"}) + checkEnumScanner(t, "WinnerCommitment", WinnerCommitmentAllValues, (*WinnerCommitment).Scan, + "invalid value", "WinnerCommitment", []string{enumGoldenNone, "ONE", "TWO"}) +} + +func checkEnumScanner[T ~string]( + t *testing.T, name string, values []T, scan func(*T, any) error, errorPrefix, typeName string, expected []string, +) { + t.Helper() + t.Run(name, func(t *testing.T) { + actual := make([]string, len(values)) + for i, value := range values { + actual[i] = string(value) + } + require.Equal(t, expected, actual, "AllValues must contain the full public enum vocabulary") + for _, text := range expected { + value := T(text) + t.Run(string(value), func(t *testing.T) { + for _, input := range []any{string(value), []byte(value)} { + var got T + require.NoError(t, scan(&got, input)) + require.Equal(t, value, got) + } + }) + } + for _, input := range []any{"INVALID", []byte("INVALID"), "", []byte(nil)} { + got := values[0] + text, ok := input.(string) + if !ok { + text = string(input.([]byte)) + } + require.EqualError(t, scan(&got, input), fmt.Sprintf("%s '%s' for %s enum", errorPrefix, text, name)) + require.Equal(t, values[0], got, "invalid enum values must not modify the receiver") + } + type namedString string + for _, input := range []any{nil, 1, true, namedString(values[0])} { + got := values[0] + require.EqualError(t, scan(&got, input), + fmt.Sprintf("%s for %s enum. Enum value has to be of type string or []byte", errorPrefix, typeName)) + require.Equal(t, values[0], got, "unsupported input types must not modify the receiver") + } + }) +} diff --git a/internal/model/epoch_status_test.go b/internal/model/epoch_status_test.go new file mode 100644 index 000000000..7dd3c3a22 --- /dev/null +++ b/internal/model/epoch_status_test.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNonTerminalEpochStatusesPartitionAllValues(t *testing.T) { + partition := NonTerminalEpochStatuses() + terminal := []EpochStatus{EpochStatus_ClaimAccepted, EpochStatus_ClaimRejected, EpochStatus_ClaimForeclosed} + partition = append(partition, terminal...) + require.ElementsMatch(t, EpochStatusAllValues, partition, + "classify every epoch status exactly once and keep the epoch_unreconciled_idx predicate in sync") +} + +func TestNonTerminalEpochStatusesReturnsOwnedSlice(t *testing.T) { + first, second := NonTerminalEpochStatuses(), NonTerminalEpochStatuses() + require.NotEmpty(t, first) + first[0] = EpochStatus_ClaimAccepted + require.NotContains(t, second, EpochStatus_ClaimAccepted) + require.Equal(t, second, NonTerminalEpochStatuses()) +} diff --git a/internal/model/models.go b/internal/model/models.go index 3aeee802e..9c3131b4f 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -28,7 +28,6 @@ type Application struct { EpochLength uint64 `json:"epoch_length"` ClaimStagingPeriod uint64 `json:"claim_staging_period"` WithdrawalConfig WithdrawalConfig `json:"withdrawal_config"` - DataAvailability []byte `json:"data_availability"` ConsensusType Consensus `json:"consensus_type"` Enabled bool `json:"enabled"` Status ApplicationStatus `json:"status"` @@ -158,19 +157,12 @@ func (w *WithdrawalConfig) UnmarshalJSON(data []byte) error { return nil } -// HasDataAvailabilitySelector checks if the application's DataAvailability -// starts with the given DataAvailabilitySelector -func (a *Application) HasDataAvailabilitySelector(selector DataAvailabilitySelector) bool { - return selector.MatchesBytes(a.DataAvailability) -} - func (a *Application) MarshalJSON() ([]byte, error) { // Create an alias to avoid infinite recursion in MarshalJSON. type Alias Application // Define a new structure that embeds the alias but overrides the hex fields. aux := &struct { *Alias - DataAvailability string `json:"data_availability"` IInputBoxBlock string `json:"iinputbox_block"` LastEpochCheckBlock string `json:"last_epoch_check_block"` LastInputCheckBlock string `json:"last_input_check_block"` @@ -186,7 +178,6 @@ func (a *Application) MarshalJSON() ([]byte, error) { AccountsDriveProvedBlock string `json:"accounts_drive_proved_block"` }{ Alias: (*Alias)(a), - DataAvailability: "0x" + hex.EncodeToString(a.DataAvailability), IInputBoxBlock: fmt.Sprintf("0x%x", a.IInputBoxBlock), LastEpochCheckBlock: fmt.Sprintf("0x%x", a.LastEpochCheckBlock), LastInputCheckBlock: fmt.Sprintf("0x%x", a.LastInputCheckBlock), @@ -209,7 +200,6 @@ func (a *Application) UnmarshalJSON(in []byte) error { aux := &struct { *Alias - DataAvailability string `json:"data_availability"` IInputBoxBlock string `json:"iinputbox_block"` LastInputCheckBlock string `json:"last_input_check_block"` LastOutputCheckBlock string `json:"last_output_check_block"` @@ -233,12 +223,6 @@ func (a *Application) UnmarshalJSON(in []byte) error { *a = Application(*aux.Alias) - // manually decode the following values as hex instead of the default (base64) - a.DataAvailability, err = hexutil.Decode(aux.DataAvailability) - if err != nil { - return err - } - a.IInputBoxBlock, err = ParseHexUint64(aux.IInputBoxBlock) if err != nil { return err @@ -407,38 +391,7 @@ func (e ApplicationStatus) IsExecutionTerminal() bool { } func (e *ApplicationStatus) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for ApplicationStatus enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "OK": - *e = ApplicationStatus_OK - case "FAILED": - *e = ApplicationStatus_Failed - case "DIVERGED": - *e = ApplicationStatus_Diverged - case "CORRUPTED": - *e = ApplicationStatus_Corrupted - case "GUEST_EXCEPTION": - *e = ApplicationStatus_GuestException - case "MACHINE_HALTED": - *e = ApplicationStatus_MachineHalted - case "MCYCLE_OVERFLOW": - *e = ApplicationStatus_McycleOverflow - case "UNEXPECTED_YIELD": - *e = ApplicationStatus_UnexpectedYield - default: - return errors.New("invalid value '" + enumValue + "' for ApplicationStatus enum") - } - - return nil + return scanEnum(e, value, ApplicationStatusAllValues, "ApplicationStatus") } func (e ApplicationStatus) String() string { @@ -460,78 +413,13 @@ var ConsensusAllValues = []Consensus{ } func (e *Consensus) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for ConsensusType enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "AUTHORITY": - *e = Consensus_Authority - case "QUORUM": - *e = Consensus_Quorum - case "PRT": - *e = Consensus_PRT - default: - return errors.New("invalid value '" + enumValue + "' for Consensus enum") - } - - return nil + return scanEnum(e, value, ConsensusAllValues, "Consensus") } func (e Consensus) String() string { return string(e) } -const DATA_AVAILABILITY_SELECTOR_SIZE = 4 - -type DataAvailabilitySelector [DATA_AVAILABILITY_SELECTOR_SIZE]byte - -// Known data availability selectors -var ( - // ABI encoded "InputBox(address)" - DataAvailability_InputBox = DataAvailabilitySelector{0xb1, 0x2c, 0x9e, 0xde} -) - -func (d *DataAvailabilitySelector) MarshalJSON() ([]byte, error) { - return json.Marshal("0x" + hex.EncodeToString(d[:])) -} - -// MatchesBytes checks if this selector matches the first bytes of the given byte slice -func (d DataAvailabilitySelector) MatchesBytes(data []byte) bool { - if len(data) < DATA_AVAILABILITY_SELECTOR_SIZE { - return false - } - for i := range DATA_AVAILABILITY_SELECTOR_SIZE { - if data[i] != d[i] { - return false - } - } - return true -} - -func (d *DataAvailabilitySelector) Scan(value any) error { - var selector []byte - switch v := value.(type) { - case []byte: - selector = v - default: - return errors.New("invalid scan value for DataAvailabilitySelector. Value has to be of type []byte") - } - - if len(selector) != DATA_AVAILABILITY_SELECTOR_SIZE { - return errors.New("invalid value for DataAvailabilitySelector") - } - copy(d[:], selector[:DATA_AVAILABILITY_SELECTOR_SIZE]) - - return nil -} - type SnapshotPolicy string const ( @@ -547,28 +435,7 @@ var SnapshotPolicyAllValues = []SnapshotPolicy{ } func (e *SnapshotPolicy) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid scan value for SnapshotPolicy enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "NONE": - *e = SnapshotPolicy_None - case "EVERY_INPUT": - *e = SnapshotPolicy_EveryInput - case "EVERY_EPOCH": - *e = SnapshotPolicy_EveryEpoch - default: - return errors.New("invalid scan value '" + enumValue + "' for SnapshotPolicy enum") - } - - return nil + return scanEnum(e, value, SnapshotPolicyAllValues, "SnapshotPolicy") } func (e SnapshotPolicy) String() string { @@ -996,41 +863,23 @@ var EpochStatusAllValues = []EpochStatus{ EpochStatus_ClaimForeclosed, } -func (e *EpochStatus) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for EpochStatus enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "OPEN": - *e = EpochStatus_Open - case "CLOSED": - *e = EpochStatus_Closed - case "INPUTS_PROCESSED": - *e = EpochStatus_InputsProcessed - case "CLAIM_COMPUTED": - *e = EpochStatus_ClaimComputed - case "CLAIM_SUBMITTED": - *e = EpochStatus_ClaimSubmitted - case "CLAIM_STAGED": - *e = EpochStatus_ClaimStaged - case "CLAIM_ACCEPTED": - *e = EpochStatus_ClaimAccepted - case "CLAIM_REJECTED": - *e = EpochStatus_ClaimRejected - case "CLAIM_FORECLOSED": - *e = EpochStatus_ClaimForeclosed - default: - return errors.New("invalid value '" + enumValue + "' for EpochStatus enum") +// NonTerminalEpochStatuses returns the states that still require epoch or claim +// work. Each caller owns the returned slice. +// Keep this set equal to the epoch_unreconciled_idx predicate in the initial +// PostgreSQL migration (000001_create_initial_schema.up.sql). +func NonTerminalEpochStatuses() []EpochStatus { + return []EpochStatus{ + EpochStatus_Open, + EpochStatus_Closed, + EpochStatus_InputsProcessed, + EpochStatus_ClaimComputed, + EpochStatus_ClaimSubmitted, + EpochStatus_ClaimStaged, } +} - return nil +func (e *EpochStatus) Scan(value any) error { + return scanEnum(e, value, EpochStatusAllValues, "EpochStatus") } func (e EpochStatus) String() string { @@ -1207,36 +1056,7 @@ func (e InputCompletionStatus) TerminalApplicationStatus() (ApplicationStatus, b } func (e *InputCompletionStatus) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for InputCompletionStatus enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "NONE": - *e = InputCompletionStatus_None - case "ACCEPTED": - *e = InputCompletionStatus_Accepted - case "REJECTED": - *e = InputCompletionStatus_Rejected - case "EXCEPTION": - *e = InputCompletionStatus_Exception - case "MACHINE_HALTED": - *e = InputCompletionStatus_MachineHalted - case "OVERFLOW": - *e = InputCompletionStatus_Overflow - case "UNEXPECTED_YIELD": - *e = InputCompletionStatus_UnexpectedYield - default: - return errors.New("invalid value '" + enumValue + "' for InputCompletionStatus enum") - } - - return nil + return scanEnum(e, value, InputCompletionStatusAllValues, "InputCompletionStatus") } func (e InputCompletionStatus) String() string { @@ -1256,7 +1076,7 @@ type Output struct { UpdatedAt time.Time `json:"updated_at"` } -func (i *Output) MarshalJSON() ([]byte, error) { +func (o *Output) MarshalJSON() ([]byte, error) { // Create an alias to avoid infinite recursion in MarshalJSON. type Alias Output // Define a new structure that embeds the alias but overrides the hex fields. @@ -1267,11 +1087,11 @@ func (i *Output) MarshalJSON() ([]byte, error) { RawData string `json:"raw_data"` *Alias }{ - EpochIndex: fmt.Sprintf("0x%x", i.EpochIndex), - InputIndex: fmt.Sprintf("0x%x", i.InputIndex), - Index: fmt.Sprintf("0x%x", i.Index), - RawData: "0x" + hex.EncodeToString(i.RawData), - Alias: (*Alias)(i), + EpochIndex: fmt.Sprintf("0x%x", o.EpochIndex), + InputIndex: fmt.Sprintf("0x%x", o.InputIndex), + Index: fmt.Sprintf("0x%x", o.Index), + RawData: "0x" + hex.EncodeToString(o.RawData), + Alias: (*Alias)(o), } return json.Marshal(aux) } @@ -1477,12 +1297,17 @@ type StateProof struct { HtifTohostProof [][32]byte } +// ErrIncompleteStateProof means an epoch does not contain every persisted +// component required to reconstruct its machine state proof. +var ErrIncompleteStateProof = errors.New("epoch state proof is incomplete") + // StateProofSiblingCount is the height of the canonical machine // memory tree above a 32-byte data block (64 - 5). const StateProofSiblingCount = 59 // IsComplete reports whether all three state leaves have the canonical sibling -// depth. Leaf contents and roots are verified by pkg/machine. +// depth. The machine validates proof contents when it collects them. The +// contracts validate the persisted proof when the node submits it. func (p *StateProof) IsComplete() bool { return p != nil && len(p.TxBufferProof) == StateProofSiblingCount && @@ -1503,6 +1328,32 @@ func (e *Epoch) HasCompleteStateProof() bool { len(e.HtifTohostProof) == StateProofSiblingCount } +// StateProof reconstructs the persisted machine state proof owned by an +// epoch. The returned value owns its sibling slices. +func (e *Epoch) StateProof() (StateProof, error) { + if !e.HasCompleteStateProof() { + return StateProof{}, ErrIncompleteStateProof + } + + return StateProof{ + TxBufferDataBlock: *e.TxBufferDataBlock, + TxBufferProof: copyStateProofSiblings(e.TxBufferProof), + MachineHash: *e.MachineHash, + IflagsYDataBlock: *e.IflagsYDataBlock, + IflagsYProof: copyStateProofSiblings(e.IflagsYProof), + HtifTohostDataBlock: *e.HtifTohostDataBlock, + HtifTohostProof: copyStateProofSiblings(e.HtifTohostProof), + }, nil +} + +func copyStateProofSiblings(siblings []common.Hash) [][32]byte { + result := make([][32]byte, len(siblings)) + for i := range siblings { + result[i] = siblings[i] + } + return result +} + type AdvanceResult struct { StateProof EpochIndex uint64 @@ -1580,30 +1431,7 @@ var DefaultBlockAllValues = []DefaultBlock{ } func (e *DefaultBlock) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for DefaultBlock enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "FINALIZED": - *e = DefaultBlock_Finalized - case "LATEST": - *e = DefaultBlock_Latest - case "PENDING": - *e = DefaultBlock_Pending - case "SAFE": - *e = DefaultBlock_Safe - default: - return errors.New("invalid value '" + enumValue + "' for DefaultBlock enum") - } - - return nil + return scanEnum(e, value, DefaultBlockAllValues, "DefaultBlock") } func (e DefaultBlock) String() string { @@ -1626,6 +1454,9 @@ const ( MonitoredEvent_MatchCreated MonitoredEvent = "MatchCreated" MonitoredEvent_MatchDeleted MonitoredEvent = "MatchDeleted" MonitoredEvent_NewInnerTournament MonitoredEvent = "NewInnerTournament" + MonitoredEvent_LeafMatchSealed MonitoredEvent = "LeafMatchSealed" + MonitoredEvent_PartialBondRefund MonitoredEvent = "PartialBondRefund" + MonitoredEvent_BondRecovered MonitoredEvent = "BondRecovered" ) func (e MonitoredEvent) String() string { @@ -1633,142 +1464,122 @@ func (e MonitoredEvent) String() string { } type Tournament struct { - ApplicationID int64 `sql:"primary_key" json:"-"` - EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` - Address common.Address `sql:"primary_key" json:"address"` - ParentTournamentAddress *common.Address `json:"parent_tournament_address"` - ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` - MaxLevel uint64 `json:"max_level"` - Level uint64 `json:"level"` - Log2Step uint64 `json:"log2step"` - Height uint64 `json:"height"` - WinnerCommitment *common.Hash `json:"winner_commitment"` - FinalStateHash *common.Hash `json:"final_state_hash"` - FinishedAtBlock uint64 `json:"finished_at_block"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (t *Tournament) MarshalJSON() ([]byte, error) { - // Create an alias to avoid infinite recursion in MarshalJSON. + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` + Address common.Address `sql:"primary_key" json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel uint64 `json:"max_level"` + Level uint64 `json:"level"` + Log2Step uint64 `json:"log2step"` + Height uint64 `json:"height"` + InitialHash common.Hash `json:"initial_hash"` + BaseCycle Uint256 `json:"base_cycle"` + Kind TournamentKind `json:"kind"` + StartInstant uint64 `json:"start_instant"` + Allowance uint64 `json:"allowance"` + CreationEvent *TournamentCreationEvent `json:"creation_event"` + Snapshot TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value Tournament) MarshalJSON() ([]byte, error) { type Alias Tournament - // Define a new structure that embeds the alias but overrides the hex fields. - aux := &struct { - EpochIndex string `json:"epoch_index"` - MaxLevel string `json:"max_level"` - Level string `json:"level"` - Log2Step string `json:"log2step"` - Height string `json:"height"` - FinishedAtBlock string `json:"finished_at_block"` + return json.Marshal(struct { *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + MaxLevel hexutil.Uint64 `json:"max_level"` + Level hexutil.Uint64 `json:"level"` + Log2Step hexutil.Uint64 `json:"log2step"` + Height hexutil.Uint64 `json:"height"` + StartInstant hexutil.Uint64 `json:"start_instant"` + Allowance hexutil.Uint64 `json:"allowance"` }{ - Alias: (*Alias)(t), - EpochIndex: fmt.Sprintf("0x%x", t.EpochIndex), - MaxLevel: fmt.Sprintf("0x%x", t.MaxLevel), - Level: fmt.Sprintf("0x%x", t.Level), - Log2Step: fmt.Sprintf("0x%x", t.Log2Step), - Height: fmt.Sprintf("0x%x", t.Height), - FinishedAtBlock: fmt.Sprintf("0x%x", t.FinishedAtBlock), - } - return json.Marshal(aux) + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + MaxLevel: hexutil.Uint64(value.MaxLevel), + Level: hexutil.Uint64(value.Level), + Log2Step: hexutil.Uint64(value.Log2Step), + Height: hexutil.Uint64(value.Height), + StartInstant: hexutil.Uint64(value.StartInstant), + Allowance: hexutil.Uint64(value.Allowance), + }) } -func (t *Tournament) UnmarshalJSON(data []byte) error { +func (value *Tournament) UnmarshalJSON(data []byte) error { type Alias Tournament - aux := &struct { - EpochIndex string `json:"epoch_index"` - MaxLevel string `json:"max_level"` - Level string `json:"level"` - Log2Step string `json:"log2step"` - Height string `json:"height"` - FinishedAtBlock string `json:"finished_at_block"` + var decoded Tournament + aux := struct { *Alias - }{Alias: (*Alias)(t)} - - if err := json.Unmarshal(data, aux); err != nil { + EpochIndex hexutil.Uint64 `json:"epoch_index"` + MaxLevel hexutil.Uint64 `json:"max_level"` + Level hexutil.Uint64 `json:"level"` + Log2Step hexutil.Uint64 `json:"log2step"` + Height hexutil.Uint64 `json:"height"` + StartInstant hexutil.Uint64 `json:"start_instant"` + Allowance hexutil.Uint64 `json:"allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { return err } - *t = Tournament(*aux.Alias) - - var err error - t.EpochIndex, err = ParseHexUint64(aux.EpochIndex) - if err != nil { - return fmt.Errorf("error on EpochIndex: %w", err) - } - t.MaxLevel, err = ParseHexUint64(aux.MaxLevel) - if err != nil { - return fmt.Errorf("error on MaxLevel: %w", err) - } - t.Level, err = ParseHexUint64(aux.Level) - if err != nil { - return fmt.Errorf("error on Level: %w", err) - } - t.Log2Step, err = ParseHexUint64(aux.Log2Step) - if err != nil { - return fmt.Errorf("error on Log2Step: %w", err) - } - t.Height, err = ParseHexUint64(aux.Height) - if err != nil { - return fmt.Errorf("error on Height: %w", err) - } - t.FinishedAtBlock, err = ParseHexUint64(aux.FinishedAtBlock) - if err != nil { - return fmt.Errorf("error on FinishedAtBlock: %w", err) - } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.MaxLevel = uint64(aux.MaxLevel) + decoded.Level = uint64(aux.Level) + decoded.Log2Step = uint64(aux.Log2Step) + decoded.Height = uint64(aux.Height) + decoded.StartInstant = uint64(aux.StartInstant) + decoded.Allowance = uint64(aux.Allowance) + *value = decoded return nil } type Commitment struct { - ApplicationID int64 `sql:"primary_key" json:"-"` - EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` - TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` - Commitment common.Hash `sql:"primary_key" json:"commitment"` - FinalStateHash common.Hash `json:"final_state_hash"` - SubmitterAddress common.Address `json:"submitter_address"` - BlockNumber uint64 `json:"block_number"` - TxHash common.Hash `json:"tx_hash"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (c *Commitment) MarshalJSON() ([]byte, error) { - // Create an alias to avoid infinite recursion in MarshalJSON. + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` + TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` + Commitment common.Hash `sql:"primary_key" json:"commitment"` + FinalStateHash common.Hash `json:"final_state_hash"` + SubmitterAddress common.Address `json:"submitter_address"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + Snapshot CommitmentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value Commitment) MarshalJSON() ([]byte, error) { type Alias Commitment - // Define a new structure that embeds the alias but overrides the hex fields. - aux := &struct { - EpochIndex string `json:"epoch_index"` - BlockNumber string `json:"block_number"` + return json.Marshal(struct { *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` }{ - EpochIndex: fmt.Sprintf("0x%x", c.EpochIndex), - BlockNumber: fmt.Sprintf("0x%x", c.BlockNumber), - Alias: (*Alias)(c), - } - return json.Marshal(aux) + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) } -func (c *Commitment) UnmarshalJSON(data []byte) error { +func (value *Commitment) UnmarshalJSON(data []byte) error { type Alias Commitment - aux := &struct { - EpochIndex string `json:"epoch_index"` - BlockNumber string `json:"block_number"` + var decoded Commitment + aux := struct { *Alias - }{Alias: (*Alias)(c)} - - if err := json.Unmarshal(data, aux); err != nil { + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { return err } - *c = Commitment(*aux.Alias) - - var err error - c.EpochIndex, err = ParseHexUint64(aux.EpochIndex) - if err != nil { - return fmt.Errorf("error on EpochIndex: %w", err) - } - c.BlockNumber, err = ParseHexUint64(aux.BlockNumber) - if err != nil { - return fmt.Errorf("error on BlockNumber: %w", err) - } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded return nil } @@ -1782,59 +1593,117 @@ type Match struct { LeftOfTwo common.Hash `json:"left_of_two"` BlockNumber uint64 `json:"block_number"` TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + EliminableAt uint64 `json:"eliminable_at"` + LeafSeal *LeafMatchSeal `json:"leaf_seal"` Winner WinnerCommitment `json:"winner_commitment"` DeletionReason MatchDeletionReason `json:"deletion_reason"` DeletionBlockNumber uint64 `json:"deletion_block_number"` - DeletionTxHash common.Hash `json:"deletion_tx_hash"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` + DeletionLogIndex *uint64 `json:"deletion_log_index"` + Snapshot MatchSnapshot `json:"snapshot"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -func (m *Match) MarshalJSON() ([]byte, error) { - // Create an alias to avoid infinite recursion in MarshalJSON. +func (value Match) MarshalJSON() ([]byte, error) { type Alias Match - // Define a new structure that embeds the alias but overrides the hex fields. - aux := &struct { - EpochIndex string `json:"epoch_index"` - BlockNumber string `json:"block_number"` - DeletionBlockNumber string `json:"deletion_block_number"` + return json.Marshal(struct { *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + DeletionBlockNumber hexutil.Uint64 `json:"deletion_block_number"` + DeletionLogIndex *hexutil.Uint64 `json:"deletion_log_index"` }{ - EpochIndex: fmt.Sprintf("0x%x", m.EpochIndex), - BlockNumber: fmt.Sprintf("0x%x", m.BlockNumber), - DeletionBlockNumber: fmt.Sprintf("0x%x", m.DeletionBlockNumber), - Alias: (*Alias)(m), + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + EliminableAt: hexutil.Uint64(value.EliminableAt), + DeletionBlockNumber: hexutil.Uint64(value.DeletionBlockNumber), + DeletionLogIndex: (*hexutil.Uint64)(value.DeletionLogIndex), + }) +} + +func (value *Match) UnmarshalJSON(data []byte) error { + type Alias Match + var decoded Match + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + DeletionBlockNumber hexutil.Uint64 `json:"deletion_block_number"` + DeletionLogIndex *hexutil.Uint64 `json:"deletion_log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err } - return json.Marshal(aux) + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.DeletionBlockNumber = uint64(aux.DeletionBlockNumber) + decoded.DeletionLogIndex = (*uint64)(aux.DeletionLogIndex) + *value = decoded + return nil } type MatchAdvanced struct { - ApplicationID int64 `sql:"primary_key" json:"-"` - EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` - TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` - IDHash common.Hash `sql:"primary_key" json:"id_hash"` - OtherParent common.Hash `json:"other_parent"` - LeftNode common.Hash `json:"left_node"` - BlockNumber uint64 `json:"block_number"` - TxHash common.Hash `json:"tx_hash"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (m *MatchAdvanced) MarshalJSON() ([]byte, error) { - // Create an alias to avoid infinite recursion in MarshalJSON. + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + IDHash common.Hash `json:"id_hash"` + OtherParent common.Hash `json:"other_parent"` + LeftNode common.Hash `json:"left_node"` + SegmentStartPosition Uint256 `json:"segment_start_position"` + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `sql:"primary_key" json:"tx_hash"` + LogIndex uint64 `sql:"primary_key" json:"log_index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value MatchAdvanced) MarshalJSON() ([]byte, error) { type Alias MatchAdvanced - // Define a new structure that embeds the alias but overrides the hex fields. - aux := &struct { - EpochIndex string `json:"epoch_index"` - BlockNumber string `json:"block_number"` + return json.Marshal(struct { *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` }{ - EpochIndex: fmt.Sprintf("0x%x", m.EpochIndex), - BlockNumber: fmt.Sprintf("0x%x", m.BlockNumber), - Alias: (*Alias)(m), + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + EliminableAt: hexutil.Uint64(value.EliminableAt), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *MatchAdvanced) UnmarshalJSON(data []byte) error { + type Alias MatchAdvanced + var decoded MatchAdvanced + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err } - return json.Marshal(aux) + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil } // MatchDeletionReason represents the reason why a match was deleted @@ -1855,48 +1724,23 @@ var MatchDeletionReasonAllValues = []MatchDeletionReason{ } func (e *MatchDeletionReason) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for MatchDeletionReason enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "STEP": - *e = MatchDeletionReason_STEP - case "TIMEOUT": - *e = MatchDeletionReason_TIMEOUT - case "CHILD_TOURNAMENT": - *e = MatchDeletionReason_CHILD_TOURNAMENT - case "NOT_DELETED": - *e = MatchDeletionReason_NOT_DELETED - default: - return errors.New("invalid value '" + enumValue + "' for MatchDeletionReason enum") - } - - return nil + return scanEnum(e, value, MatchDeletionReasonAllValues, "MatchDeletionReason") } func (e MatchDeletionReason) String() string { return string(e) } -func MatchDeletionReasonFromUint8(v uint8) MatchDeletionReason { +func MatchDeletionReasonFromUint8(v uint8) (MatchDeletionReason, error) { switch v { case 0: - return MatchDeletionReason_STEP + return MatchDeletionReason_STEP, nil case 1: - return MatchDeletionReason_TIMEOUT + return MatchDeletionReason_TIMEOUT, nil case 2: //nolint: mnd - return MatchDeletionReason_CHILD_TOURNAMENT - case 0xff: //nolint: mnd - return MatchDeletionReason_NOT_DELETED + return MatchDeletionReason_CHILD_TOURNAMENT, nil default: - return MatchDeletionReason_STEP // default to STEP for unknown values + return "", fmt.Errorf("unmodelled MatchDeletionReason %d from contract", v) } } @@ -1916,44 +1760,23 @@ var WinnerCommitmentAllValues = []WinnerCommitment{ } func (e *WinnerCommitment) Scan(value any) error { - var enumValue string - switch val := value.(type) { - case string: - enumValue = val - case []byte: - enumValue = string(val) - default: - return errors.New("invalid value for WinnerCommitment enum. Enum value has to be of type string or []byte") - } - - switch enumValue { - case "NONE": - *e = WinnerCommitment_NONE - case "ONE": - *e = WinnerCommitment_ONE - case "TWO": - *e = WinnerCommitment_TWO - default: - return errors.New("invalid value '" + enumValue + "' for WinnerCommitment enum") - } - - return nil + return scanEnum(e, value, WinnerCommitmentAllValues, "WinnerCommitment") } func (e WinnerCommitment) String() string { return string(e) } -func WinnerCommitmentFromUint8(v uint8) WinnerCommitment { +func WinnerCommitmentFromUint8(v uint8) (WinnerCommitment, error) { switch v { case 0: - return WinnerCommitment_NONE + return WinnerCommitment_NONE, nil case 1: - return WinnerCommitment_ONE + return WinnerCommitment_ONE, nil case 2: //nolint: mnd - return WinnerCommitment_TWO + return WinnerCommitment_TWO, nil default: - return WinnerCommitment_NONE // default to NONE for unknown values + return "", fmt.Errorf("unmodelled WinnerCommitment %d from contract", v) } } diff --git a/internal/model/models_json_test.go b/internal/model/models_json_test.go index 4c5c9ff91..d592f70e3 100644 --- a/internal/model/models_json_test.go +++ b/internal/model/models_json_test.go @@ -12,6 +12,33 @@ import ( "github.com/stretchr/testify/require" ) +func TestApplicationJSONRoundtrip(t *testing.T) { + original := Application{ + Name: "direct-input-box", + IApplicationAddress: common.HexToAddress("0x1234"), + IConsensusAddress: common.HexToAddress("0x5678"), + IInputBoxAddress: common.HexToAddress("0x9abc"), + IInputBoxBlock: 10, + EpochLength: 20, + ClaimStagingPeriod: 30, + ConsensusType: Consensus_Authority, + Enabled: true, + Status: ApplicationStatus_OK, + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &fields)) + require.Contains(t, fields, "iinputbox_address") + require.NotContains(t, fields, "data_availability") + + var decoded Application + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, original, decoded) +} + func TestEpochJSONRoundtrip(t *testing.T) { root := common.HexToHash("0xabcd") iflagsY := common.HexToHash("0x1234") @@ -92,6 +119,50 @@ func TestStateProofCompleteness(t *testing.T) { require.False(t, (*Epoch)(nil).HasCompleteStateProof()) } +func TestEpochStateProof(t *testing.T) { + machineHash := common.Hash{1} + txBufferDataBlock := common.Hash{2} + iflagsYDataBlock := common.Hash{3} + htifTohostDataBlock := common.Hash{4} + epoch := &Epoch{ + MachineHash: &machineHash, + TxBufferDataBlock: &txBufferDataBlock, + TxBufferProof: make([]common.Hash, StateProofSiblingCount), + IflagsYDataBlock: &iflagsYDataBlock, + IflagsYProof: make([]common.Hash, StateProofSiblingCount), + HtifTohostDataBlock: &htifTohostDataBlock, + HtifTohostProof: make([]common.Hash, StateProofSiblingCount), + } + epoch.TxBufferProof[0] = common.Hash{5} + epoch.IflagsYProof[0] = common.Hash{6} + epoch.HtifTohostProof[0] = common.Hash{7} + + proof, err := epoch.StateProof() + require.NoError(t, err) + require.Equal(t, machineHash, proof.MachineHash) + require.Equal(t, txBufferDataBlock, proof.TxBufferDataBlock) + require.Equal(t, iflagsYDataBlock, proof.IflagsYDataBlock) + require.Equal(t, htifTohostDataBlock, proof.HtifTohostDataBlock) + require.Equal(t, [32]byte{5}, proof.TxBufferProof[0]) + require.Equal(t, [32]byte{6}, proof.IflagsYProof[0]) + require.Equal(t, [32]byte{7}, proof.HtifTohostProof[0]) + + proof.TxBufferProof[0][0] = 8 + proof.IflagsYProof[0][0] = 9 + proof.HtifTohostProof[0][0] = 10 + require.Equal(t, byte(5), epoch.TxBufferProof[0][0]) + require.Equal(t, byte(6), epoch.IflagsYProof[0][0]) + require.Equal(t, byte(7), epoch.HtifTohostProof[0][0]) +} + +func TestEpochStateProofRejectsIncompleteEpoch(t *testing.T) { + _, err := (&Epoch{}).StateProof() + require.ErrorIs(t, err, ErrIncompleteStateProof) + + _, err = (*Epoch)(nil).StateProof() + require.ErrorIs(t, err, ErrIncompleteStateProof) +} + func TestInputJSONRoundtrip(t *testing.T) { machineHash := common.HexToHash("0x1234") txBufferDataBlock := common.HexToHash("0xabcd") @@ -168,8 +239,9 @@ func TestInputUnmarshalJSONInvalidHex(t *testing.T) { wantErr: "LogIndex", }, { - name: "invalid ExceptionData", - json: `{"epoch_index":"0x0","index":"0x0","block_number":"0x0","raw_data":"0x","exception_data":"not-hex","log_index":"0x0"}`, + name: "invalid ExceptionData", + json: `{"epoch_index":"0x0","index":"0x0","block_number":"0x0","raw_data":"0x",` + + `"exception_data":"not-hex","log_index":"0x0"}`, wantErr: "ExceptionData", }, } @@ -271,11 +343,13 @@ func TestTournamentJSONRoundtrip(t *testing.T) { Level: 2, Log2Step: 16, Height: 8, - WinnerCommitment: &winner, - FinalStateHash: &finalState, - FinishedAtBlock: 9999, - CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), - UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), + Snapshot: TournamentSnapshot{ + WinnerCommitment: &winner, + FinalStateHash: &finalState, + FinishedAtBlock: 9999, + }, + CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), + UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), } data, err := json.Marshal(&original) @@ -295,9 +369,7 @@ func TestTournamentJSONRoundtrip(t *testing.T) { require.Equal(t, original.Level, decoded.Level) require.Equal(t, original.Log2Step, decoded.Log2Step) require.Equal(t, original.Height, decoded.Height) - require.Equal(t, original.WinnerCommitment, decoded.WinnerCommitment) - require.Equal(t, original.FinalStateHash, decoded.FinalStateHash) - require.Equal(t, original.FinishedAtBlock, decoded.FinishedAtBlock) + require.Equal(t, original.Snapshot, decoded.Snapshot) } func TestCommitmentJSONRoundtrip(t *testing.T) { diff --git a/internal/model/prt_enums.go b/internal/model/prt_enums.go new file mode 100644 index 000000000..a46ea232c --- /dev/null +++ b/internal/model/prt_enums.go @@ -0,0 +1,188 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +type TournamentKind string + +const ( + TournamentKindLeaf TournamentKind = "LEAF" + TournamentKindNonLeaf TournamentKind = "NON_LEAF" +) + +var TournamentKindAllValues = []TournamentKind{ + TournamentKindLeaf, + TournamentKindNonLeaf, +} + +func (e *TournamentKind) Scan(value any) error { + return scanEnum(e, value, TournamentKindAllValues, "TournamentKind") +} + +func (e TournamentKind) String() string { + return string(e) +} + +type TournamentStandingState string + +const ( + TournamentStandingMatchesActive TournamentStandingState = "MATCHES_ACTIVE" + TournamentStandingAwaitingClosure TournamentStandingState = "AWAITING_CLOSURE" + TournamentStandingRootWinner TournamentStandingState = "ROOT_WINNER" + TournamentStandingRootFailed TournamentStandingState = "ROOT_FAILED" + TournamentStandingInnerWinner TournamentStandingState = "INNER_WINNER" + TournamentStandingInnerEliminableNoWinner TournamentStandingState = "INNER_ELIMINABLE_NO_WINNER" + TournamentStandingInnerEliminableWinnerExpired TournamentStandingState = "INNER_ELIMINABLE_WINNER_EXPIRED" +) + +var TournamentStandingStateAllValues = []TournamentStandingState{ + TournamentStandingMatchesActive, + TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, + TournamentStandingRootFailed, + TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, + TournamentStandingInnerEliminableWinnerExpired, +} + +func (e *TournamentStandingState) Scan(value any) error { + return scanEnum(e, value, TournamentStandingStateAllValues, "TournamentStandingState") +} + +func (e TournamentStandingState) String() string { + return string(e) +} + +type MatchPhase string + +const ( + MatchPhaseUninitialized MatchPhase = "UNINITIALIZED" + MatchPhaseBisecting MatchPhase = "BISECTING" + MatchPhaseReadyToSeal MatchPhase = "READY_TO_SEAL" + MatchPhaseSealed MatchPhase = "SEALED" +) + +var MatchPhaseAllValues = []MatchPhase{ + MatchPhaseUninitialized, + MatchPhaseBisecting, + MatchPhaseReadyToSeal, + MatchPhaseSealed, +} + +func (e *MatchPhase) Scan(value any) error { + return scanEnum(e, value, MatchPhaseAllValues, "MatchPhase") +} + +func (e MatchPhase) String() string { + return string(e) +} + +type CommitmentSide string + +const ( + CommitmentSideOne CommitmentSide = "ONE" + CommitmentSideTwo CommitmentSide = "TWO" +) + +var CommitmentSideAllValues = []CommitmentSide{ + CommitmentSideOne, + CommitmentSideTwo, +} + +func (e *CommitmentSide) Scan(value any) error { + return scanEnum(e, value, CommitmentSideAllValues, "CommitmentSide") +} + +func (e CommitmentSide) String() string { + return string(e) +} + +type MatchTimeoutOutcome string + +const ( + MatchTimeoutNone MatchTimeoutOutcome = "NONE" + MatchTimeoutOneWins MatchTimeoutOutcome = "ONE_WINS" + MatchTimeoutTwoWins MatchTimeoutOutcome = "TWO_WINS" + MatchTimeoutEliminateBoth MatchTimeoutOutcome = "ELIMINATE_BOTH" +) + +var MatchTimeoutOutcomeAllValues = []MatchTimeoutOutcome{ + MatchTimeoutNone, + MatchTimeoutOneWins, + MatchTimeoutTwoWins, + MatchTimeoutEliminateBoth, +} + +func (e *MatchTimeoutOutcome) Scan(value any) error { + return scanEnum(e, value, MatchTimeoutOutcomeAllValues, "MatchTimeoutOutcome") +} + +func (e MatchTimeoutOutcome) String() string { + return string(e) +} + +type InnerTournamentDisposition string + +const ( + InnerTournamentUnsettled InnerTournamentDisposition = "UNSETTLED" + InnerTournamentWinner InnerTournamentDisposition = "WINNER" + InnerTournamentEliminable InnerTournamentDisposition = "ELIMINABLE" +) + +var InnerTournamentDispositionAllValues = []InnerTournamentDisposition{ + InnerTournamentUnsettled, + InnerTournamentWinner, + InnerTournamentEliminable, +} + +func (e *InnerTournamentDisposition) Scan(value any) error { + return scanEnum(e, value, InnerTournamentDispositionAllValues, "InnerTournamentDisposition") +} + +func (e InnerTournamentDisposition) String() string { + return string(e) +} + +type BondDisposition string + +const ( + BondDispositionTournamentRunning BondDisposition = "TOURNAMENT_RUNNING" + BondDispositionNoWinner BondDisposition = "NO_WINNER" + BondDispositionRecoverable BondDisposition = "RECOVERABLE" + BondDispositionRecovered BondDisposition = "RECOVERED" +) + +var BondDispositionAllValues = []BondDisposition{ + BondDispositionTournamentRunning, + BondDispositionNoWinner, + BondDispositionRecoverable, + BondDispositionRecovered, +} + +func (e *BondDisposition) Scan(value any) error { + return scanEnum(e, value, BondDispositionAllValues, "BondDisposition") +} + +func (e BondDisposition) String() string { + return string(e) +} + +type BondEventType string + +const ( + BondEventPartialRefund BondEventType = "PARTIAL_BOND_REFUND" + BondEventRecovered BondEventType = "BOND_RECOVERED" +) + +var BondEventTypeAllValues = []BondEventType{ + BondEventPartialRefund, + BondEventRecovered, +} + +func (e *BondEventType) Scan(value any) error { + return scanEnum(e, value, BondEventTypeAllValues, "BondEventType") +} + +func (e BondEventType) String() string { + return string(e) +} diff --git a/internal/model/prt_enums_test.go b/internal/model/prt_enums_test.go new file mode 100644 index 000000000..911e18be0 --- /dev/null +++ b/internal/model/prt_enums_test.go @@ -0,0 +1,201 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTournamentKindValues(t *testing.T) { + expected := []string{"LEAF", "NON_LEAF"} + actual := make([]string, len(TournamentKindAllValues)) + for i, value := range TournamentKindAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value TournamentKind + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := TournamentKindLeaf + require.Error(t, value.Scan(invalid)) + require.Equal(t, TournamentKindLeaf, value) + } +} + +func TestTournamentStandingStateValues(t *testing.T) { + expected := []string{ + "MATCHES_ACTIVE", "AWAITING_CLOSURE", "ROOT_WINNER", "ROOT_FAILED", + "INNER_WINNER", "INNER_ELIMINABLE_NO_WINNER", "INNER_ELIMINABLE_WINNER_EXPIRED", + } + actual := make([]string, len(TournamentStandingStateAllValues)) + for i, value := range TournamentStandingStateAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value TournamentStandingState + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := TournamentStandingMatchesActive + require.Error(t, value.Scan(invalid)) + require.Equal(t, TournamentStandingMatchesActive, value) + } +} + +func TestMatchPhaseValues(t *testing.T) { + expected := []string{"UNINITIALIZED", "BISECTING", "READY_TO_SEAL", "SEALED"} + actual := make([]string, len(MatchPhaseAllValues)) + for i, value := range MatchPhaseAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value MatchPhase + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := MatchPhaseUninitialized + require.Error(t, value.Scan(invalid)) + require.Equal(t, MatchPhaseUninitialized, value) + } +} + +func TestCommitmentSideValues(t *testing.T) { + expected := []string{"ONE", "TWO"} + actual := make([]string, len(CommitmentSideAllValues)) + for i, value := range CommitmentSideAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value CommitmentSide + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := CommitmentSideOne + require.Error(t, value.Scan(invalid)) + require.Equal(t, CommitmentSideOne, value) + } +} + +func TestMatchTimeoutOutcomeValues(t *testing.T) { + expected := []string{enumGoldenNone, "ONE_WINS", "TWO_WINS", "ELIMINATE_BOTH"} + actual := make([]string, len(MatchTimeoutOutcomeAllValues)) + for i, value := range MatchTimeoutOutcomeAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value MatchTimeoutOutcome + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := MatchTimeoutNone + require.Error(t, value.Scan(invalid)) + require.Equal(t, MatchTimeoutNone, value) + } +} + +func TestInnerTournamentDispositionValues(t *testing.T) { + expected := []string{"UNSETTLED", "WINNER", "ELIMINABLE"} + actual := make([]string, len(InnerTournamentDispositionAllValues)) + for i, value := range InnerTournamentDispositionAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value InnerTournamentDisposition + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := InnerTournamentUnsettled + require.Error(t, value.Scan(invalid)) + require.Equal(t, InnerTournamentUnsettled, value) + } +} + +func TestBondDispositionValues(t *testing.T) { + expected := []string{"TOURNAMENT_RUNNING", "NO_WINNER", "RECOVERABLE", "RECOVERED"} + actual := make([]string, len(BondDispositionAllValues)) + for i, value := range BondDispositionAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value BondDisposition + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := BondDispositionTournamentRunning + require.Error(t, value.Scan(invalid)) + require.Equal(t, BondDispositionTournamentRunning, value) + } +} + +func TestBondEventTypeValues(t *testing.T) { + expected := []string{"PARTIAL_BOND_REFUND", "BOND_RECOVERED"} + actual := make([]string, len(BondEventTypeAllValues)) + for i, value := range BondEventTypeAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value BondEventType + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := BondEventPartialRefund + require.Error(t, value.Scan(invalid)) + require.Equal(t, BondEventPartialRefund, value) + } +} + +func TestContractMatchEnumsRejectUnknownValues(t *testing.T) { + reasons := []MatchDeletionReason{MatchDeletionReason_STEP, MatchDeletionReason_TIMEOUT, MatchDeletionReason_CHILD_TOURNAMENT} + winners := []WinnerCommitment{WinnerCommitment_NONE, WinnerCommitment_ONE, WinnerCommitment_TWO} + for value := 0; value <= 255; value++ { + reason, reasonErr := MatchDeletionReasonFromUint8(uint8(value)) + winner, winnerErr := WinnerCommitmentFromUint8(uint8(value)) + if value < len(reasons) { + require.NoError(t, reasonErr) + require.Equal(t, reasons[value], reason) + require.NoError(t, winnerErr) + require.Equal(t, winners[value], winner) + } else { + require.Error(t, reasonErr) + require.Empty(t, reason) + require.Error(t, winnerErr) + require.Empty(t, winner) + } + } +} diff --git a/internal/model/prt_observer.go b/internal/model/prt_observer.go new file mode 100644 index 000000000..076a6ce4c --- /dev/null +++ b/internal/model/prt_observer.go @@ -0,0 +1,361 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +type TournamentSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Standing TournamentStandingState `json:"standing"` + AcceptsJoins bool `json:"accepts_joins"` + Candidate *common.Hash `json:"candidate"` + WinnerCommitment *common.Hash `json:"winner_commitment"` + FinalStateHash *common.Hash `json:"final_state_hash"` + ParentCommitment *common.Hash `json:"parent_commitment"` + FinishedAtBlock uint64 `json:"finished_at_block"` + WinnerExpiresAt uint64 `json:"winner_expires_at"` + InnerResult *TournamentInnerResult `json:"inner_result"` + BondRecovery TournamentBondRecovery `json:"bond_recovery"` +} + +func (value TournamentSnapshot) MarshalJSON() ([]byte, error) { + type Alias TournamentSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + FinishedAtBlock hexutil.Uint64 `json:"finished_at_block"` + WinnerExpiresAt hexutil.Uint64 `json:"winner_expires_at"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + FinishedAtBlock: hexutil.Uint64(value.FinishedAtBlock), + WinnerExpiresAt: hexutil.Uint64(value.WinnerExpiresAt), + }) +} + +func (value *TournamentSnapshot) UnmarshalJSON(data []byte) error { + type Alias TournamentSnapshot + var decoded TournamentSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + FinishedAtBlock hexutil.Uint64 `json:"finished_at_block"` + WinnerExpiresAt hexutil.Uint64 `json:"winner_expires_at"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.FinishedAtBlock = uint64(aux.FinishedAtBlock) + decoded.WinnerExpiresAt = uint64(aux.WinnerExpiresAt) + *value = decoded + return nil +} + +type TournamentInnerResult struct { + Disposition InnerTournamentDisposition `json:"disposition"` + ParentCommitment *common.Hash `json:"parent_commitment"` + PausedAllowance uint64 `json:"paused_allowance"` +} + +func (value TournamentInnerResult) MarshalJSON() ([]byte, error) { + type Alias TournamentInnerResult + return json.Marshal(struct { + *Alias + PausedAllowance hexutil.Uint64 `json:"paused_allowance"` + }{ + Alias: (*Alias)(&value), + PausedAllowance: hexutil.Uint64(value.PausedAllowance), + }) +} + +func (value *TournamentInnerResult) UnmarshalJSON(data []byte) error { + type Alias TournamentInnerResult + var decoded TournamentInnerResult + aux := struct { + *Alias + PausedAllowance hexutil.Uint64 `json:"paused_allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.PausedAllowance = uint64(aux.PausedAllowance) + *value = decoded + return nil +} + +type TournamentBondRecovery struct { + Disposition BondDisposition `json:"disposition"` + Claimer *common.Address `json:"claimer"` + Payment *Uint256 `json:"payment"` +} + +type CommitmentSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Claimer common.Address `json:"claimer"` + ClockRunning bool `json:"clock_running"` + ClockDeadline uint64 `json:"clock_deadline"` + ClockAllowance uint64 `json:"clock_allowance"` +} + +func (value CommitmentSnapshot) MarshalJSON() ([]byte, error) { + type Alias CommitmentSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + ClockDeadline hexutil.Uint64 `json:"clock_deadline"` + ClockAllowance hexutil.Uint64 `json:"clock_allowance"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + ClockDeadline: hexutil.Uint64(value.ClockDeadline), + ClockAllowance: hexutil.Uint64(value.ClockAllowance), + }) +} + +func (value *CommitmentSnapshot) UnmarshalJSON(data []byte) error { + type Alias CommitmentSnapshot + var decoded CommitmentSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + ClockDeadline hexutil.Uint64 `json:"clock_deadline"` + ClockAllowance hexutil.Uint64 `json:"clock_allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.ClockDeadline = uint64(aux.ClockDeadline) + decoded.ClockAllowance = uint64(aux.ClockAllowance) + *value = decoded + return nil +} + +type MatchSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Phase MatchPhase `json:"phase"` + Bisection *MatchBisectionSnapshot `json:"bisection"` + Sealed *MatchSealedSnapshot `json:"sealed"` + TimeoutOutcome MatchTimeoutOutcome `json:"timeout_outcome"` + DeferredCharge uint64 `json:"deferred_charge"` +} + +func (value MatchSnapshot) MarshalJSON() ([]byte, error) { + type Alias MatchSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + DeferredCharge hexutil.Uint64 `json:"deferred_charge"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + DeferredCharge: hexutil.Uint64(value.DeferredCharge), + }) +} + +func (value *MatchSnapshot) UnmarshalJSON(data []byte) error { + type Alias MatchSnapshot + var decoded MatchSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + DeferredCharge hexutil.Uint64 `json:"deferred_charge"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.DeferredCharge = uint64(aux.DeferredCharge) + *value = decoded + return nil +} + +type MatchBisectionSnapshot struct { + RevealingParent common.Hash `json:"revealing_parent"` + WaitingLeft common.Hash `json:"waiting_left"` + WaitingRight common.Hash `json:"waiting_right"` + SegmentStartPosition Uint256 `json:"segment_start_position"` + SegmentStartCycle Uint256 `json:"segment_start_cycle"` + CurrentHeight *uint64 `json:"current_height"` + Responder CommitmentSide `json:"responder"` +} + +func (value MatchBisectionSnapshot) MarshalJSON() ([]byte, error) { + type Alias MatchBisectionSnapshot + return json.Marshal(struct { + *Alias + CurrentHeight *hexutil.Uint64 `json:"current_height"` + }{ + Alias: (*Alias)(&value), + CurrentHeight: (*hexutil.Uint64)(value.CurrentHeight), + }) +} + +func (value *MatchBisectionSnapshot) UnmarshalJSON(data []byte) error { + type Alias MatchBisectionSnapshot + var decoded MatchBisectionSnapshot + aux := struct { + *Alias + CurrentHeight *hexutil.Uint64 `json:"current_height"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.CurrentHeight = (*uint64)(aux.CurrentHeight) + *value = decoded + return nil +} + +type MatchSealedSnapshot struct { + AgreeState common.Hash `json:"agree_state"` + DivergencePosition Uint256 `json:"divergence_position"` + DivergenceCycle Uint256 `json:"divergence_cycle"` + FinalStateOne common.Hash `json:"final_state_one"` + FinalStateTwo common.Hash `json:"final_state_two"` +} + +type TournamentCreationEvent struct { + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` +} + +func (value TournamentCreationEvent) MarshalJSON() ([]byte, error) { + type Alias TournamentCreationEvent + return json.Marshal(struct { + *Alias + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *TournamentCreationEvent) UnmarshalJSON(data []byte) error { + type Alias TournamentCreationEvent + var decoded TournamentCreationEvent + aux := struct { + *Alias + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type LeafMatchSeal struct { + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` +} + +func (value LeafMatchSeal) MarshalJSON() ([]byte, error) { + type Alias LeafMatchSeal + return json.Marshal(struct { + *Alias + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EliminableAt: hexutil.Uint64(value.EliminableAt), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *LeafMatchSeal) UnmarshalJSON(data []byte) error { + type Alias LeafMatchSeal + var decoded LeafMatchSeal + aux := struct { + *Alias + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type BondEvent struct { + ApplicationID int64 `json:"-"` + EpochIndex uint64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + Type BondEventType `json:"type"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + Refund *PartialBondRefund `json:"refund"` + Recovery *BondRecovered `json:"recovery"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value BondEvent) MarshalJSON() ([]byte, error) { + type Alias BondEvent + return json.Marshal(struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *BondEvent) UnmarshalJSON(data []byte) error { + type Alias BondEvent + var decoded BondEvent + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type PartialBondRefund struct { + Recipient common.Address `json:"recipient"` + Value Uint256 `json:"value"` + Success bool `json:"success"` +} + +type BondRecovered struct { + Commitment common.Hash `json:"commitment"` + Claimer common.Address `json:"claimer"` + Payment Uint256 `json:"payment"` + Burned Uint256 `json:"burned"` +} diff --git a/internal/model/uint256.go b/internal/model/uint256.go new file mode 100644 index 000000000..207876884 --- /dev/null +++ b/internal/model/uint256.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/holiman/uint256" +) + +// Uint256 stores a Solidity unsigned integer without narrowing its value. +// SQL uses decimal NUMERIC values; JSON uses canonical hexadecimal quantities. +type Uint256 uint256.Int + +var ErrInvalidUint256 = errors.New("invalid uint256") + +func Uint256FromBig(value *big.Int) (Uint256, error) { + if value == nil || value.Sign() < 0 { + return Uint256{}, ErrInvalidUint256 + } + parsed, overflow := uint256.FromBig(value) + if overflow { + return Uint256{}, ErrInvalidUint256 + } + return Uint256(*parsed), nil +} + +func (value Uint256) ToBig() *big.Int { + return (*uint256.Int)(&value).ToBig() +} + +func (value *Uint256) Scan(source any) error { + if source == nil { + return fmt.Errorf("cannot scan NULL into Uint256: %w", ErrInvalidUint256) + } + // PostgreSQL NUMERIC can retain decimal scale for an exact integer (1.00). + // Remove only zero fractional digits; a fractional value is not uint256. + var text string + switch source := source.(type) { + case string: + text = source + case []byte: + text = string(source) + default: + return fmt.Errorf("unsupported Uint256 scan type %T: %w", source, ErrInvalidUint256) + } + if text == "" { + return fmt.Errorf("empty Uint256 value: %w", ErrInvalidUint256) + } + if whole, fraction, found := strings.Cut(text, "."); found { + if fraction == "" || strings.Trim(fraction, "0") != "" { + return fmt.Errorf("fractional Uint256 value: %w", ErrInvalidUint256) + } + text = whole + } + var parsed uint256.Int + if err := parsed.Scan(text); err != nil { + return fmt.Errorf("scan Uint256: %w", err) + } + *value = Uint256(parsed) + return nil +} + +func (value Uint256) Value() (driver.Value, error) { + return (*uint256.Int)(&value).Value() +} + +func (value Uint256) MarshalJSON() ([]byte, error) { + return json.Marshal((*uint256.Int)(&value).Hex()) +} + +func (value *Uint256) UnmarshalJSON(data []byte) error { + var text string + if err := json.Unmarshal(data, &text); err != nil { + return err + } + var parsed uint256.Int + if err := parsed.SetFromHex(text); err != nil { + return fmt.Errorf("decode Uint256: %w", err) + } + *value = Uint256(parsed) + return nil +} diff --git a/internal/model/uint256_test.go b/internal/model/uint256_test.go new file mode 100644 index 000000000..235d70a8b --- /dev/null +++ b/internal/model/uint256_test.go @@ -0,0 +1,108 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/require" +) + +func TestUint256Codec(t *testing.T) { + const maximum = "115792089237316195423570985008687907853269984665640564039457584007913129639935" + const over = "115792089237316195423570985008687907853269984665640564039457584007913129639936" + const maxHex = `"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"` + mapping := pgtype.NewMap() + for _, decimal := range []string{"0", "18446744073709551616", maximum} { + var value Uint256 + if err := value.Scan(decimal); err != nil { + t.Fatal(err) + } + for _, format := range []int16{pgtype.TextFormatCode, pgtype.BinaryFormatCode} { + encoded, err := mapping.Encode(pgtype.NumericOID, format, value, nil) + if err != nil { + t.Fatal(err) + } + var decoded Uint256 + if err := mapping.Scan(pgtype.NumericOID, format, encoded, &decoded); err != nil { + t.Fatal(err) + } + if value != decoded { + t.Fatalf("numeric round trip: got %v want %v", decoded, value) + } + } + if decimal == maximum { + encoded, err := json.Marshal(value) + if err != nil || string(encoded) != maxHex { + t.Fatalf("JSON: %s, %v", encoded, err) + } + } + } + for _, src := range []any{nil, "", "-1", "0.5", over, 123, "1e78"} { + value := Uint256(*uint256.NewInt(7)) + before := value + if err := value.Scan(src); err == nil { + t.Fatalf("accepted invalid SQL value %#v", src) + } + if value != before { + t.Fatalf("changed receiver after SQL error %#v", src) + } + } + for _, raw := range []string{`"0x0"`, `"0x10000000000000000"`, maxHex} { + var value Uint256 + if err := json.Unmarshal([]byte(raw), &value); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(value) + if err != nil || string(encoded) != raw { + t.Fatalf("JSON round trip: %s, %v", encoded, err) + } + } + for _, raw := range []string{ + `null`, `1`, `"1"`, `"0x"`, `"0x00"`, `"-0x1"`, + `"0x10000000000000000000000000000000000000000000000000000000000000000"`, + } { + value := Uint256(*uint256.NewInt(7)) + before := value + if err := json.Unmarshal([]byte(raw), &value); err == nil { + t.Fatalf("accepted invalid JSON %s", raw) + } + if value != before { + t.Fatalf("changed receiver after JSON error %s", raw) + } + } + var nullable *Uint256 + if err := mapping.Scan(pgtype.NumericOID, pgtype.TextFormatCode, nil, &nullable); err != nil { + t.Fatal(err) + } + if nullable != nil { + t.Fatal("SQL NULL must remain nil") + } + for _, format := range []int16{pgtype.TextFormatCode, pgtype.BinaryFormatCode} { + scaledInteger := pgtype.Numeric{Int: big.NewInt(100), Exp: -2, Valid: true} + encoded, err := mapping.Encode(pgtype.NumericOID, format, scaledInteger, nil) + require.NoError(t, err) + var decoded Uint256 + require.NoError(t, mapping.Scan(pgtype.NumericOID, format, encoded, &decoded)) + require.Equal(t, big.NewInt(1), decoded.ToBig()) + } +} + +func TestUint256FromBig(t *testing.T) { + limit := new(big.Int).Lsh(big.NewInt(1), 256) + for _, value := range []*big.Int{nil, big.NewInt(-1), limit} { + _, err := Uint256FromBig(value) + require.ErrorIs(t, err, ErrInvalidUint256) + } + maximum := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1)) + value, err := Uint256FromBig(maximum) + require.NoError(t, err) + require.Equal(t, maximum, value.ToBig()) + maximum.SetInt64(0) + require.NotZero(t, value.ToBig().Sign(), "the model must not share mutable big.Int storage") +} diff --git a/internal/prt/block_policy_test.go b/internal/prt/block_policy_test.go new file mode 100644 index 000000000..78e8e7f91 --- /dev/null +++ b/internal/prt/block_policy_test.go @@ -0,0 +1,450 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func TestCreateUsesMatchingPersistedPRTDefaultBlock(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + rawConfig, err := json.Marshal(PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, + ClaimSubmissionEnabled: false, + ChainID: 42, + }) + require.NoError(t, err) + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return(rawConfig, nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + + svc, err := Create(ctx, &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, + BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainId: 42, + FeatureClaimSubmissionEnabled: false, + }, + Repository: repo, + EthClient: client, + AdapterFactory: &adapterFactoryMock{}, + }) + require.NoError(t, err) + s := svc.(*Service) + require.False(t, s.submissionEnabled) + require.NotNil(t, s.pendingTransactions) + require.NotNil(t, s.disputeWarnings) + require.NotNil(t, s.zeroStagingWarnings) + require.NotNil(t, s.rootBondRecoveries) + block, err := s.getDefaultBlockNumber(ctx) + require.NoError(t, err) + require.Equal(t, uint64(100), block) + client.AssertNotCalled(t, "BlockNumber", mock.Anything) + client.AssertExpectations(t) + repo.AssertExpectations(t) +} + +type prtBlockPolicyCreateRepository struct { + repository.Repository + mock.Mock +} + +func (r *prtBlockPolicyCreateRepository) LoadNodeConfigRaw(ctx context.Context, key string) ( + []byte, time.Time, time.Time, error, +) { + args := r.Called(ctx, key) + return args.Get(0).([]byte), time.Time{}, time.Time{}, args.Error(1) +} + +func TestPRTDefaultBlockFailureDoesNotFallBackToLatest(t *testing.T) { + for _, test := range []struct { + name string + header *types.Header + err error + }{ + {name: "unavailable finalized head", err: errors.New("finalized head unavailable")}, + {name: "nil header"}, + {name: "nil number", header: &types.Header{}}, + {name: "negative number", header: &types.Header{Number: big.NewInt(-1)}}, + {name: "overflow number", header: &types.Header{Number: new(big.Int).Lsh(big.NewInt(1), 64)}}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + s.defaultBlock = model.DefaultBlock_Finalized + s.submissionEnabled = true + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(test.header, test.err).Once() + s.client = client + + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{prtRevertTestApp()}, uint64(1), nil).Once() + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.Error(t, err) + if test.err != nil { + require.ErrorIs(t, err, test.err) + } + client.AssertNotCalled(t, "BlockNumber", mock.Anything) + repo.AssertNotCalled(t, "ListEpochs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + client.AssertExpectations(t) + }) + } +} + +func TestPRTLatestStageCanAcceptWithoutPersistingStage(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + trusted := resultTestSnapshot(f.epoch, false) + latest := resultTestSnapshot(f.epoch, true) + latest.sealed.StagingBlockNumber = 118 + latest.accept.IsClaimStagingPeriodOver = true + f.expectTick(100, 120, trusted, latest) + acceptTx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.consensus.On("AcceptStagedTournamentResult", mock.Anything, f.epoch.Index).Return(acceptTx, nil).Once() + + require.NoError(t, f.tick(100)) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Nil(t, f.epoch.StagedAtBlock) + require.Equal(t, acceptTx.Hash(), f.s.pendingTransactions[f.app.ID].Hash) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) +} + +func TestPRTLatestStageReorgDoesNotLatchLocalState(t *testing.T) { + for _, restaged := range []bool{false, true} { + name := "removed stage can be submitted again" + if restaged { + name = "same result restaged at a different block" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + trusted := resultTestSnapshot(f.epoch, false) + latest := resultTestSnapshot(f.epoch, true) + latest.sealed.StagingBlockNumber = 118 + f.expectTick(100, 120, trusted, latest) + require.NoError(t, f.tick(100)) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Nil(t, f.epoch.StagedAtBlock) + + changed := resultTestSnapshot(f.epoch, restaged) + if restaged { + changed.sealed.StagingBlockNumber = 121 + } else { + f.consensus.On("StageTournamentResult", mock.Anything, f.epoch.Index, mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 2}), nil).Once() + } + f.expectTick(101, 122, trusted, changed) + require.NoError(t, f.tick(101)) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Nil(t, f.epoch.StagedAtBlock) + if !restaged { + require.Equal(t, tournamentActionStage, f.s.pendingTransactions[f.app.ID].Action) + } + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + }) + } +} + +func TestPRTStageIsPersistedOnlyWhenTrustedHeadCatchesUp(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + unstaged := resultTestSnapshot(f.epoch, false) + staged := resultTestSnapshot(f.epoch, true) + staged.sealed.StagingBlockNumber = 118 + f.expectTick(100, 120, unstaged, staged) + require.NoError(t, f.tick(100)) + f.assertNoStageWrite(t) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, f.epoch.Index, uint64(118)).Return(nil).Once() + f.expectTick(119, 120, staged, staged) + require.NoError(t, f.tick(119)) + require.Equal(t, model.EpochStatus_ClaimStaged, f.epoch.Status) + require.NotNil(t, f.epoch.StagedAtBlock) + require.Equal(t, uint64(118), *f.epoch.StagedAtBlock) + f.assertNoPermanentStatusWrite(t) +} + +func TestPRTLatestResultMismatchDoesNotChangeApplicationStatus(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*daveConsensusSnapshot) + }{ + {name: "winner commitment", mutate: func(s *daveConsensusSnapshot) { + s.stage.WinnerCommitment = common.HexToHash("0xbad") + }}, + {name: "winner machine", mutate: func(s *daveConsensusSnapshot) { + s.stage.WinnerPostEpochMachineStateHash = common.HexToHash("0xbad") + s.sealed.StagedPostEpochMachineStateHash = s.stage.WinnerPostEpochMachineStateHash + s.accept.StagedPostEpochMachineStateHash = s.stage.WinnerPostEpochMachineStateHash + }}, + {name: "staged outputs", mutate: func(s *daveConsensusSnapshot) { + s.sealed.StagedPostEpochOutputsMerkleRoot = common.HexToHash("0xbad") + s.accept.StagedPostEpochOutputsMerkleRoot = s.sealed.StagedPostEpochOutputsMerkleRoot + }}, + {name: "input bounds", mutate: func(s *daveConsensusSnapshot) { + s.sealed.InputIndexUpperBound++ + }}, + {name: "tournament address", mutate: func(s *daveConsensusSnapshot) { + s.sealed.Tournament = common.HexToAddress("0xbad") + }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + trusted := resultTestSnapshot(f.epoch, false) + latest := resultTestSnapshot(f.epoch, true) + latest.sealed.StagingBlockNumber = 118 + latest.accept.IsClaimStagingPeriodOver = true + test.mutate(&latest) + f.expectTick(100, 120, trusted, latest) + + // A temporary mismatch can return an error or defer work. Neither + // outcome permits a durable failure status or a result transaction. + _ = f.tick(100) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + }) + } +} + +func TestPRTLatestProofRevertWaitsForTrustedWinner(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + trusted := resultTestSnapshot(f.epoch, false) + trusted.stage.IsFinished = false + trusted.stage.WinnerCommitment = common.Hash{} + trusted.stage.WinnerPostEpochMachineStateHash = common.Hash{} + latest := resultTestSnapshot(f.epoch, false) + f.expectTick(100, 120, trusted, latest) + f.consensus.On("StageTournamentResult", mock.Anything, f.epoch.Index, mock.Anything). + Return((*types.Transaction)(nil), daveConsensusRevertError("InvalidMachineMerkleProof")).Once() + // The error path must confirm the winner before it diagnoses the stored + // proof. The tournament is still unfinished at the configured head. + f.expectHead(100) + f.expectSnapshot(100, trusted) + + require.ErrorContains(t, f.tick(100), "winner is not confirmed") + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + require.Empty(t, f.s.pendingTransactions) +} + +func TestPRTObserverStopsAtTrustedHead(t *testing.T) { + for _, finishedAt := range []uint64{0, 118} { + name := "running tournament" + if finishedAt != 0 { + name = "stored finish block is above trusted head" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock = 80 + f.app.LastEpochCheckBlock = 100 + f.epoch.LastBlock = 90 + trusted := resultTestSnapshot(f.epoch, false) + trusted.stage.IsFinished = false + trusted.stage.WinnerCommitment = common.Hash{} + trusted.stage.WinnerPostEpochMachineStateHash = common.Hash{} + latest := resultTestSnapshot(f.epoch, true) + latest.sealed.StagingBlockNumber = 118 + f.expectTick(100, 120, trusted, latest) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.TournamentAddress.Hex()). + Return(&model.Tournament{ + ApplicationID: f.app.ID, + EpochIndex: f.epoch.Index, + Address: *f.epoch.TournamentAddress, + MaxLevel: 1, + StartInstant: 90, + Kind: model.TournamentKindLeaf, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: finishedAt}, + }, nil).Once() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(tournament, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + tournament.On("Descriptor", opts). + Return(TournamentDescriptor{BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, StartInstant: 90}, nil).Once() + tournament.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingMatchesActive}, nil).Once() + expectTournamentAuxiliaryReads(tournament, opts, RootLevel, model.TournamentStandingMatchesActive) + tournament.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts != nil && opts.Start == 90 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + tournament.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + observer := &observerCheckpointFixture{t: t, s: f.s, repo: f.repo, app: f.app} + observer.emptyParticipants(f.epoch, *f.epoch.TournamentAddress) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.Anything, uint64(100)).Return(nil).Once() + observer.unaccepted(f.epoch, 0) + + require.NoError(t, runPRTApplicationTick(f.s, f.app)) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestPRTJoinUsesLatestCommitmentToSuppressDuplicate(t *testing.T) { + for _, matches := range []bool{true, false} { + name := "matching final state" + if !matches { + name = "different final state defers" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + f.expectTick(100, 120, snapshot, snapshot) + f.repo.On("GetCommitment", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index, + f.epoch.TournamentAddress.Hex(), f.epoch.Commitment.String()).Return((*model.Commitment)(nil), nil).Once() + finalState := *f.epoch.MachineHash + if !matches { + finalState = common.HexToHash("0xbad") + } + tournament := &tournamentAdapterMock{} + tournament.On("CommitmentStanding", mock.MatchedBy(resultCallOptsAtBlock(120)), [32]byte(*f.epoch.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: finalState}, nil).Once() + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(tournament, nil).Once() + + err := f.tick(100) + if matches { + require.NoError(t, err) + } + tournament.AssertNotCalled(t, "JoinTournament", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.assertNoPermanentStatusWrite(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestPRTReaderUsesOnlyConfiguredHead(t *testing.T) { + f := newPRTBlockPolicyFixture(t, false) + f.expectHead(100) + snapshot := resultTestSnapshot(f.epoch, true) + snapshot.sealed.StagingBlockNumber = 99 + f.expectSnapshot(100, snapshot) + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, f.epoch.Index, uint64(99)).Return(nil).Once() + + require.NoError(t, f.tick(100)) + require.Equal(t, model.EpochStatus_ClaimStaged, f.epoch.Status) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) +} + +type prtBlockPolicyFixture struct { + s *Service + app *model.Application + epoch *model.Epoch + repo *prtRepositoryMock + client *ethClientMock + consensus *daveConsensusAdapterMock + factory *adapterFactoryMock +} + +func newPRTBlockPolicyFixture(t *testing.T, submissionEnabled bool) *prtBlockPolicyFixture { + t.Helper() + f := &prtBlockPolicyFixture{ + app: prtRevertTestApp(), + epoch: resultTestEpoch(model.EpochStatus_ClaimComputed), + client: ðClientMock{}, + consensus: &daveConsensusAdapterMock{}, + factory: &adapterFactoryMock{}, + } + applyPRTStateProof(f.epoch, repotest.KeccakStateProof(common.HexToHash("0x500"))) + f.epoch.CommitmentProof = []common.Hash{common.HexToHash("0x700")} + f.s, f.repo = newPRTServiceMock() + f.s.adapterFactory = f.factory + f.s.submissionTimeout = time.Second + f.s.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: common.HexToAddress("0x600")}) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = submissionEnabled + f.s.client = f.client + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index).Return(f.epoch, nil) + f.factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Return(f.consensus, nil) + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.client.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.factory.AssertExpectations(t) + }) + return f +} + +func (f *prtBlockPolicyFixture) expectHead(block uint64) { + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: new(big.Int).SetUint64(block)}, nil).Once() +} + +func (f *prtBlockPolicyFixture) expectSnapshot(block uint64, snapshot daveConsensusSnapshot) { + opts := mock.MatchedBy(resultCallOptsAtBlock(block)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() +} + +func (f *prtBlockPolicyFixture) expectTick(trustedBlock, latestBlock uint64, trusted, latest daveConsensusSnapshot) { + f.expectHead(trustedBlock) + f.client.On("BlockNumber", mock.Anything).Return(latestBlock, nil).Once() + f.expectSnapshot(trustedBlock, trusted) + if latestBlock != trustedBlock { + f.expectSnapshot(latestBlock, latest) + } +} + +func (f *prtBlockPolicyFixture) tick(trustedBlock uint64) error { + // The existing observer has already scanned this trusted head. Keep this + // fixture focused on the block policy for result and join actions. + f.app.LastTournamentCheckBlock = trustedBlock + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), nil).Once() + return runPRTApplicationTick(f.s, f.app) +} + +func runPRTApplicationTick(s *Service, app *model.Application) error { + block, err := s.getDefaultBlockNumber(context.Background()) + if err != nil { + return err + } + return s.validateApplication(context.Background(), app, block) +} + +func (f *prtBlockPolicyFixture) assertNoStageWrite(t *testing.T) { + t.Helper() + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func (f *prtBlockPolicyFixture) assertNoPermanentStatusWrite(t *testing.T) { + t.Helper() + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) +} diff --git a/internal/prt/bond.go b/internal/prt/bond.go new file mode 100644 index 000000000..efd997f86 --- /dev/null +++ b/internal/prt/bond.go @@ -0,0 +1,431 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +type rootBondRecovery struct { + EpochIndex uint64 + Tournament common.Address + TxHash *common.Hash + FirstMissingBlock *uint64 +} + +// discoverForeclosedRootBond finds the current root even when foreclosure +// prevented an accept attempt. Only published, signer-owned recoverable bonds +// enter the queue. Running roots remain eligible for discovery on a later tick. +func (s *Service) discoverForeclosedRootBond( + ctx context.Context, app *model.Application, epochs []*model.Epoch, consensus DaveConsensusAdapter, observedBlock uint64, +) error { + if !s.submissionEnabled || consensus == nil || !app.ForeclosureScanCaughtUp() || + observedBlock < app.ForecloseBlock || observedBlock < app.LastTournamentCheckBlock { + return nil + } + sealed, err := consensus.GetCurrentSealedEpoch(pinnedCallOpts(ctx, observedBlock)) + if err != nil { + return fmt.Errorf("reading foreclosed current root for bond recovery: %w", err) + } + if s.discoveredForeclosedRootBonds[app.ID] == sealed.Tournament { + return nil + } + for _, epoch := range epochs { + if epoch.Index != sealed.EpochNumber || epoch.TournamentAddress == nil || + *epoch.TournamentAddress != sealed.Tournament || epoch.Commitment == nil { + continue + } + tournament, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), sealed.Tournament.Hex()) + if err != nil { + return fmt.Errorf("loading foreclosed current root for bond recovery: %w", err) + } + if tournament == nil || tournament.Snapshot.AsOfBlock > app.LastTournamentCheckBlock || + tournament.Snapshot.AsOfBlock > observedBlock { + return nil + } + recovery := tournament.Snapshot.BondRecovery + if recovery.Disposition != model.BondDispositionRecoverable || recovery.Claimer == nil || + *recovery.Claimer != s.txOptsFactory.From() { + return nil + } + s.queueRootBondRecovery(app.ID, epoch.Index, sealed.Tournament) + // Keep this marker after retirement: a successful transaction can have + // a failed payment push, which must not restart an automatic retry loop. + // Restart clears both the marker and the existing in-memory queue. + s.markForeclosedRootBondDiscovered(app.ID, sealed.Tournament) + return nil + } + return nil +} + +func (s *Service) markForeclosedRootBondDiscovered(appID int64, tournament common.Address) { + if s.discoveredForeclosedRootBonds == nil { + s.discoveredForeclosedRootBonds = make(map[int64]common.Address) + } + s.discoveredForeclosedRootBonds[appID] = tournament +} + +func (s *Service) queueRootBondRecovery(appID int64, epochIndex uint64, tournament common.Address) { + for _, candidate := range s.rootBondRecoveries[appID] { + if candidate.EpochIndex == epochIndex && candidate.Tournament == tournament { + return + } + } + s.rootBondRecoveries[appID] = append(s.rootBondRecoveries[appID], &rootBondRecovery{ + EpochIndex: epochIndex, + Tournament: tournament, + }) + s.Logger.Info("Queued root tournament bond recovery candidate; restart clears this in-memory candidate", + "application_id", appID, + "epoch_index", epochIndex, + "tournament", tournament) +} + +func (s *Service) rootBondRecoveryInFlight(appID int64) (*rootBondRecovery, error) { + var found *rootBondRecovery + for _, candidate := range s.rootBondRecoveries[appID] { + if candidate.TxHash == nil { + continue + } + if found != nil { + return nil, fmt.Errorf("application %d has multiple root bond recovery transactions in flight", appID) + } + found = candidate + } + return found, nil +} + +func (s *Service) hasNonRecoveryMutationInFlight(appID int64) bool { + _, pending := s.pendingTransactions[appID] + return pending +} + +func (s *Service) retireRootBondRecovery(appID int64, target *rootBondRecovery) { + candidates := s.rootBondRecoveries[appID] + for i, candidate := range candidates { + if candidate != target { + continue + } + candidates = append(candidates[:i], candidates[i+1:]...) + if len(candidates) == 0 { + delete(s.rootBondRecoveries, appID) + } else { + s.rootBondRecoveries[appID] = candidates + } + return + } +} + +// recoverRootBonds reconciles queued root tournament bonds from oldest to +// newest. It can submit at most one recovery transaction in one call. +func (s *Service) recoverRootBonds(ctx context.Context, app *model.Application, mostRecentBlock uint64) error { + if len(s.rootBondRecoveries[app.ID]) == 0 { + return nil + } + if s.hasNonRecoveryMutationInFlight(app.ID) { + return nil + } + + inFlight, err := s.rootBondRecoveryInFlight(app.ID) + if err != nil { + return err + } + if inFlight != nil { + return s.reconcileRootBondRecoveryTransaction(ctx, app, inFlight, mostRecentBlock) + } + + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + return fmt.Errorf("binding DaveConsensus for root bond recovery: %w", err) + } + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + sealed, err := consensus.GetCurrentSealedEpoch(callOpts) + if err != nil { + return fmt.Errorf("reading current epoch for root bond recovery: %w", err) + } + + for len(s.rootBondRecoveries[app.ID]) > 0 { + candidate := s.rootBondRecoveries[app.ID][0] + // Live apps recover only after acceptance advances the sealed epoch. + // Foreclosure prevents acceptance, but the current root can still + // finish and release its bond. Its disposition and claimer decide below. + if candidate.EpochIndex > sealed.EpochNumber || + (candidate.EpochIndex == sealed.EpochNumber && !app.IsForeclosed()) { + return nil + } + mutated, err := s.recoverRootBond(ctx, app, candidate, callOpts) + if err != nil || mutated { + return err + } + if len(s.rootBondRecoveries[app.ID]) > 0 && s.rootBondRecoveries[app.ID][0] == candidate { + return nil + } + } + return nil +} + +func (s *Service) recoverRootBond( + ctx context.Context, + app *model.Application, + candidate *rootBondRecovery, + callOpts *bind.CallOpts, +) (bool, error) { + tournament, err := s.adapterFactory.CreateTournamentAdapter(candidate.Tournament) + if err != nil { + return false, fmt.Errorf("binding root tournament %s for bond recovery: %w", candidate.Tournament, err) + } + recovery, err := tournament.BondRecovery(callOpts) + if err != nil { + return false, fmt.Errorf("reading root bond recovery for tournament %s: %w", candidate.Tournament, err) + } + + switch recovery.Disposition { + case model.BondDispositionTournamentRunning: + s.Logger.Warn("Root tournament bond is still running", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + return false, nil + case model.BondDispositionNoWinner: + s.Logger.Error("Root tournament bond has no winner and cannot be recovered", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + s.retireRootBondRecovery(app.ID, candidate) + return false, nil + case model.BondDispositionRecovered: + s.Logger.Info("Root tournament bond is recovered", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + s.retireRootBondRecovery(app.ID, candidate) + return false, nil + case model.BondDispositionRecoverable: + if recovery.Claimer != s.txOptsFactory.From() { + s.Logger.Info("Root tournament bond recovery candidate belongs to another claimer; retiring candidate", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "claimer", recovery.Claimer, + "payment", recovery.Payment) + s.retireRootBondRecovery(app.ID, candidate) + return false, nil + } + if err := s.broadcastRootBondRecovery(ctx, app, candidate, tournament, recovery); err != nil { + return false, err + } + return true, nil + default: + return false, fmt.Errorf("root tournament %s returned unknown bond disposition %s", + candidate.Tournament, recovery.Disposition) + } +} + +func (s *Service) broadcastRootBondRecovery( + ctx context.Context, + app *model.Application, + candidate *rootBondRecovery, + tournament TournamentAdapter, + recovery BondRecovery, +) error { + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) + defer cancel() + txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) + if err != nil { + return fmt.Errorf("creating transaction options to recover epoch %d root bond: %w", candidate.EpochIndex, err) + } + tx, err := tournament.TryRecoveringBond(txOpts) + if err != nil { + return s.handleRootBondRecoveryRevert(app, candidate, err) + } + if tx == nil { + return errors.New("root bond recovery returned a nil transaction") + } + txHash := tx.Hash() + candidate.TxHash = &txHash + candidate.FirstMissingBlock = nil + s.Logger.Info("Sent root tournament bond recovery transaction", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "claimer", recovery.Claimer, + "payment", recovery.Payment, + "tx", txHash) + return nil +} + +func (s *Service) handleRootBondRecoveryRevert( + app *model.Application, + candidate *rootBondRecovery, + err error, +) error { + switch { + case isTournamentError(err, "TournamentNotFinished"): + s.Logger.Warn("Root bond recovery observed a running tournament; waiting for a fresh view", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + return nil + case isTournamentError(err, "NoWinner"): + s.Logger.Warn("Root bond recovery observed no winner; waiting for a fresh view", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + return nil + case ethutil.IsNonceTooLowError(err): + s.Logger.Info("Root bond recovery nonce is too low; waiting for chain reconciliation", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament) + return nil + default: + return err + } +} + +func (s *Service) reconcileRootBondRecoveryTransaction( + ctx context.Context, + app *model.Application, + candidate *rootBondRecovery, + mostRecentBlock uint64, +) error { + txHash := *candidate.TxHash + _, pending, err := s.client.TransactionByHash(ctx, txHash) + missing := errors.Is(err, ethereum.NotFound) + if err != nil && !missing { + return fmt.Errorf("checking root bond recovery transaction %s: %w", txHash, err) + } + if !missing { + candidate.FirstMissingBlock = nil + } + if !missing && pending { + return nil + } + receipt, err := s.client.TransactionReceipt(ctx, txHash) + if missing && errors.Is(err, ethereum.NotFound) { + if candidate.FirstMissingBlock == nil || mostRecentBlock < *candidate.FirstMissingBlock { + candidate.FirstMissingBlock = new(mostRecentBlock) + return nil + } + if mostRecentBlock-*candidate.FirstMissingBlock >= maxMissingTournamentTransactionBlocks { + s.Logger.Warn("Root bond recovery transaction remains missing; waiting for fresh bond state before retry", + "application", app.Name, "epoch_index", candidate.EpochIndex, "tournament", candidate.Tournament, + "tx", txHash, "missing_since_block", *candidate.FirstMissingBlock, "latest_block", mostRecentBlock) + candidate.TxHash, candidate.FirstMissingBlock = nil, nil + } + return nil + } + if err != nil { + return fmt.Errorf("fetching root bond recovery receipt %s: %w", txHash, err) + } + if receipt == nil { + return fmt.Errorf("root bond recovery transaction %s has an invalid receipt block", txHash) + } + if _, err := checkedUint64(receipt.BlockNumber, "root bond recovery transaction receipt block"); err != nil { + return err + } + if receipt.TxHash != txHash { + return fmt.Errorf("root bond recovery receipt hash %s differs from transaction %s", receipt.TxHash, txHash) + } + if receipt.Status != types.ReceiptStatusFailed && receipt.Status != types.ReceiptStatusSuccessful { + return fmt.Errorf("root bond recovery transaction %s has invalid receipt status %d", txHash, receipt.Status) + } + candidate.FirstMissingBlock = nil + callBlock := new(big.Int).SetUint64(mostRecentBlock) + if receipt.BlockNumber.Cmp(callBlock) > 0 { + callBlock.Set(receipt.BlockNumber) + } + + tournament, err := s.adapterFactory.CreateTournamentAdapter(candidate.Tournament) + if err != nil { + return fmt.Errorf("binding root tournament %s after recovery transaction: %w", candidate.Tournament, err) + } + recovery, err := tournament.BondRecovery(&bind.CallOpts{Context: ctx, BlockNumber: callBlock}) + if err != nil { + if errors.Is(err, errInvalidBondRecovery) { + candidate.TxHash = nil + } + return fmt.Errorf("reading root bond state after transaction %s: %w", txHash, err) + } + + switch recovery.Disposition { + case model.BondDispositionRecovered: + s.Logger.Info("Root tournament bond recovery is confirmed", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "status", receipt.Status) + s.retireRootBondRecovery(app.ID, candidate) + return nil + case model.BondDispositionRecoverable: + if recovery.Claimer != s.txOptsFactory.From() { + s.Logger.Warn("Root tournament bond now belongs to another claimer; retiring recovery", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "status", receipt.Status, + "claimer", recovery.Claimer, + "payment", recovery.Payment) + s.retireRootBondRecovery(app.ID, candidate) + return nil + } + if receipt.Status == types.ReceiptStatusFailed { + candidate.TxHash = nil + s.Logger.Warn("Root tournament bond recovery transaction reverted; waiting for a fresh retry", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "claimer", recovery.Claimer, + "payment", recovery.Payment) + return nil + } + s.Logger.Error("Root tournament bond payment failed; suppressing automatic retry", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "status", receipt.Status, + "claimer", recovery.Claimer, + "payment", recovery.Payment, + "outcome", "failed_push") + if app.IsForeclosed() { + // A candidate queued before foreclosure can finish before the + // observation view catches up. Later discovery must not retry it. + s.markForeclosedRootBondDiscovered(app.ID, candidate.Tournament) + } + s.retireRootBondRecovery(app.ID, candidate) + return nil + case model.BondDispositionNoWinner: + s.Logger.Error("Root tournament bond has no winner after recovery transaction", + "application", app.Name, + "epoch_index", candidate.EpochIndex, + "tournament", candidate.Tournament, + "tx", txHash, + "status", receipt.Status) + s.retireRootBondRecovery(app.ID, candidate) + return nil + case model.BondDispositionTournamentRunning: + candidate.TxHash = nil + return fmt.Errorf("root tournament %s reports a running bond after mined recovery transaction %s", + candidate.Tournament, txHash) + default: + candidate.TxHash = nil + return fmt.Errorf("root tournament %s returned unknown bond disposition %s after transaction %s", + candidate.Tournament, recovery.Disposition, txHash) + } +} diff --git a/internal/prt/bond_discovery_test.go b/internal/prt/bond_discovery_test.go new file mode 100644 index 000000000..866beabc8 --- /dev/null +++ b/internal/prt/bond_discovery_test.go @@ -0,0 +1,256 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +type foreclosedBondFixture struct { + s *Service + repo *prtRepositoryMock + client *ethClientMock + consensus *daveConsensusAdapterMock + tournament *tournamentAdapterMock + app *model.Application + epoch *model.Epoch + projection *model.Tournament + sealed CurrentSealedEpoch + owned common.Address +} + +func newForeclosedBondFixture(t *testing.T, disposition model.BondDisposition) *foreclosedBondFixture { + t.Helper() + factory := &adapterFactoryMock{} + f := &foreclosedBondFixture{ + app: prtForeclosedApp(1, 100), epoch: resultTestEpoch(model.EpochStatus_ClaimStaged), + client: ðClientMock{}, consensus: &daveConsensusAdapterMock{}, tournament: &tournamentAdapterMock{}, + owned: common.HexToAddress("0x600"), + } + f.app.LastTournamentCheckBlock = 120 + f.s = newRootBondTestService(f.owned, factory) + f.s.client, f.s.submissionEnabled = f.client, true + f.repo = f.s.repository.(*prtRepositoryMock) + f.sealed = CurrentSealedEpoch{EpochNumber: f.epoch.Index, Tournament: *f.epoch.TournamentAddress} + f.projection = &model.Tournament{ + ApplicationID: f.app.ID, EpochIndex: f.epoch.Index, Address: *f.epoch.TournamentAddress, + Snapshot: model.TournamentSnapshot{ + AsOfBlock: 120, FinishedAtBlock: 110, WinnerCommitment: f.epoch.Commitment, + BondRecovery: model.TournamentBondRecovery{Disposition: disposition, Claimer: &f.owned}, + }, + } + factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Return(f.consensus, nil) + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.client.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.tournament.AssertExpectations(t) + factory.AssertExpectations(t) + }) + return f +} + +func (f *foreclosedBondFixture) expectObservedTick(unreconciled bool, loadProjection bool) { + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(120))).Return(f.sealed, nil).Once() + if loadProjection { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.TournamentAddress.Hex()). + Return(f.projection, nil).Once() + } + f.repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock).Return(false, nil).Once() + f.repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock). + Return(unreconciled, nil).Once() + if unreconciled { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.TournamentAddress.Hex()). + Return(f.projection, nil).Once() + f.repo.On("ListEpochs", mock.Anything, f.app.Name, + repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + f.repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, f.app.ID, f.epoch.Index). + Run(func(mock.Arguments) { f.epoch.Status = model.EpochStatus_ClaimForeclosed }).Return(nil).Once() + } +} + +func (f *foreclosedBondFixture) expectRecoveryBroadcast(tx *types.Transaction) { + f.client.On("BlockNumber", mock.Anything).Return(uint64(125), nil).Once() + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(125))).Return(f.sealed, nil).Once() + f.s.adapterFactory.(*adapterFactoryMock).On("CreateTournamentAdapter", f.projection.Address).Return(f.tournament, nil).Once() + f.tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(125))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, f.owned, 1), nil).Once() + f.tournament.On("TryRecoveringBond", mock.Anything).Return(tx, nil).Once() +} + +func (f *foreclosedBondFixture) tick(t *testing.T) { + t.Helper() + require.NoError(t, f.s.handleForeclosedApp(t.Context(), f.app, func() (uint64, error) { return 120, nil })) + // Foreclosure permits bond recovery, never another join/stage/accept. + require.Empty(t, f.s.pendingTransactions) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) +} + +func TestForeclosedRootBondDiscoveryWithoutAcceptAttempt(t *testing.T) { + for _, status := range []model.EpochStatus{model.EpochStatus_ClaimStaged, model.EpochStatus_ClaimForeclosed} { + t.Run(status.String(), func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = status + f.expectObservedTick(status == model.EpochStatus_ClaimStaged, true) + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.expectRecoveryBroadcast(tx) + require.Empty(t, f.s.rootBondRecoveries) + f.tick(t) + require.Len(t, f.s.rootBondRecoveries[f.app.ID], 1) + require.Equal(t, tx.Hash(), *f.s.rootBondRecoveries[f.app.ID][0].TxHash) + require.Equal(t, model.EpochStatus_ClaimForeclosed, f.epoch.Status) + }) + } +} + +func TestForeclosedRunningRootBecomesRecoverableAfterClaimDrain(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionTournamentRunning) + f.projection.Snapshot.FinishedAtBlock = 0 + f.expectObservedTick(true, true) + f.tick(t) + require.Empty(t, f.s.rootBondRecoveries) + require.Empty(t, f.s.discoveredForeclosedRootBonds) + require.Equal(t, model.EpochStatus_ClaimForeclosed, f.epoch.Status) + + f.projection.Snapshot.FinishedAtBlock = 115 + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionRecoverable + f.expectObservedTick(false, true) + f.expectRecoveryBroadcast(types.NewTx(&types.LegacyTx{Nonce: 1})) + f.tick(t) + require.Len(t, f.s.rootBondRecoveries[f.app.ID], 1) +} + +func TestForeclosedRootDiscoveryDoesNotQueueIneligibleBonds(t *testing.T) { + for _, test := range []struct { + name string + update func(*foreclosedBondFixture) + }{ + {name: "foreign claimer", update: func(f *foreclosedBondFixture) { + f.projection.Snapshot.BondRecovery.Claimer = new(common.HexToAddress("0x700")) + }}, + {name: "recovered", update: func(f *foreclosedBondFixture) { + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionRecovered + }}, + {name: "no winner", update: func(f *foreclosedBondFixture) { + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionNoWinner + }}, + {name: "projection beyond published cursor", update: func(f *foreclosedBondFixture) { + f.projection.Snapshot.AsOfBlock = 121 + }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = model.EpochStatus_ClaimForeclosed + test.update(f) + for range 2 { + f.expectObservedTick(false, true) + f.tick(t) + } + require.Empty(t, f.s.rootBondRecoveries) + require.Empty(t, f.s.discoveredForeclosedRootBonds) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + }) + } +} + +func TestForeclosedRootDiscoveryDoesNotRetryFailedPaymentPush(t *testing.T) { + for _, queuedBeforeForeclosure := range []bool{false, true} { + name := "discovered after foreclosure" + if queuedBeforeForeclosure { + name = "queued before foreclosure with observation lag" + } + t.Run(name, func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = model.EpochStatus_ClaimForeclosed + if queuedBeforeForeclosure { + f.s.queueRootBondRecovery(f.app.ID, f.epoch.Index, f.projection.Address) + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionTournamentRunning + } + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.expectObservedTick(false, true) + f.expectRecoveryBroadcast(tx) + f.tick(t) + + f.expectObservedTick(false, queuedBeforeForeclosure) + f.client.On("BlockNumber", mock.Anything).Return(uint64(125), nil).Once() + f.client.On("TransactionByHash", mock.Anything, tx.Hash()).Return(tx, false, nil).Once() + f.client.On("TransactionReceipt", mock.Anything, tx.Hash()).Return(&types.Receipt{ + TxHash: tx.Hash(), BlockNumber: big.NewInt(125), Status: types.ReceiptStatusSuccessful, + }, nil).Once() + f.s.adapterFactory.(*adapterFactoryMock).On("CreateTournamentAdapter", f.projection.Address).Return(f.tournament, nil).Once() + f.tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(125))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, f.owned, 1), nil).Once() + f.tick(t) + require.Empty(t, f.s.rootBondRecoveries) + + f.projection.Snapshot.BondRecovery.Disposition = model.BondDispositionRecoverable + f.expectObservedTick(false, false) + f.tick(t) + f.tournament.AssertNumberOfCalls(t, "TryRecoveringBond", 1) + require.Empty(t, f.s.rootBondRecoveries) + }) + } +} + +func TestForeclosedRootDiscoveryRequiresCurrentRootIdentity(t *testing.T) { + for _, mismatch := range []string{"epoch", "tournament"} { + t.Run(mismatch, func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = model.EpochStatus_ClaimForeclosed + if mismatch == "epoch" { + f.sealed.EpochNumber++ + } else { + f.sealed.Tournament = common.HexToAddress("0x900") + } + f.expectObservedTick(false, false) + f.tick(t) + require.Empty(t, f.s.rootBondRecoveries) + require.Empty(t, f.s.discoveredForeclosedRootBonds) + }) + } +} + +func TestForeclosedRootDiscoveryRetriesReadErrors(t *testing.T) { + for _, failure := range []string{"current epoch", "published root"} { + t.Run(failure, func(t *testing.T) { + f := newForeclosedBondFixture(t, model.BondDispositionRecoverable) + f.epoch.Status = model.EpochStatus_ClaimForeclosed + readErr := errors.New("discovery read failed") + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + var chainErr error + if failure == "current epoch" { + chainErr = readErr + } else { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), f.projection.Address.Hex()). + Return((*model.Tournament)(nil), readErr).Once() + } + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(120))).Return(f.sealed, chainErr).Once() + f.repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock).Return(false, nil).Once() + f.repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock).Return(false, nil).Once() + require.ErrorIs(t, f.s.handleForeclosedApp(t.Context(), f.app, func() (uint64, error) { return 120, nil }), readErr) + require.Empty(t, f.s.discoveredForeclosedRootBonds, "a read failure must not consume discovery") + require.Empty(t, f.s.rootBondRecoveries) + + f.expectObservedTick(false, true) + f.expectRecoveryBroadcast(types.NewTx(&types.LegacyTx{Nonce: 1})) + f.tick(t) + require.Len(t, f.s.rootBondRecoveries[f.app.ID], 1) + }) + } +} diff --git a/internal/prt/bond_foreclosure_test.go b/internal/prt/bond_foreclosure_test.go new file mode 100644 index 000000000..07e31c88b --- /dev/null +++ b/internal/prt/bond_foreclosure_test.go @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRootBondRecoveryForeclosureAdmission(t *testing.T) { + owned := common.HexToAddress("0x600") + tests := []struct { + name string + foreclosed bool + candidateEpoch uint64 + recovery BondRecovery + wantRead bool + wantTx bool + }{ + { + name: "foreclosed current epoch recovers owned bond", foreclosed: true, candidateEpoch: 3, + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), wantRead: true, wantTx: true, + }, + { + name: "foreclosed future epoch waits", foreclosed: true, candidateEpoch: 4, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + if test.foreclosed { + app.ForecloseBlock = 10 + } + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + tournamentAddress := common.HexToAddress("0x300") + service.queueRootBondRecovery(app.ID, test.candidateEpoch, tournamentAddress) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(20))). + Return(CurrentSealedEpoch{EpochNumber: 3, Tournament: tournamentAddress}, nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + tournament := &tournamentAdapterMock{} + if test.wantRead { + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(20))).Return(test.recovery, nil).Once() + } + if test.wantTx { + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + } + + require.NoError(t, service.recoverRootBonds(t.Context(), app, 20)) + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Equal(t, test.wantTx, service.rootBondRecoveries[app.ID][0].TxHash != nil) + require.Empty(t, service.pendingTransactions) + service.repository.(*prtRepositoryMock).AssertExpectations(t) + factory.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestRootBondRecoveryWithoutCandidatesDoesNotReadChain(t *testing.T) { + app := prtForeclosedApp(1, 10) + factory := &adapterFactoryMock{} + service := newRootBondTestService(common.HexToAddress("0x600"), factory) + + require.NoError(t, service.recoverRootBonds(t.Context(), app, 20)) + require.Empty(t, service.rootBondRecoveries) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + factory.AssertNotCalled(t, "CreateTournamentAdapter", mock.Anything) +} diff --git a/internal/prt/bond_missing_test.go b/internal/prt/bond_missing_test.go new file mode 100644 index 000000000..bd456fa58 --- /dev/null +++ b/internal/prt/bond_missing_test.go @@ -0,0 +1,161 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" +) + +func TestRootBondRecoveryMissingHashWaitsForFreshState(t *testing.T) { + for _, recoveredElsewhere := range []bool{false, true} { + t.Run(map[bool]string{false: "retry owned bond", true: "another caller recovered"}[recoveredElsewhere], func(t *testing.T) { + app := prtRevertTestApp() + owned := common.HexToAddress("0x600") + factory := &adapterFactoryMock{} + s := newRootBondTestService(owned, factory) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: common.HexToAddress("0x300"), TxHash: &txHash} + s.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + s.client = client + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), false, ethereum.NotFound).Times(5) + client.On("TransactionReceipt", mock.Anything, txHash). + Return((*types.Receipt)(nil), ethereum.NotFound).Times(5) + for _, head := range []uint64{100, 100, 99, 162} { + require.NoError(t, s.recoverRootBonds(t.Context(), app, head)) + require.Equal(t, &txHash, candidate.TxHash) + } + require.Equal(t, uint64(99), *candidate.FirstMissingBlock) + require.NoError(t, s.recoverRootBonds(t.Context(), app, 163)) + require.Nil(t, candidate.TxHash) + require.Nil(t, candidate.FirstMissingBlock) + require.Len(t, s.rootBondRecoveries[app.ID], 1) + require.Empty(t, factory.Calls, "expiry must end the action cycle") + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, s.repository.(*prtRepositoryMock).Calls) + + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(164))). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + tournament := &tournamentAdapterMock{} + factory.On("CreateTournamentAdapter", candidate.Tournament).Return(tournament, nil).Once() + recovery := canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1) + if recoveredElsewhere { + recovery = canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0) + } else { + tournament.On("TryRecoveringBond", mock.Anything).Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + } + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(164))).Return(recovery, nil).Once() + require.NoError(t, s.recoverRootBonds(t.Context(), app, 164)) + if recoveredElsewhere { + require.Empty(t, s.rootBondRecoveries[app.ID]) + } else { + require.NotNil(t, candidate.TxHash) + } + client.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestRootBondRecoveryMissingHashRetainsUncertainTransaction(t *testing.T) { + rpcErr := errors.New("provider unavailable") + for _, test := range []struct { + name string + lookupErr error + pending bool + receiptErr error + receipt *types.Receipt + wantErr error + resetTimer bool + }{ + {name: "pending resets timer", pending: true, resetTimer: true}, + {name: "transaction visible without receipt", receiptErr: ethereum.NotFound, wantErr: ethereum.NotFound, resetTimer: true}, + {name: "lookup failure", lookupErr: rpcErr, wantErr: rpcErr}, + {name: "lookup cancellation", lookupErr: context.Canceled, wantErr: context.Canceled}, + {name: "receipt failure", lookupErr: ethereum.NotFound, receiptErr: rpcErr, wantErr: rpcErr}, + {name: "receipt cancellation", lookupErr: ethereum.NotFound, receiptErr: context.Canceled, wantErr: context.Canceled}, + {name: "nil receipt", lookupErr: ethereum.NotFound}, + {name: "wrong receipt hash", lookupErr: ethereum.NotFound, + receipt: &types.Receipt{TxHash: common.HexToHash("0xbad"), BlockNumber: big.NewInt(21)}}, + {name: "invalid receipt block", lookupErr: ethereum.NotFound, + receipt: &types.Receipt{TxHash: common.HexToHash("0x400"), BlockNumber: big.NewInt(-1)}}, + {name: "overflow receipt block", lookupErr: ethereum.NotFound, + receipt: &types.Receipt{TxHash: common.HexToHash("0x400"), BlockNumber: new(big.Int).Lsh(big.NewInt(1), 64)}}, + {name: "invalid receipt status", lookupErr: ethereum.NotFound, + receipt: &types.Receipt{TxHash: common.HexToHash("0x400"), BlockNumber: big.NewInt(21), Status: 2}}, + } { + t.Run(test.name, func(t *testing.T) { + s := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + app := prtRevertTestApp() + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, TxHash: &txHash, FirstMissingBlock: new(uint64(1))} + s.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + s.client = client + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), test.pending, test.lookupErr).Once() + if !test.pending && (test.lookupErr == nil || errors.Is(test.lookupErr, ethereum.NotFound)) { + client.On("TransactionReceipt", mock.Anything, txHash).Return(test.receipt, test.receiptErr).Once() + } + err := s.recoverRootBonds(t.Context(), app, 100) + switch { + case test.pending: + require.NoError(t, err) + case test.wantErr != nil: + require.ErrorIs(t, err, test.wantErr) + default: + require.Error(t, err) + } + require.Equal(t, &txHash, candidate.TxHash) + require.Equal(t, test.resetTimer, candidate.FirstMissingBlock == nil) + require.Len(t, s.rootBondRecoveries[app.ID], 1) + require.Empty(t, s.repository.(*prtRepositoryMock).Calls) + client.AssertExpectations(t) + }) + } +} + +func TestRootBondRecoveryReceiptOnlyResetsMissingTimer(t *testing.T) { + app := prtRevertTestApp() + factory := &adapterFactoryMock{} + s := newRootBondTestService(common.HexToAddress("0x600"), factory) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{ + EpochIndex: 3, Tournament: common.HexToAddress("0x300"), TxHash: &txHash, FirstMissingBlock: new(uint64(1)), + } + s.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + s.client = client + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), false, ethereum.NotFound).Once() + client.On("TransactionReceipt", mock.Anything, txHash).Return(&types.Receipt{ + TxHash: txHash, BlockNumber: big.NewInt(101), Status: types.ReceiptStatusSuccessful, + }, nil).Once() + tournament := &tournamentAdapterMock{} + factory.On("CreateTournamentAdapter", candidate.Tournament).Return(tournament, nil).Once() + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(101))). + Return(canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), nil).Once() + require.NoError(t, s.recoverRootBonds(t.Context(), app, 100)) + require.Nil(t, candidate.FirstMissingBlock) + require.Empty(t, s.rootBondRecoveries[app.ID]) + client.AssertExpectations(t) + factory.AssertExpectations(t) + tournament.AssertExpectations(t) +} diff --git a/internal/prt/bond_test.go b/internal/prt/bond_test.go new file mode 100644 index 000000000..ad2f6c414 --- /dev/null +++ b/internal/prt/bond_test.go @@ -0,0 +1,497 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +const noWinnerCase = "no winner" + +func TestBondRecoveryFromBindingMapsAndOwnsPayment(t *testing.T) { + claimer := common.HexToAddress("0x100") + payment := big.NewInt(42) + recovery, err := bondRecoveryFromBinding(uint8(2), claimer, payment) + require.NoError(t, err) + require.Equal(t, model.BondDispositionRecoverable, recovery.Disposition) + require.Equal(t, claimer, recovery.Claimer) + require.Equal(t, int64(42), recovery.Payment.Int64()) + + recovery.Payment.SetInt64(7) + require.Equal(t, int64(42), payment.Int64()) +} + +func TestBondRecoveryFromBindingValidatesCanonicalState(t *testing.T) { + claimer := common.HexToAddress("0x100") + tests := []struct { + name string + disposition uint8 + claimer common.Address + payment *big.Int + }{ + {name: "unknown disposition", disposition: 4, payment: big.NewInt(0)}, + {name: "nil payment", disposition: uint8(3)}, + {name: "negative payment", disposition: uint8(2), claimer: claimer, payment: big.NewInt(-1)}, + {name: "running claimer", disposition: uint8(0), claimer: claimer, payment: big.NewInt(0)}, + {name: "no winner payment", disposition: uint8(1), payment: big.NewInt(1)}, + {name: "recovered claimer", disposition: uint8(3), claimer: claimer, payment: big.NewInt(0)}, + {name: "recoverable zero claimer", disposition: uint8(2), payment: big.NewInt(0)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := bondRecoveryFromBinding(test.disposition, test.claimer, test.payment) + require.ErrorIs(t, err, errInvalidBondRecovery) + }) + } + + recovery, err := bondRecoveryFromBinding( + uint8(2), claimer, big.NewInt(0), + ) + require.NoError(t, err) + require.Zero(t, recovery.Payment.Sign(), "a zero recoverable payment is valid") +} + +func TestAcceptQueuesRootBondBeforeCallAndDeduplicates(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + snapshot := resultTestSnapshot(epoch, true) + acceptErr := errors.New("accept failed") + consensus := &daveConsensusAdapterMock{} + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + var output bytes.Buffer + service.Logger = slog.New(slog.NewTextHandler(&output, nil)) + consensus.On("AcceptStagedTournamentResult", mock.Anything, epoch.Index). + Run(func(mock.Arguments) { + require.Len(t, service.rootBondRecoveries[app.ID], 1) + candidate := service.rootBondRecoveries[app.ID][0] + require.Equal(t, epoch.Index, candidate.EpochIndex) + require.Equal(t, snapshot.sealed.Tournament, candidate.Tournament) + }). + Return((*types.Transaction)(nil), acceptErr).Twice() + + for range 2 { + err := service.broadcastAcceptTournamentResult(context.Background(), app, epoch, consensus, snapshot) + require.ErrorIs(t, err, acceptErr) + } + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Equal(t, 1, strings.Count(output.String(), "Queued root tournament bond recovery candidate")) + require.Contains(t, output.String(), "restart clears") + require.Contains(t, output.String(), "application_id=7") + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), snapshot.sealed.Tournament.Hex()) + require.NotContains(t, output.String(), "payment=") + require.NotContains(t, output.String(), "claimer=") + consensus.AssertExpectations(t) +} + +func TestRootBondRecoveryWaitsForNextEpoch(t *testing.T) { + app := prtRevertTestApp() + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + service.queueRootBondRecovery(app.ID, 3, common.HexToAddress("0x300")) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(20))). + Return(CurrentSealedEpoch{EpochNumber: 3}, nil).Once() + service.adapterFactory.(*adapterFactoryMock). + On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + + require.NoError(t, service.recoverRootBonds(context.Background(), app, 20)) + require.Len(t, service.rootBondRecoveries[app.ID], 1) + service.adapterFactory.(*adapterFactoryMock).AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestRootBondRecoveryHandlesEveryDispositionAndOwnership(t *testing.T) { + owned := common.HexToAddress("0x600") + external := common.HexToAddress("0x700") + tests := []struct { + name string + recovery BondRecovery + wantRetired bool + wantTx bool + }{ + {name: "running", recovery: canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0)}, + {name: noWinnerCase, recovery: canonicalBondRecovery(model.BondDispositionNoWinner, common.Address{}, 0), wantRetired: true}, + {name: "recovered by other actor", + recovery: canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), wantRetired: true}, + {name: "external claimer", recovery: canonicalBondRecovery(model.BondDispositionRecoverable, external, 8), wantRetired: true}, + {name: "owned zero payment", recovery: canonicalBondRecovery(model.BondDispositionRecoverable, owned, 0), wantTx: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + tournamentAddress := common.HexToAddress("0x300") + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(20))).Return(test.recovery, nil).Once() + if test.wantTx { + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + } + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + require.NoError(t, service.recoverRootBonds(context.Background(), app, 20)) + if test.wantRetired { + require.Empty(t, service.rootBondRecoveries[app.ID]) + // A resolved candidate must not trigger another state read or send. + require.NoError(t, service.recoverRootBonds(context.Background(), app, 21)) + } else { + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Equal(t, test.wantTx, service.rootBondRecoveries[app.ID][0].TxHash != nil) + } + factory.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestPendingRootBondRecoveryBlocksOtherMutations(t *testing.T) { + app := prtRevertTestApp() + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: common.HexToAddress("0x300")} + txHash := common.HexToHash("0x400") + candidate.TxHash = &txHash + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return(types.NewTx(&types.LegacyTx{}), true, nil).Once() + service.client = client + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.NoError(t, service.recoverRootBonds(context.Background(), app, 20)) + require.Equal(t, &txHash, candidate.TxHash) + client.AssertExpectations(t) +} + +func TestOtherPRTMutationBlocksRootBondRecovery(t *testing.T) { + app := prtRevertTestApp() + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + factory := &adapterFactoryMock{} + service := newRootBondTestService(common.HexToAddress("0x600"), factory) + service.queueRootBondRecovery(app.ID, 3, common.HexToAddress("0x300")) + txHash := common.HexToHash("0x400") + service.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: action, Hash: txHash} + + require.NoError(t, service.recoverRootBonds(context.Background(), app, 20)) + require.Len(t, service.rootBondRecoveries[app.ID], 1) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + factory.AssertNotCalled(t, "CreateTournamentAdapter", mock.Anything) + }) + } +} + +func TestConcurrentRootRecoveryAndResultMutationIsRejected(t *testing.T) { + app := prtRevertTestApp() + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + recoveryTx := common.HexToHash("0x400") + stageTx := common.HexToHash("0x500") + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{{ + EpochIndex: 3, + Tournament: common.HexToAddress("0x300"), + TxHash: &recoveryTx, + }} + service.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: tournamentActionStage, Hash: stageTx} + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.Nil(t, joinEpoch) + require.ErrorContains(t, err, "bond recovery and another PRT transaction in flight") +} + +func TestRootBondRecoveryKnownRevertsWaitForFreshView(t *testing.T) { + app := prtRevertTestApp() + for _, test := range []struct { + name string + err error + }{ + {name: "tournament not finished", err: tournamentRevertError("TournamentNotFinished")}, + {name: noWinnerCase, err: tournamentRevertError("NoWinner")}, + {name: "nonce too low", err: errors.New("nonce too low")}, + } { + t.Run(test.name, func(t *testing.T) { + service := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: common.HexToAddress("0x300")} + tournament := &tournamentAdapterMock{} + tournament.On("TryRecoveringBond", mock.Anything). + Return((*types.Transaction)(nil), test.err).Once() + + err := service.broadcastRootBondRecovery( + context.Background(), app, candidate, tournament, + canonicalBondRecovery(model.BondDispositionRecoverable, common.HexToAddress("0x600"), 1), + ) + require.NoError(t, err) + require.Nil(t, candidate.TxHash) + tournament.AssertExpectations(t) + }) + } +} + +func TestMinedRootBondRecoveryUsesPostTransactionState(t *testing.T) { + owned := common.HexToAddress("0x600") + external := common.HexToAddress("0x700") + for _, test := range []struct { + name string + status uint64 + recovery BondRecovery + wantRetired bool + wantTxCleared bool + wantError string + wantFailedPush bool + }{ + {name: "recovered", status: types.ReceiptStatusSuccessful, + recovery: canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), wantRetired: true}, + {name: "successful receipt with failed push", status: types.ReceiptStatusSuccessful, + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, owned, 7), + wantRetired: true, wantFailedPush: true}, + {name: "recoverable bond now belongs to another claimer", status: types.ReceiptStatusSuccessful, + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, external, 7), wantRetired: true}, + {name: "failed receipt with recoverable state", status: types.ReceiptStatusFailed, + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, owned, 7), wantTxCleared: true}, + {name: noWinnerCase, status: types.ReceiptStatusSuccessful, + recovery: canonicalBondRecovery(model.BondDispositionNoWinner, common.Address{}, 0), wantRetired: true}, + {name: "running", status: types.ReceiptStatusFailed, + recovery: canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0), + wantTxCleared: true, wantError: "running bond"}, + } { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + var logs bytes.Buffer + service.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + tournamentAddress := common.HexToAddress("0x300") + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: tournamentAddress, TxHash: &txHash} + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, txHash).Return(&types.Receipt{ + Status: test.status, + TxHash: txHash, + BlockNumber: big.NewInt(21), + }, nil).Once() + service.client = client + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(21))).Return(test.recovery, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + err := service.recoverRootBonds(context.Background(), app, 20) + if test.wantError == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, test.wantError) + } + require.Equal(t, test.wantRetired, len(service.rootBondRecoveries[app.ID]) == 0) + if test.wantTxCleared { + require.Nil(t, candidate.TxHash) + } + if test.wantFailedPush { + require.Contains(t, logs.String(), "level=ERROR") + require.Contains(t, logs.String(), "outcome=failed_push") + } else { + require.NotContains(t, logs.String(), "outcome=failed_push") + } + client.AssertExpectations(t) + factory.AssertExpectations(t) + tournament.AssertExpectations(t) + }) + } +} + +func TestRootBondRecoveryKeepsCandidateOnErrors(t *testing.T) { + owned := common.HexToAddress("0x600") + app := prtRevertTestApp() + tournamentAddress := common.HexToAddress("0x300") + + t.Run("pre-broadcast view", func(t *testing.T) { + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything).Return(BondRecovery{}, errors.New("view failed")).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + require.ErrorContains(t, service.recoverRootBonds(context.Background(), app, 20), "view failed") + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + }) + + t.Run("nil transaction", func(t *testing.T) { + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + tournament.On("TryRecoveringBond", mock.Anything).Return((*types.Transaction)(nil), nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + require.ErrorContains(t, service.recoverRootBonds(context.Background(), app, 20), "nil transaction") + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + }) + + t.Run("transaction lookup", func(t *testing.T) { + service := newRootBondTestService(owned, &adapterFactoryMock{}) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: tournamentAddress, TxHash: &txHash} + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), false, errors.New("lookup failed")).Once() + service.client = client + + require.ErrorContains(t, service.recoverRootBonds(context.Background(), app, 20), "lookup failed") + require.Equal(t, &txHash, candidate.TxHash) + }) + + for _, test := range []struct { + name string + viewErr error + clearTx bool + }{ + {name: "post-transaction view", viewErr: errors.New("view failed")}, + {name: "invalid post-transaction view", viewErr: errInvalidBondRecovery, clearTx: true}, + } { + t.Run(test.name, func(t *testing.T) { + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: tournamentAddress, TxHash: &txHash} + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, txHash). + Return(&types.Receipt{TxHash: txHash, BlockNumber: big.NewInt(21)}, nil).Once() + service.client = client + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything).Return(BondRecovery{}, test.viewErr).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + require.ErrorIs(t, service.recoverRootBonds(context.Background(), app, 20), test.viewErr) + if test.clearTx { + require.Nil(t, candidate.TxHash) + } else { + require.Equal(t, &txHash, candidate.TxHash) + } + require.Len(t, service.rootBondRecoveries[app.ID], 1) + require.Same(t, candidate, service.rootBondRecoveries[app.ID][0]) + client.AssertExpectations(t) + tournament.AssertExpectations(t) + factory.AssertExpectations(t) + }) + } + + for _, test := range []struct { + name string + receipt *types.Receipt + want string + }{ + {name: "receipt hash", receipt: &types.Receipt{ + TxHash: common.HexToHash("0xbad"), BlockNumber: big.NewInt(21), + }, want: "differs from transaction"}, + {name: "receipt status", receipt: &types.Receipt{ + TxHash: common.HexToHash("0x400"), BlockNumber: big.NewInt(21), Status: 2, + }, want: "invalid receipt status"}, + } { + t.Run(test.name, func(t *testing.T) { + service := newRootBondTestService(owned, &adapterFactoryMock{}) + txHash := common.HexToHash("0x400") + candidate := &rootBondRecovery{EpochIndex: 3, Tournament: tournamentAddress, TxHash: &txHash} + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{candidate} + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, txHash). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, txHash).Return(test.receipt, nil).Once() + service.client = client + + require.ErrorContains(t, service.recoverRootBonds(context.Background(), app, 20), test.want) + require.Equal(t, &txHash, candidate.TxHash) + }) + } +} + +func TestRootBondRecoveryIsOldestFirstAndIsolatedPerApplication(t *testing.T) { + owned := common.HexToAddress("0x600") + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + appOne := prtRevertTestApp() + appTwo := prtRevertTestApp() + appTwo.ID = 8 + appTwo.Name = "prt-app-two" + appTwo.IConsensusAddress = common.HexToAddress("0x108") + rootOne := common.HexToAddress("0x301") + rootTwo := common.HexToAddress("0x302") + rootThree := common.HexToAddress("0x303") + service.queueRootBondRecovery(appOne.ID, 1, rootOne) + service.queueRootBondRecovery(appOne.ID, 2, rootTwo) + service.queueRootBondRecovery(appTwo.ID, 1, rootThree) + + for _, app := range []*model.Application{appOne, appTwo} { + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(CurrentSealedEpoch{EpochNumber: 3}, nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + } + for _, address := range []common.Address{rootOne, rootThree} { + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: uint64(address[19])}), nil).Once() + factory.On("CreateTournamentAdapter", address).Return(tournament, nil).Once() + } + + require.NoError(t, service.recoverRootBonds(context.Background(), appOne, 20)) + require.NotNil(t, service.rootBondRecoveries[appOne.ID][0].TxHash) + require.Nil(t, service.rootBondRecoveries[appOne.ID][1].TxHash) + require.NoError(t, service.recoverRootBonds(context.Background(), appTwo, 20)) + require.NotNil(t, service.rootBondRecoveries[appTwo.ID][0].TxHash) + factory.AssertNotCalled(t, "CreateTournamentAdapter", rootTwo) + factory.AssertExpectations(t) +} + +func newRootBondTestService(from common.Address, factory AdapterFactory) *Service { + service, _ := newPRTServiceMock() + service.adapterFactory = factory + service.submissionTimeout = time.Second + service.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: from}) + return service +} + +func canonicalBondRecovery(disposition model.BondDisposition, claimer common.Address, payment int64) BondRecovery { + return BondRecovery{Disposition: disposition, Claimer: claimer, Payment: big.NewInt(payment)} +} diff --git a/internal/prt/call_opts.go b/internal/prt/call_opts.go new file mode 100644 index 000000000..2556dca12 --- /dev/null +++ b/internal/prt/call_opts.go @@ -0,0 +1,15 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +func pinnedCallOpts(ctx context.Context, block uint64) *bind.CallOpts { + return &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} +} diff --git a/internal/prt/call_opts_test.go b/internal/prt/call_opts_test.go new file mode 100644 index 000000000..ad543980a --- /dev/null +++ b/internal/prt/call_opts_test.go @@ -0,0 +1,24 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPinnedCallOptsOwnsBlockNumber(t *testing.T) { + ctx := t.Context() + first := pinnedCallOpts(ctx, math.MaxUint64) + second := pinnedCallOpts(ctx, math.MaxUint64) + require.Same(t, ctx, first.Context) + require.Same(t, ctx, second.Context) + require.NotSame(t, first, second) + require.NotSame(t, first.BlockNumber, second.BlockNumber) + require.Equal(t, uint64(math.MaxUint64), first.BlockNumber.Uint64()) + first.BlockNumber.SetUint64(0) + require.Equal(t, uint64(math.MaxUint64), second.BlockNumber.Uint64()) +} diff --git a/internal/prt/consensus.go b/internal/prt/consensus.go new file mode 100644 index 000000000..9b7258d81 --- /dev/null +++ b/internal/prt/consensus.go @@ -0,0 +1,566 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +type daveConsensusSnapshot struct { + sealed CurrentSealedEpoch + stage CanStageTournamentResult + accept CanAcceptStagedTournamentResult +} + +func readDaveConsensusSnapshot( + ctx context.Context, + consensus DaveConsensusAdapter, + mostRecentBlock uint64, +) (daveConsensusSnapshot, error) { + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + sealed, err := consensus.GetCurrentSealedEpoch(callOpts) + if err != nil { + return daveConsensusSnapshot{}, fmt.Errorf("reading current sealed epoch: %w", err) + } + stage, err := consensus.CanStageTournamentResult(callOpts) + if err != nil { + return daveConsensusSnapshot{}, fmt.Errorf("reading tournament stage readiness: %w", err) + } + accept, err := consensus.CanAcceptStagedTournamentResult(callOpts) + if err != nil { + return daveConsensusSnapshot{}, fmt.Errorf("reading tournament accept readiness: %w", err) + } + snapshot := daveConsensusSnapshot{sealed: sealed, stage: stage, accept: accept} + if err := validateDaveConsensusSnapshot(snapshot, mostRecentBlock); err != nil { + return daveConsensusSnapshot{}, err + } + return snapshot, nil +} + +func validateDaveConsensusSnapshot(snapshot daveConsensusSnapshot, mostRecentBlock uint64) error { + sealed := snapshot.sealed + stage := snapshot.stage + accept := snapshot.accept + if sealed.EpochNumber != stage.EpochNumber || sealed.EpochNumber != accept.EpochNumber { + return fmt.Errorf("inconsistent DaveConsensus epoch numbers: sealed=%d stage=%d accept=%d", + sealed.EpochNumber, stage.EpochNumber, accept.EpochNumber) + } + if sealed.IsTournamentResultStaged != stage.IsTournamentResultStaged || + sealed.IsTournamentResultStaged != accept.IsTournamentResultStaged { + return fmt.Errorf("inconsistent DaveConsensus staged flags: sealed=%t stage=%t accept=%t", + sealed.IsTournamentResultStaged, + stage.IsTournamentResultStaged, + accept.IsTournamentResultStaged) + } + if sealed.InputIndexLowerBound > sealed.InputIndexUpperBound { + return fmt.Errorf("sealed epoch %d has input lower bound %d above upper bound %d", + sealed.EpochNumber, sealed.InputIndexLowerBound, sealed.InputIndexUpperBound) + } + if sealed.Tournament == (common.Address{}) { + return fmt.Errorf("sealed epoch %d has zero tournament address", sealed.EpochNumber) + } + + if sealed.IsTournamentResultStaged { + if sealed.StagingBlockNumber == 0 || sealed.StagingBlockNumber > mostRecentBlock { + return fmt.Errorf("sealed epoch %d has invalid staging block %d at observed block %d", + sealed.EpochNumber, sealed.StagingBlockNumber, mostRecentBlock) + } + if sealed.StagedPostEpochMachineStateHash != accept.StagedPostEpochMachineStateHash { + return errors.New("staged machine state differs between sealed and accept views") + } + if sealed.StagedPostEpochOutputsMerkleRoot != accept.StagedPostEpochOutputsMerkleRoot { + return errors.New("staged outputs root differs between sealed and accept views") + } + if stage.WinnerPostEpochMachineStateHash != sealed.StagedPostEpochMachineStateHash { + return errors.New("staged machine state differs from the tournament winner") + } + } + return nil +} + +// progressTournamentResult records results at the published tournament block, +// bounded by the configured head. A separate latest snapshot decides whether +// to send a stage or accept transaction now. +// A non-nil return value is the epoch that can be joined from that latest snapshot. +// Recovery is allowed only during a known wait or to reconcile a sent refund. +func (s *Service) progressTournamentResult( + ctx context.Context, + app *model.Application, + confirmedBlock uint64, + latestBlock uint64, +) (*model.Epoch, bool, error) { + recovery, err := s.rootBondRecoveryInFlight(app.ID) + if err != nil { + return nil, false, err + } + if recovery != nil { + if s.hasNonRecoveryMutationInFlight(app.ID) { + return nil, false, fmt.Errorf("application %s has bond recovery and another PRT transaction in flight", + app.IApplicationAddress) + } + s.Logger.Info("Tournament actions wait for pending root bond recovery", + "application", app.Name, "epoch_index", recovery.EpochIndex, "tx", *recovery.TxHash) + return nil, true, nil + } + if blocked, err := s.waitForTournamentTransaction(ctx, app, latestBlock); blocked || err != nil { + return nil, false, err + } + + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + return nil, false, fmt.Errorf("binding DaveConsensus for app %s: %w", app.IApplicationAddress, err) + } + observedBlock := min(confirmedBlock, app.LastTournamentCheckBlock) + var snapshot daveConsensusSnapshot + var epoch *model.Epoch + observedSnapshot := false + // Root discovery can stay behind a moving configured head. Read the result + // at the committed window instead of waiting for those heads to coincide. + // Zero means that no tournament window has been published yet. + if observedBlock != 0 { + snapshot, err = readDaveConsensusSnapshot(ctx, consensus, observedBlock) + if err != nil && !errors.Is(err, bind.ErrNoCode) { + return nil, false, fmt.Errorf("reading DaveConsensus snapshot for app %s: %w", app.IApplicationAddress, err) + } + if err == nil { + observedSnapshot = true + epoch, err = s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), snapshot.sealed.EpochNumber) + if err != nil { + return nil, false, fmt.Errorf("loading current sealed epoch %d: %w", snapshot.sealed.EpochNumber, err) + } + if pendingTournamentResult(epoch) && (epoch.StagedAtBlock == nil || *epoch.StagedAtBlock <= observedBlock) { + if err := s.recordTournamentResult(ctx, app, epoch, snapshot); err != nil { + return nil, false, err + } + } + } else { + // A bootstrap window can precede consensus deployment. This does + // not prevent a submitter from checking the latest chain below. + s.Logger.Debug("DaveConsensus is not deployed at the published tournament block", + "application", app.Name, "block", observedBlock) + } + } + if !s.submissionEnabled { + return nil, false, nil + } + + if !observedSnapshot || latestBlock != observedBlock { + snapshot, err = readDaveConsensusSnapshot(ctx, consensus, latestBlock) + if err != nil { + return nil, false, fmt.Errorf("reading latest DaveConsensus snapshot for app %s: %w", app.IApplicationAddress, err) + } + if epoch == nil || epoch.Index != snapshot.sealed.EpochNumber { + epoch, err = s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), snapshot.sealed.EpochNumber) + if err != nil { + return nil, false, fmt.Errorf("loading latest sealed epoch %d: %w", snapshot.sealed.EpochNumber, err) + } + } + } + if !pendingTournamentResult(epoch) { + s.Logger.Debug("Local epoch is not ready for tournament result processing", + "application", app.Name, + "epoch_index", snapshot.sealed.EpochNumber) + // A failed root has no remaining join or stage action to prioritize. + return nil, snapshot.stage.IsTournamentFailed, nil + } + if err := s.validateResultEpoch(ctx, app, epoch, snapshot.sealed.EpochNumber); err != nil { + return nil, false, err + } + // Latest evidence may prevent this action, but cannot change stored epoch + // or application status. Re-read it on the next tick after a race or reorg. + if err := matchConsensusSnapshotToEpoch(epoch, snapshot); err != nil { + return nil, false, fmt.Errorf("latest tournament result does not match local epoch: %w", err) + } + if snapshot.stage.IsTournamentFailed { + return nil, true, nil + } + if snapshot.sealed.IsTournamentResultStaged { + if !snapshot.accept.DoAllSentriesAgreeWithStagedTournamentResult && + !snapshot.accept.IsClaimStagingPeriodOver { + return nil, true, nil + } + return nil, false, s.broadcastAcceptTournamentResult(ctx, app, epoch, consensus, snapshot) + } + + if epoch.Status == model.EpochStatus_ClaimStaged { + s.Logger.Info("Local staged epoch is not staged in the pinned DaveConsensus snapshot; waiting for synchronization", + "application", app.Name, + "epoch_index", epoch.Index, + "block", latestBlock) + return nil, false, nil + } + if !snapshot.stage.IsFinished { + return epoch, false, nil + } + return nil, false, s.broadcastStageTournamentResult(ctx, app, epoch, consensus) +} + +func pendingTournamentResult(epoch *model.Epoch) bool { + return epoch != nil && (epoch.Status == model.EpochStatus_ClaimComputed || epoch.Status == model.EpochStatus_ClaimStaged) +} + +// recordTournamentResult consumes a snapshot at a published tournament block +// that is no newer than the configured head. +func (s *Service) recordTournamentResult( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + snapshot daveConsensusSnapshot, +) error { + if err := s.validateResultEpoch(ctx, app, epoch, snapshot.sealed.EpochNumber); err != nil { + return err + } + if epoch.InputIndexLowerBound != snapshot.sealed.InputIndexLowerBound || + epoch.InputIndexUpperBound != snapshot.sealed.InputIndexUpperBound { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has input bounds inconsistent with DaveConsensus: local=[%d,%d] on-chain=[%d,%d]", + epoch.Index, epoch.InputIndexLowerBound, epoch.InputIndexUpperBound, + snapshot.sealed.InputIndexLowerBound, snapshot.sealed.InputIndexUpperBound) + } + if err := matchConsensusSnapshotToEpoch(epoch, snapshot); err != nil { + return s.setApplicationDiverged(ctx, app, "%v", err) + } + if snapshot.stage.IsTournamentFailed { + s.logFailedRootTournament(app, epoch.Index, snapshot.sealed.Tournament) + return nil + } + if !snapshot.sealed.IsTournamentResultStaged { + return nil + } + if epoch.Status == model.EpochStatus_ClaimStaged { + if epoch.StagedAtBlock == nil || *epoch.StagedAtBlock != snapshot.sealed.StagingBlockNumber { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has staging block inconsistent with DaveConsensus", epoch.Index) + } + return nil + } + if err := s.repository.UpdateEpochReconciledStaged(ctx, app.ID, epoch.Index, snapshot.sealed.StagingBlockNumber); err != nil { + return fmt.Errorf("recording staged tournament result for epoch %d: %w", epoch.Index, err) + } + epoch.Status = model.EpochStatus_ClaimStaged + epoch.StagedAtBlock = new(snapshot.sealed.StagingBlockNumber) + s.Logger.Info("Recorded staged tournament result", + "application", app.Name, "epoch_index", epoch.Index, "staging_block", snapshot.sealed.StagingBlockNumber) + return nil +} + +func (s *Service) validateResultEpoch(ctx context.Context, app *model.Application, epoch *model.Epoch, index uint64) error { + if epoch.TournamentAddress == nil || epoch.Commitment == nil || epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has missing required fields for tournament result processing", epoch.Index) + } + if epoch.Index != index { + return s.setApplicationCorrupted(ctx, app, + "loaded epoch %d for current sealed epoch %d", epoch.Index, index) + } + return nil +} + +func matchConsensusSnapshotToEpoch(epoch *model.Epoch, snapshot daveConsensusSnapshot) error { + if epoch.InputIndexLowerBound != snapshot.sealed.InputIndexLowerBound || + epoch.InputIndexUpperBound != snapshot.sealed.InputIndexUpperBound { + return fmt.Errorf( + "epoch %d has input bounds inconsistent with DaveConsensus: local=[%d,%d] on-chain=[%d,%d]", + epoch.Index, + epoch.InputIndexLowerBound, + epoch.InputIndexUpperBound, + snapshot.sealed.InputIndexLowerBound, + snapshot.sealed.InputIndexUpperBound) + } + if *epoch.TournamentAddress != snapshot.sealed.Tournament { + return fmt.Errorf( + "epoch %d has inconsistent root tournament between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.TournamentAddress, snapshot.sealed.Tournament) + } + if snapshot.stage.IsTournamentFailed { + return nil + } + if !snapshot.stage.IsFinished { + return nil + } + if *epoch.Commitment != snapshot.stage.WinnerCommitment { + return fmt.Errorf( + "epoch %d has inconsistent commitment between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.Commitment, snapshot.stage.WinnerCommitment) + } + if *epoch.MachineHash != snapshot.stage.WinnerPostEpochMachineStateHash { + return fmt.Errorf( + "epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash, snapshot.stage.WinnerPostEpochMachineStateHash) + } + if snapshot.sealed.IsTournamentResultStaged { + if *epoch.MachineHash != snapshot.sealed.StagedPostEpochMachineStateHash { + return fmt.Errorf( + "epoch %d has inconsistent staged machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash, snapshot.sealed.StagedPostEpochMachineStateHash) + } + if *epoch.TxBufferDataBlock != snapshot.sealed.StagedPostEpochOutputsMerkleRoot { + return fmt.Errorf( + "epoch %d has inconsistent staged outputs root between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.TxBufferDataBlock, snapshot.sealed.StagedPostEpochOutputsMerkleRoot) + } + } + return nil +} + +func (s *Service) broadcastStageTournamentResult( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + consensus DaveConsensusAdapter, +) error { + proof, err := epoch.StateProof() + if err != nil { + return s.setApplicationCorrupted(ctx, app, + "cannot stage tournament result for epoch %d: persisted machine state proof is incomplete: %v", + epoch.Index, err) + } + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) + defer cancel() + txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) + if err != nil { + return fmt.Errorf("creating transaction options to stage epoch %d: %w", epoch.Index, err) + } + tx, err := consensus.StageTournamentResult(txOpts, epoch.Index, proof) + if err != nil { + return s.handleStageTournamentResultRevert(ctx, app, epoch, err) + } + if tx == nil { + return errors.New("stage tournament result returned a nil transaction") + } + txHash := tx.Hash() + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionStage, Hash: txHash, EpochIndex: epoch.Index, + } + s.Logger.Info("Sent tournament result stage transaction", + "application", app.Name, + "epoch_index", epoch.Index, + "tx", txHash) + return nil +} + +func (s *Service) broadcastAcceptTournamentResult( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + consensus DaveConsensusAdapter, + snapshot daveConsensusSnapshot, +) error { + if epoch.TournamentAddress == nil || *epoch.TournamentAddress != snapshot.sealed.Tournament { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has no validated root tournament for result acceptance", epoch.Index) + } + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) + defer cancel() + txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) + if err != nil { + return fmt.Errorf("creating transaction options to accept epoch %d: %w", epoch.Index, err) + } + s.queueRootBondRecovery(app.ID, epoch.Index, snapshot.sealed.Tournament) + tx, err := consensus.AcceptStagedTournamentResult(txOpts, epoch.Index) + if err != nil { + return s.handleAcceptTournamentResultRevert(ctx, app, epoch, err) + } + if tx == nil { + return errors.New("accept staged tournament result returned a nil transaction") + } + txHash := tx.Hash() + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionAccept, Hash: txHash, EpochIndex: epoch.Index, + } + s.Logger.Info("Sent staged tournament result acceptance transaction", + "application", app.Name, + "epoch_index", epoch.Index, + "tournament", snapshot.sealed.Tournament, + "tx", txHash) + return nil +} + +func (s *Service) handleStageTournamentResultRevert( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + err error, +) error { + if name := stageMachineValidationRevertName(err); name != "" { + if name == "InvalidMachineMerkleProof" { + // The contract checks this proof against its current winner, which + // may differ from the latest precheck after a reorg or RPC race. + // Only diagnose the proof once its target is confirmed. + if confirmErr := s.confirmStageProofTarget(ctx, app, epoch); confirmErr != nil { + return fmt.Errorf("stage proof rejected before its target could be confirmed: %w", confirmErr) + } + } + guidance := "The node validated the proof when it collected it. " + + "Check proof serialization, stored proof data, and node and contract versions before re-enabling." + if name == "InvalidPostEpochMachineIflagsYRegister" || name == "InvalidPostEpochMachineHtifTohostRegister" { + guidance = "The proven post-epoch machine state cannot finalize. " + + "Check the selected post-epoch state, stored data, and node and contract versions. " + + "If this state is unrecoverable, consider guardian foreclosure to permit emergency withdrawals and deposit refunds." + } + return s.setApplicationFailed(ctx, app, + "StageTournamentResult rejected the machine state proof with %s for epoch %d. %s", + name, epoch.Index, guidance) + } + switch { + case isTournamentError(err, "TournamentFailedNoWinner"): + s.Logger.Warn("Stage transaction observed a failed tournament; waiting for configured-block confirmation", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "TournamentResultAlreadyStaged"): + s.Logger.Info("Tournament result was already staged; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "TournamentNotFinishedYet"): + s.Logger.Info("Tournament result is not ready to stage; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "IncorrectEpochNumber"): + return s.handleResultEpochRace(app, "StageTournamentResult", epoch.Index, err) + case isDaveConsensusError(err, "ApplicationForeclosed"): + s.Logger.Warn("StageTournamentResult observed application foreclosure; waiting for the foreclosure observer", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveApplicationFailure(err): + return s.failDaveApplicationCall(ctx, app, "StageTournamentResult", epoch.Index, err) + case ethutil.IsNonceTooLowError(err): + s.Logger.Info("StageTournamentResult broadcast was rejected with nonce too low; waiting for chain reconciliation", + "application", app.Name, "epoch_index", epoch.Index) + return nil + default: + s.logErrorUnlessShutdown(ctx, "failed to stage tournament result", err, + "application", app.Name, + "epoch_index", epoch.Index, + "decoded_revert", describeKnownRevert(err)) + return err + } +} + +func (s *Service) confirmStageProofTarget(ctx context.Context, app *model.Application, epoch *model.Epoch) error { + block, err := s.getDefaultBlockNumber(ctx) + if err != nil { + return err + } + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + return err + } + snapshot, err := readDaveConsensusSnapshot(ctx, consensus, block) + if err != nil { + return err + } + if snapshot.sealed.EpochNumber != epoch.Index || !snapshot.stage.IsFinished || snapshot.stage.IsTournamentFailed { + return fmt.Errorf("epoch %d winner is not confirmed at configured block %d", epoch.Index, block) + } + if err := s.validateResultEpoch(ctx, app, epoch, snapshot.sealed.EpochNumber); err != nil { + return err + } + return matchConsensusSnapshotToEpoch(epoch, snapshot) +} + +func stageMachineValidationRevertName(err error) string { + for _, name := range []string{ + "InvalidSiblingsArrayLength", + "InvalidMachineMerkleProof", + "InvalidPostEpochMachineIflagsYRegister", + "InvalidPostEpochMachineHtifTohostRegister", + } { + if isDaveConsensusError(err, name) { + return name + } + } + return "" +} + +func (s *Service) handleAcceptTournamentResultRevert( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + err error, +) error { + switch { + case isDaveConsensusError(err, "TournamentResultNotStaged"): + s.Logger.Info("Tournament result is no longer staged; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "ClaimStagingPeriodNotOverYet"): + s.Logger.Info("Tournament result is not ready to accept; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveConsensusError(err, "IncorrectEpochNumber"): + return s.handleResultEpochRace(app, "AcceptStagedTournamentResult", epoch.Index, err) + case isDaveConsensusError(err, "ApplicationForeclosed"): + s.Logger.Warn("AcceptStagedTournamentResult observed application foreclosure; waiting for the foreclosure observer", + "application", app.Name, "epoch_index", epoch.Index) + return nil + case isDaveApplicationFailure(err): + return s.failDaveApplicationCall(ctx, app, "AcceptStagedTournamentResult", epoch.Index, err) + case ethutil.IsNonceTooLowError(err): + s.Logger.Info("AcceptStagedTournamentResult broadcast was rejected with nonce too low; waiting for chain reconciliation", + "application", app.Name, "epoch_index", epoch.Index) + return nil + default: + s.logErrorUnlessShutdown(ctx, "failed to accept staged tournament result", err, + "application", app.Name, + "epoch_index", epoch.Index, + "decoded_revert", describeKnownRevert(err)) + return err + } +} + +func (s *Service) handleResultEpochRace( + app *model.Application, + action string, + epochNumber uint64, + err error, +) error { + received, actual, ok := decodeIncorrectEpochNumber(err) + if !ok { + return err + } + if received.Cmp(actual) > 0 { + // Simulation may use a lagging replica or an unfinalized fork. It is + // not evidence that the local epoch is invalid. + return err + } + s.Logger.Info("Tournament result transaction used a stale epoch; waiting for chain synchronization", + "application", app.Name, + "action", action, + "epoch_index", epochNumber, + "chain_epoch", actual) + return nil +} + +func isDaveApplicationFailure(err error) bool { + return isDaveConsensusError(err, "ApplicationNotDeployed") || + isDaveConsensusError(err, "ApplicationReverted") || + isDaveConsensusError(err, "IllformedApplicationReturnData") +} + +func (s *Service) failDaveApplicationCall( + ctx context.Context, + app *model.Application, + action string, + epochNumber uint64, + err error, +) error { + for _, name := range []string{"ApplicationNotDeployed", "ApplicationReverted", "IllformedApplicationReturnData"} { + if !isDaveConsensusError(err, name) { + continue + } + return s.setApplicationFailed(ctx, app, + "%s reverted with %s for epoch %d. Verify the application and consensus contracts before re-enabling.%s", + action, name, epochNumber, ethutil.ApplicationReturnDataSuffix(err, idaveconsensus.IDaveConsensusMetaData, name)) + } + return err +} diff --git a/internal/prt/consensus_test.go b/internal/prt/consensus_test.go new file mode 100644 index 000000000..66823c0cf --- /dev/null +++ b/internal/prt/consensus_test.go @@ -0,0 +1,732 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func TestCheckedUint64(t *testing.T) { + require.Equal(t, uint64(42), mustCheckedUint64(t, big.NewInt(42))) + for _, value := range []*big.Int{ + nil, + big.NewInt(-1), + new(big.Int).Lsh(common.Big1, 64), + } { + _, err := checkedUint64(value, "test field") + require.ErrorContains(t, err, "test field is not a uint64") + } +} + +func mustCheckedUint64(t *testing.T, value *big.Int) uint64 { + t.Helper() + result, err := checkedUint64(value, "test field") + require.NoError(t, err) + return result +} + +func TestDaveMachineValidityProofUsesContractOrderAndOwnsSiblings(t *testing.T) { + proof := model.StateProof{ + IflagsYDataBlock: common.HexToHash("0x11"), IflagsYProof: [][32]byte{{0x12}, {0x13}}, + HtifTohostDataBlock: common.HexToHash("0x21"), HtifTohostProof: [][32]byte{{0x22}, {0x23}}, + TxBufferDataBlock: common.HexToHash("0x31"), TxBufferProof: [][32]byte{{0x32}, {0x33}}, + } + wire := daveMachineValidityProof(proof) + require.Equal(t, [32]byte(proof.IflagsYDataBlock), wire.IflagsYProof.DataBlock) + require.Equal(t, proof.IflagsYProof, wire.IflagsYProof.Siblings) + require.Equal(t, [32]byte(proof.HtifTohostDataBlock), wire.HtifTohostProof.DataBlock) + require.Equal(t, proof.HtifTohostProof, wire.HtifTohostProof.Siblings) + require.Equal(t, [32]byte(proof.TxBufferDataBlock), wire.TxBufferProof.DataBlock) + require.Equal(t, proof.TxBufferProof, wire.TxBufferProof.Siblings) + wire.IflagsYProof.Siblings[0][0] ^= 0xff + wire.HtifTohostProof.Siblings[0][0] ^= 0xff + wire.TxBufferProof.Siblings[0][0] ^= 0xff + require.Equal(t, byte(0x12), proof.IflagsYProof[0][0]) + require.Equal(t, byte(0x22), proof.HtifTohostProof[0][0]) + require.Equal(t, byte(0x32), proof.TxBufferProof[0][0]) +} + +func TestReadDaveConsensusSnapshotUsesOnePinnedCallOptions(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + snapshot := resultTestSnapshot(epoch, true) + consensus := &daveConsensusAdapterMock{} + var first *bind.CallOpts + checkOpts := func(args mock.Arguments) { + opts := args.Get(0).(*bind.CallOpts) + require.Equal(t, int64(20), opts.BlockNumber.Int64()) + if first == nil { + first = opts + } else { + require.Same(t, first, opts) + } + } + consensus.On("GetCurrentSealedEpoch", mock.Anything).Run(checkOpts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", mock.Anything).Run(checkOpts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Run(checkOpts).Return(snapshot.accept, nil).Once() + + got, err := readDaveConsensusSnapshot(context.Background(), consensus, 20) + require.NoError(t, err) + require.Equal(t, snapshot, got) + consensus.AssertExpectations(t) +} + +func TestValidateDaveConsensusSnapshotRejectsInconsistentViews(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + tests := []struct { + name string + mutate func(*daveConsensusSnapshot) + want string + }{ + {name: "epoch number", mutate: func(s *daveConsensusSnapshot) { s.accept.EpochNumber++ }, want: "epoch numbers"}, + {name: "staged flag", mutate: func(s *daveConsensusSnapshot) { + s.accept.IsTournamentResultStaged = false + }, want: "staged flags"}, + {name: "staging block", mutate: func(s *daveConsensusSnapshot) { s.sealed.StagingBlockNumber = 21 }, want: "staging block"}, + {name: "staged machine", mutate: func(s *daveConsensusSnapshot) { + s.accept.StagedPostEpochMachineStateHash[0] ^= 0xff + }, want: "staged machine state differs"}, + {name: "staged outputs", mutate: func(s *daveConsensusSnapshot) { + s.accept.StagedPostEpochOutputsMerkleRoot[0] ^= 0xff + }, want: "staged outputs root differs"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + snapshot := resultTestSnapshot(epoch, true) + test.mutate(&snapshot) + require.ErrorContains(t, validateDaveConsensusSnapshot(snapshot, 20), test.want) + }) + } +} + +func TestProgressTournamentResultReaderReconcilesStagedResult(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + service, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, true), false) + repo.On("UpdateEpochReconciledStaged", mock.Anything, epoch.ApplicationID, epoch.Index, uint64(10)). + Return(nil).Once() + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.NotNil(t, epoch.StagedAtBlock) + require.Equal(t, uint64(10), *epoch.StagedAtBlock) + consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestProgressTournamentResultReaderDoesNotStageResult(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, false), false) + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestProgressTournamentResultUsesLatestWithoutPublishedConsensus(t *testing.T) { + for _, publishedBlock := range []uint64{0, 99} { + name := "no published window" + if publishedBlock != 0 { + name = "published window precedes deployment" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock = publishedBlock + if publishedBlock != 0 { + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(publishedBlock))). + Return(CurrentSealedEpoch{}, bind.ErrNoCode).Once() + } + latest := resultTestSnapshot(f.epoch, false) + latest.stage.IsFinished = false + f.expectSnapshot(120, latest) + + epoch, _, err := f.s.progressTournamentResult(t.Context(), f.app, 100, 120) + require.NoError(t, err) + require.Same(t, f.epoch, epoch, "latest join selection must remain available") + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) + f.consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(0))) + }) + } +} + +func TestProgressTournamentResultReaderWaitsWithoutPublishedConsensus(t *testing.T) { + for _, publishedBlock := range []uint64{0, 99} { + t.Run(new(big.Int).SetUint64(publishedBlock).String(), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = publishedBlock + consensus := &daveConsensusAdapterMock{} + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + s.adapterFactory = factory + if publishedBlock != 0 { + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(publishedBlock))). + Return(CurrentSealedEpoch{}, bind.ErrNoCode).Once() + } + + epoch, _, err := s.progressTournamentResult(t.Context(), app, 100, 100) + require.NoError(t, err) + require.Nil(t, epoch) + require.Empty(t, repo.Calls) + consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(100))) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + }) + } +} + +func TestProgressTournamentResultReusesPublishedSnapshotAtLatest(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock = 100 + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + f.expectSnapshot(100, snapshot) + + epoch, _, err := f.s.progressTournamentResult(t.Context(), f.app, 100, 100) + require.NoError(t, err) + require.Same(t, f.epoch, epoch) + f.consensus.AssertNumberOfCalls(t, "GetCurrentSealedEpoch", 1) +} + +func TestProgressTournamentResultPreservesObservedReadError(t *testing.T) { + s, repo := newPRTServiceMock() + s.submissionEnabled = true + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 99 + consensus := &daveConsensusAdapterMock{} + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + s.adapterFactory = factory + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(99))). + Return(CurrentSealedEpoch{}, context.DeadlineExceeded).Once() + + epoch, _, err := s.progressTournamentResult(t.Context(), app, 100, 120) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, epoch) + require.Empty(t, repo.Calls) + consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(120))) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) +} + +func TestProgressTournamentResultReloadsLatestEpochAfterPublishedResult(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock = 99 + observed := resultTestSnapshot(f.epoch, false) + observed.stage.IsFinished = false + f.expectSnapshot(99, observed) + latestEpoch := *f.epoch + latestEpoch.Index++ + latestEpoch.TournamentAddress = new(common.HexToAddress("0x201")) + latest := resultTestSnapshot(&latestEpoch, false) + latest.stage.IsFinished = false + f.expectSnapshot(120, latest) + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), latestEpoch.Index).Return(&latestEpoch, nil).Once() + + epoch, _, err := f.s.progressTournamentResult(t.Context(), f.app, 100, 120) + require.NoError(t, err) + require.Same(t, &latestEpoch, epoch) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) +} + +func TestProgressTournamentResultWaitsBelowStoredStagingBlock(t *testing.T) { + f := newPRTBlockPolicyFixture(t, false) + f.app.LastTournamentCheckBlock = 99 + f.epoch.Status = model.EpochStatus_ClaimStaged + f.epoch.StagedAtBlock = new(uint64(100)) + older := resultTestSnapshot(f.epoch, true) + older.sealed.StagingBlockNumber = 90 + f.expectSnapshot(99, older) + + epoch, _, err := f.s.progressTournamentResult(t.Context(), f.app, 101, 101) + require.NoError(t, err) + require.Nil(t, epoch) + require.Equal(t, new(uint64(100)), f.epoch.StagedAtBlock) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) +} + +func TestProgressTournamentResultRejectsInputBoundsMismatch(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*daveConsensusSnapshot) + }{ + {name: "lower bound", mutate: func(snapshot *daveConsensusSnapshot) { + snapshot.sealed.InputIndexLowerBound++ + }}, + {name: "upper bound", mutate: func(snapshot *daveConsensusSnapshot) { + snapshot.sealed.InputIndexUpperBound++ + }}, + } { + t.Run(test.name, func(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + test.mutate(&snapshot) + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(reasonContains("input bounds inconsistent"))).Return(nil).Once() + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.Error(t, err) + require.Nil(t, joinEpoch) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + require.Empty(t, service.rootBondRecoveries[app.ID]) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + }) + } +} + +func TestProgressTournamentResultKeepsFailedRootObservable(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.Nil(t, joinEpoch) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestProgressTournamentResultAcceptsWhenSentriesAgreeOrPeriodIsOver(t *testing.T) { + for _, test := range []struct { + name string + agree bool + period bool + accepts bool + }{ + {name: "sentries agree", agree: true, accepts: true}, + {name: "zero period is over", period: true, accepts: true}, + {name: "not ready", accepts: false}, + } { + t.Run(test.name, func(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + snapshot := resultTestSnapshot(epoch, true) + snapshot.accept.DoAllSentriesAgreeWithStagedTournamentResult = test.agree + snapshot.accept.IsClaimStagingPeriodOver = test.period + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + if test.accepts { + tx := types.NewTx(&types.LegacyTx{Nonce: 7}) + consensus.On("AcceptStagedTournamentResult", mock.Anything, epoch.Index).Return(tx, nil).Once() + } + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), prtRevertTestApp(), 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + if test.accepts { + require.Contains(t, service.pendingTransactions, epoch.ApplicationID) + } else { + require.NotContains(t, service.pendingTransactions, epoch.ApplicationID) + } + repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + consensus.AssertExpectations(t) + }) + } +} + +func TestProgressTournamentResultRejectsIncompleteProofBeforeBroadcast(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, false), true) + app := prtRevertTestApp() + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(reasonContains("cannot stage", "proof is incomplete"))).Return(nil).Once() + + _, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.Error(t, err) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestProgressTournamentResultDefersCompleteProofValidationToContract(t *testing.T) { + proof := repotest.KeccakStateProof(common.HexToHash("0x1234")) + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + applyPRTStateProof(epoch, proof) + epoch.TxBufferProof[0][0] ^= 0xff + corruptSibling := [32]byte(epoch.TxBufferProof[0]) + snapshot := resultTestSnapshot(epoch, false) + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + service.client = client + service.defaultBlock = model.DefaultBlock_Finalized + opts := mock.MatchedBy(resultCallOptsAtBlock(20)) + consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + factory := service.adapterFactory.(*adapterFactoryMock) + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + consensus.On("StageTournamentResult", mock.Anything, epoch.Index, + mock.MatchedBy(func(got model.StateProof) bool { + return got.TxBufferProof[0] == corruptSibling + })).Return((*types.Transaction)(nil), daveConsensusRevertError("InvalidMachineMerkleProof")).Once() + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(reasonContains("Check proof serialization", "stored proof data"))).Return(nil).Once() + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.Empty(t, service.pendingTransactions) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + client.AssertExpectations(t) + factory.AssertExpectations(t) +} + +func TestProgressTournamentResultStagesCompleteProof(t *testing.T) { + proof := repotest.KeccakStateProof(common.HexToHash("0x1234")) + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + applyPRTStateProof(epoch, proof) + snapshot := resultTestSnapshot(epoch, false) + service, repo, consensus := resultTestService(t, epoch, snapshot, true) + tx := types.NewTx(&types.LegacyTx{Nonce: 8}) + consensus.On("StageTournamentResult", mock.Anything, epoch.Index, + mock.MatchedBy(func(got model.StateProof) bool { + return got.IflagsYDataBlock == proof.IflagsYDataBlock && + got.HtifTohostDataBlock == proof.HtifTohostDataBlock && + got.TxBufferDataBlock == proof.TxBufferDataBlock && + len(got.IflagsYProof) == model.StateProofSiblingCount && + len(got.HtifTohostProof) == model.StateProofSiblingCount && + len(got.TxBufferProof) == model.StateProofSiblingCount + })).Return(tx, nil).Once() + + joinEpoch, _, err := service.progressTournamentResult(context.Background(), prtRevertTestApp(), 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.Contains(t, service.pendingTransactions, epoch.ApplicationID) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestCheckEpochsAcceptsComputedOrStagedEpochFromExactEpochSealedEvent(t *testing.T) { + for _, status := range []model.EpochStatus{ + model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimStaged, + } { + t.Run(status.String(), func(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(status) + epoch.LastBlock = 10 + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + wrongEmitterLog := &types.Log{ + Address: common.HexToAddress("0xbad"), TxHash: acceptTx, BlockNumber: 12, Index: 1, + } + wrongHashLog := &types.Log{ + Address: app.IConsensusAddress, TxHash: common.HexToHash("0xbad"), BlockNumber: 12, Index: 2, + } + staleLog := &types.Log{Address: app.IConsensusAddress, TxHash: acceptTx, BlockNumber: 12, Index: 3} + exactLog := &types.Log{Address: app.IConsensusAddress, TxHash: acceptTx, BlockNumber: 12, Index: 4} + receipt := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: acceptTx, + BlockNumber: big.NewInt(12), + Logs: []*types.Log{wrongEmitterLog, wrongHashLog, staleLog, exactLog}, + } + staleEvent := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(99), + Raw: *staleLog, + } + exactEvent := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: new(big.Int).SetUint64(epoch.Index + 1), + InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, + Raw: *exactLog, + } + + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, + mock.MatchedBy(func(filter repository.EpochFilter) bool { + return filter.HasTournament != nil && *filter.HasTournament && len(filter.Status) == 0 + }), repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{ + ApplicationID: app.ID, + EpochIndex: epoch.Index, + Address: *epoch.TournamentAddress, + MaxLevel: 3, + Level: 0, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9}, + }, nil).Once() + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, + mock.MatchedBy(func(hash *common.Hash) bool { return hash != nil && *hash == acceptTx })). + Return(nil).Once() + + consensus := &daveConsensusAdapterMock{} + consensus.On("ParseEpochSealed", *staleLog).Return(staleEvent, nil).Once() + consensus.On("ParseEpochSealed", *exactLog).Return(exactEvent, nil).Once() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(receipt, nil).Once() + + service, _ := newPRTServiceMock() + service.repository = repo + service.adapterFactory = factory + service.client = client + service.submissionEnabled = true + deferActions, err := service.checkEpochs(context.Background(), app, 20) + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, []*rootBondRecovery{{ + EpochIndex: epoch.Index, + Tournament: *epoch.TournamentAddress, + }}, service.rootBondRecoveries[app.ID]) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestCheckEpochsValidatesAcceptanceReceiptAgainstPinnedHead(t *testing.T) { + for _, test := range []struct { + name string + receipt *types.Receipt + wantErr string + }{ + { + name: "wrong transaction hash", + receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0xbad"), + BlockNumber: big.NewInt(12), + }, + wantErr: "differs from observed hash", + }, + { + name: "receipt after pinned head", + receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0x700"), + BlockNumber: big.NewInt(21), + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9}}, nil).Once() + consensus := &daveConsensusAdapterMock{} + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(test.receipt, nil).Once() + service, _ := newPRTServiceMock() + service.repository = repo + service.adapterFactory = factory + service.client = client + + deferActions, err := service.checkEpochs(context.Background(), app, 20) + if test.wantErr == "" { + require.NoError(t, err) + require.True(t, deferActions) + } else { + require.ErrorContains(t, err, test.wantErr) + } + repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + consensus.AssertNotCalled(t, "ParseEpochSealed", mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestValidateApplicationDefersActionsForAcceptanceAfterPinnedHead(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9}}, nil).Once() + consensus := &daveConsensusAdapterMock{} + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: acceptTx, + BlockNumber: big.NewInt(21), + }, nil).Once() + service, _ := newPRTServiceMock() + service.repository = repo + service.adapterFactory = factory + service.client = client + service.defaultBlock = model.DefaultBlock_Finalized + + require.NoError(t, runPRTApplicationTick(service, app)) + consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.Anything) + consensus.AssertNotCalled(t, "CanStageTournamentResult", mock.Anything) + consensus.AssertNotCalled(t, "CanAcceptStagedTournamentResult", mock.Anything) + consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) +} + +func resultCallOptsAtBlock(block uint64) func(*bind.CallOpts) bool { + return func(opts *bind.CallOpts) bool { + return opts != nil && opts.BlockNumber != nil && opts.BlockNumber.IsUint64() && opts.BlockNumber.Uint64() == block + } +} + +func resultTestEpoch(status model.EpochStatus) *model.Epoch { + tournament := common.HexToAddress("0x200") + commitment := common.HexToHash("0x300") + machineHash := common.HexToHash("0x400") + outputsRoot := common.HexToHash("0x500") + epoch := &model.Epoch{ + ApplicationID: 7, + Index: 3, + InputIndexLowerBound: 4, + InputIndexUpperBound: 9, + Status: status, + TournamentAddress: &tournament, + Commitment: &commitment, + MachineHash: &machineHash, + TxBufferDataBlock: &outputsRoot, + } + if status == model.EpochStatus_ClaimStaged { + epoch.StagedAtBlock = new(uint64) + *epoch.StagedAtBlock = 10 + } + return epoch +} + +func resultTestSnapshot(epoch *model.Epoch, staged bool) daveConsensusSnapshot { + snapshot := daveConsensusSnapshot{ + sealed: CurrentSealedEpoch{ + EpochNumber: epoch.Index, + InputIndexLowerBound: epoch.InputIndexLowerBound, + InputIndexUpperBound: epoch.InputIndexUpperBound, + Tournament: *epoch.TournamentAddress, + IsTournamentResultStaged: staged, + }, + stage: CanStageTournamentResult{ + IsFinished: true, + IsTournamentResultStaged: staged, + EpochNumber: epoch.Index, + WinnerCommitment: *epoch.Commitment, + WinnerPostEpochMachineStateHash: *epoch.MachineHash, + }, + accept: CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: staged, + EpochNumber: epoch.Index, + }, + } + if staged { + snapshot.sealed.StagingBlockNumber = 10 + snapshot.sealed.StagedPostEpochMachineStateHash = *epoch.MachineHash + snapshot.sealed.StagedPostEpochOutputsMerkleRoot = *epoch.TxBufferDataBlock + snapshot.accept.StagedPostEpochMachineStateHash = *epoch.MachineHash + snapshot.accept.StagedPostEpochOutputsMerkleRoot = *epoch.TxBufferDataBlock + } + return snapshot +} + +func resultTestService( + t *testing.T, + epoch *model.Epoch, + snapshot daveConsensusSnapshot, + submissionEnabled bool, +) (*Service, *prtRepositoryMock, *daveConsensusAdapterMock) { + t.Helper() + app := prtRevertTestApp() + app.IConsensusAddress = common.HexToAddress("0x100") + repo := &prtRepositoryMock{} + repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", mock.Anything).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Return(snapshot.accept, nil).Once() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + service, _ := newPRTServiceMock() + service.repository = repo + service.adapterFactory = factory + service.submissionEnabled = submissionEnabled + service.submissionTimeout = time.Second + service.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: common.HexToAddress("0x600")}) + return service, repo, consensus +} + +func applyPRTStateProof(epoch *model.Epoch, proof model.StateProof) { + epoch.MachineHash = new(common.Hash) + *epoch.MachineHash = proof.MachineHash + epoch.TxBufferDataBlock = new(common.Hash) + *epoch.TxBufferDataBlock = proof.TxBufferDataBlock + epoch.TxBufferProof = stateProofHashes(proof.TxBufferProof) + epoch.IflagsYDataBlock = new(common.Hash) + *epoch.IflagsYDataBlock = proof.IflagsYDataBlock + epoch.IflagsYProof = stateProofHashes(proof.IflagsYProof) + epoch.HtifTohostDataBlock = new(common.Hash) + *epoch.HtifTohostDataBlock = proof.HtifTohostDataBlock + epoch.HtifTohostProof = stateProofHashes(proof.HtifTohostProof) +} + +func stateProofHashes(proof [][32]byte) []common.Hash { + result := make([]common.Hash, len(proof)) + for i := range proof { + result[i] = proof[i] + } + return result +} diff --git a/internal/prt/current_projection_test.go b/internal/prt/current_projection_test.go new file mode 100644 index 000000000..7067e5547 --- /dev/null +++ b/internal/prt/current_projection_test.go @@ -0,0 +1,146 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestMatchProjectionPreservesCurrentPhaseAndFullWidth(t *testing.T) { + position := new(big.Int).Lsh(big.NewInt(1), 200) + cycle := new(big.Int).Add(position, big.NewInt(3)) + left, right, parent := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + for _, phase := range []MatchPhase{MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed, MatchPhaseUninitialized} { + t.Run(string(phase), func(t *testing.T) { + view := ObservedMatchSnapshot{Phase: phase, TimeoutOutcome: MatchTimeoutTwoWins, DeferredCharge: 7} + switch phase { + case MatchPhaseBisecting: + view.Bisecting = &BisectingMatch{RevealingParent: parent, WaitingLeft: left, WaitingRight: right, + SegmentStartPosition: new(big.Int).Set(position), SegmentStartCycle: new(big.Int).Set(cycle), + CurrentHeight: 0, Responder: CommitmentSideTwo} + case MatchPhaseReadyToSeal: + view.ReadyToSeal = &ReadyToSealMatch{RevealingParent: parent, WaitingLeft: left, WaitingRight: right, + SegmentStartPosition: new(big.Int).Set(position), SegmentStartCycle: new(big.Int).Set(cycle), + Responder: CommitmentSideOne} + case MatchPhaseSealed: + view.Sealed = &SealedMatch{AgreeState: parent, FinalStateOne: left, FinalStateTwo: right, + DivergencePosition: new(big.Int).Set(position), DivergenceCycle: new(big.Int).Set(cycle)} + case MatchPhaseUninitialized: + view.TimeoutOutcome, view.DeferredCharge = MatchTimeoutNone, 0 + } + projection, err := projectMatchSnapshot(view, 100, phase == MatchPhaseUninitialized) + require.NoError(t, err) + require.Equal(t, uint64(100), projection.AsOfBlock) + require.Equal(t, phase, projection.Phase) + require.Equal(t, view.TimeoutOutcome, projection.TimeoutOutcome) + require.Equal(t, view.DeferredCharge, projection.DeferredCharge) + switch phase { + case MatchPhaseBisecting, MatchPhaseReadyToSeal: + require.NotNil(t, projection.Bisection) + require.Nil(t, projection.Sealed) + require.Equal(t, parent, projection.Bisection.RevealingParent) + require.Equal(t, left, projection.Bisection.WaitingLeft) + require.Equal(t, right, projection.Bisection.WaitingRight) + require.Equal(t, position, projection.Bisection.SegmentStartPosition.ToBig()) + require.Equal(t, cycle, projection.Bisection.SegmentStartCycle.ToBig()) + if phase == MatchPhaseBisecting { + require.NotNil(t, projection.Bisection.CurrentHeight, "zero height is present in BISECTING") + require.Zero(t, *projection.Bisection.CurrentHeight) + require.Equal(t, CommitmentSideTwo, projection.Bisection.Responder) + view.Bisecting.SegmentStartPosition.SetUint64(0) + } else { + require.Nil(t, projection.Bisection.CurrentHeight) + require.Equal(t, CommitmentSideOne, projection.Bisection.Responder) + view.ReadyToSeal.SegmentStartPosition.SetUint64(0) + } + require.Equal(t, position, projection.Bisection.SegmentStartPosition.ToBig()) + case MatchPhaseSealed: + require.Nil(t, projection.Bisection) + require.Equal(t, parent, projection.Sealed.AgreeState) + require.Equal(t, left, projection.Sealed.FinalStateOne) + require.Equal(t, right, projection.Sealed.FinalStateTwo) + require.Equal(t, position, projection.Sealed.DivergencePosition.ToBig()) + require.Equal(t, cycle, projection.Sealed.DivergenceCycle.ToBig()) + view.Sealed.DivergencePosition.SetUint64(0) + require.Equal(t, position, projection.Sealed.DivergencePosition.ToBig()) + case MatchPhaseUninitialized: + require.Nil(t, projection.Bisection) + require.Nil(t, projection.Sealed) + } + }) + } +} + +func TestMatchProjectionRejectsInvalidPhasePayloads(t *testing.T) { + for _, view := range []ObservedMatchSnapshot{ + {Phase: unknownEnumValue}, + {Phase: MatchPhaseBisecting}, + {Phase: MatchPhaseReadyToSeal}, + {Phase: MatchPhaseSealed}, + {Phase: MatchPhaseBisecting, Bisecting: &BisectingMatch{SegmentStartPosition: big.NewInt(-1), SegmentStartCycle: new(big.Int)}}, + {Phase: MatchPhaseReadyToSeal, ReadyToSeal: &ReadyToSealMatch{SegmentStartPosition: new(big.Int), SegmentStartCycle: nil}}, + {Phase: MatchPhaseSealed, Sealed: &SealedMatch{ + DivergencePosition: new(big.Int).Lsh(big.NewInt(1), 256), DivergenceCycle: new(big.Int)}}, + {Phase: MatchPhaseBisecting, Bisecting: &BisectingMatch{}, Sealed: &SealedMatch{}}, + {Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutOneWins}, + } { + _, err := projectMatchSnapshot(view, 100, false) + require.Error(t, err) + } + _, err := projectMatchSnapshot(ObservedMatchSnapshot{Phase: MatchPhaseBisecting}, 100, true) + require.ErrorContains(t, err, "deleted match") + _, err = projectMatchSnapshot(ObservedMatchSnapshot{Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone}, 100, false) + require.ErrorContains(t, err, "no matching deletion") +} + +func TestInnerCurrentProjectionExpiresWithoutRestoringHistoricalWinner(t *testing.T) { + s, repo := newPRTServiceMock() + app, epoch := prtRevertTestApp(), checkpointEpoch(0, "0x100") + projection := &Tournament{Address: common.HexToAddress("0x101"), Level: 1, MaxLevel: 2} + adapter := &tournamentAdapterMock{} + candidate, parent, final := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + for _, block := range []uint64{100, 101, 102} { + opts := mock.MatchedBy(resultCallOptsAtBlock(block)) + standing := TournamentStanding{State: TournamentStandingInnerWinner, HasCandidate: true, Candidate: candidate, + FinalState: final, ParentCommitment: parent, FinishedAt: 90, WinnerExpiresAt: 102} + inner := InnerResult{Disposition: InnerTournamentWinner, ParentCommitment: parent, PausedAllowance: 102 - block} + if block == 102 { + standing.State, standing.FinalState, standing.ParentCommitment, standing.WinnerExpiresAt = + TournamentStandingInnerEliminableWinnerExpired, common.Hash{}, common.Hash{}, 0 + inner = InnerResult{Disposition: InnerTournamentEliminable} + } + adapter.On("Standing", opts).Return(standing, nil).Once() + adapter.On("InnerResult", opts).Return(inner, nil).Once() + adapter.On("BondRecovery", opts). + Return(canonicalBondRecovery(BondDispositionRecoverable, common.HexToAddress("0x777"), 0), nil).Once() + require.NoError(t, s.updateTournamentStanding(t.Context(), app, epoch, 1, adapter, projection, block)) + require.Equal(t, block, projection.Snapshot.AsOfBlock) + require.Equal(t, candidate, *projection.Snapshot.Candidate) + require.NotNil(t, projection.Snapshot.BondRecovery.Payment, "zero recovery amount is still present") + if block < 102 { + require.Equal(t, candidate, *projection.Snapshot.WinnerCommitment) + require.Equal(t, final, *projection.Snapshot.FinalStateHash) + require.Equal(t, parent, *projection.Snapshot.ParentCommitment) + require.Equal(t, uint64(102), projection.Snapshot.WinnerExpiresAt) + require.Equal(t, uint64(102-block), projection.Snapshot.InnerResult.PausedAllowance) + } else { + require.Nil(t, projection.Snapshot.WinnerCommitment) + require.Nil(t, projection.Snapshot.FinalStateHash) + require.Nil(t, projection.Snapshot.ParentCommitment) + require.Zero(t, projection.Snapshot.WinnerExpiresAt) + require.Equal(t, InnerTournamentEliminable, projection.Snapshot.InnerResult.Disposition) + require.Nil(t, projection.Snapshot.InnerResult.ParentCommitment) + require.Zero(t, projection.Snapshot.InnerResult.PausedAllowance) + } + } + require.Empty(t, repo.Calls) + adapter.AssertNotCalled(t, "CommitmentStanding", mock.Anything, mock.Anything) + adapter.AssertExpectations(t) +} diff --git a/internal/prt/deleted_match_projection_test.go b/internal/prt/deleted_match_projection_test.go new file mode 100644 index 000000000..e9b6a1b6f --- /dev/null +++ b/internal/prt/deleted_match_projection_test.go @@ -0,0 +1,292 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func certifiedDeletedMatch() *model.Match { + return &model.Match{ + IDHash: common.HexToHash("0xabc"), TournamentAddress: common.HexToAddress("0x100"), + CommitmentOne: common.HexToHash("0x11"), CommitmentTwo: common.HexToHash("0x22"), + LeftOfTwo: common.HexToHash("0x33"), BlockNumber: 20, TxHash: common.HexToHash("0x44"), + LogIndex: 2, EliminableAt: 30, Winner: model.WinnerCommitment_ONE, + DeletionReason: model.MatchDeletionReason_TIMEOUT, DeletionBlockNumber: 35, + DeletionTxHash: new(common.HexToHash("0x55")), DeletionLogIndex: new(uint64(3)), + Snapshot: model.MatchSnapshot{AsOfBlock: 40, Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone}, + CreatedAt: time.Unix(20, 0), UpdatedAt: time.Unix(40, 0), + } +} + +func matchDeletionEvent(match *model.Match) *itournament.ITournamentMatchDeleted { + return &itournament.ITournamentMatchDeleted{ + MatchIdHash: match.IDHash, One: match.CommitmentOne, Two: match.CommitmentTwo, Reason: 1, WinnerCommitment: 1, + Raw: types.Log{BlockNumber: match.DeletionBlockNumber, TxHash: *match.DeletionTxHash, Index: uint(*match.DeletionLogIndex)}, + } +} + +func TestTournamentEventBatchReusesCertifiedDeletionWithoutReadOrWrite(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + commitment := &model.Commitment{Commitment: stored.CommitmentOne, FinalStateHash: common.HexToHash("0x66")} + f := newEventBatchFixture(t, []*model.Commitment{commitment}, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + f.adapter.On("CommitmentStanding", mock.MatchedBy(resultCallOptsAtBlock(100)), [32]byte(commitment.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 7}, nil).Once() + + batch, err := f.project(&TournamentEvents{}) + require.NoError(t, err) + require.Empty(t, batch.Matches, "the unchanged match must not reach the repository write batch") + require.Equal(t, before, *stored, "keep the certified block, deletion facts, and timestamps") + require.Len(t, batch.Commitments, 1, "a deleted match does not make its commitments immutable") + require.Equal(t, uint64(100), batch.Commitments[0].Snapshot.AsOfBlock) + f.adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchReusesDeletionAtPublishedHead(t *testing.T) { + stored := certifiedDeletedMatch() + stored.DeletionBlockNumber, stored.Snapshot.AsOfBlock = 100, 100 + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 100 + + batch, err := f.project(&TournamentEvents{}) + require.NoError(t, err) + require.Empty(t, batch.Matches, "deletion, snapshot, cursor, and head can have the same block") + require.Equal(t, before, *stored) + f.adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchValidatesFirstDeletionAtPinnedBlock(t *testing.T) { + for _, test := range []struct { + name string + view ObservedMatchSnapshot + fail bool + }{ + {name: "deleted view", view: ObservedMatchSnapshot{ + Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone, + }}, + {name: "live view contradicts deletion", view: ObservedMatchSnapshot{Phase: model.MatchPhaseBisecting}, fail: true}, + } { + t.Run(test.name, func(t *testing.T) { + stored := certifiedDeletedMatch() + event := matchDeletionEvent(stored) + event.Raw.BlockNumber = 100 + stored.DeletionReason, stored.Winner = model.MatchDeletionReason_NOT_DELETED, model.WinnerCommitment_NONE + stored.DeletionBlockNumber, stored.DeletionTxHash, stored.DeletionLogIndex = 0, nil, nil + stored.Snapshot = model.MatchSnapshot{AsOfBlock: 40, Phase: model.MatchPhaseSealed, + Sealed: &model.MatchSealedSnapshot{}, TimeoutOutcome: model.MatchTimeoutNone} + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(test.view, nil).Once() + + batch, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + if test.fail { + require.ErrorContains(t, err, "deleted match has phase") + require.Nil(t, batch) + } else { + require.NoError(t, err) + require.Len(t, batch.Matches, 1) + require.Equal(t, uint64(100), batch.Matches[0].Snapshot.AsOfBlock) + require.Equal(t, model.MatchPhaseUninitialized, batch.Matches[0].Snapshot.Phase) + require.Equal(t, event.Raw.TxHash, *batch.Matches[0].DeletionTxHash) + } + require.Equal(t, before, *stored, "publication must not mutate the repository-owned row") + }) + } +} + +func TestTournamentEventBatchRefreshesUncertifiedDeletions(t *testing.T) { + for _, test := range []struct { + name string + change func(*model.Match, *model.Application) + }{ + {"deletion transaction is absent", func(v *model.Match, _ *model.Application) { v.DeletionTxHash = nil }}, + {"empty transaction hash", func(v *model.Match, _ *model.Application) { v.DeletionTxHash = new(common.Hash) }}, + {"missing log index", func(v *model.Match, _ *model.Application) { v.DeletionLogIndex = nil }}, + {"missing deletion block", func(v *model.Match, _ *model.Application) { v.DeletionBlockNumber = 0 }}, + {"not deleted", func(v *model.Match, _ *model.Application) { v.DeletionReason = model.MatchDeletionReason_NOT_DELETED }}, + {"deletion reason is unknown", func(v *model.Match, _ *model.Application) { v.DeletionReason = "UNKNOWN" }}, + {"deletion winner is unknown", func(v *model.Match, _ *model.Application) { v.Winner = "UNKNOWN" }}, + {"missing snapshot", func(v *model.Match, _ *model.Application) { v.Snapshot.AsOfBlock = 0 }}, + {"deletion after snapshot", func(v *model.Match, _ *model.Application) { v.DeletionBlockNumber = 41 }}, + {"snapshot after cursor", func(v *model.Match, _ *model.Application) { v.Snapshot.AsOfBlock = 51 }}, + {"head below published cursor", func(_ *model.Match, app *model.Application) { app.LastTournamentCheckBlock = 101 }}, + {"live phase", func(v *model.Match, _ *model.Application) { v.Snapshot.Phase = model.MatchPhaseBisecting }}, + {"retained timeout", func(v *model.Match, _ *model.Application) { v.Snapshot.TimeoutOutcome = model.MatchTimeoutOneWins }}, + {"retained charge", func(v *model.Match, _ *model.Application) { v.Snapshot.DeferredCharge = 1 }}, + {"retained bisection", func(v *model.Match, _ *model.Application) { v.Snapshot.Bisection = &model.MatchBisectionSnapshot{} }}, + {"retained seal", func(v *model.Match, _ *model.Application) { v.Snapshot.Sealed = &model.MatchSealedSnapshot{} }}, + } { + t.Run(test.name, func(t *testing.T) { + stored := certifiedDeletedMatch() + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + test.change(stored, f.app) + before := *stored + cause := errors.New("pinned snapshot unavailable") + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(ObservedMatchSnapshot{}, cause).Once() + + batch, err := f.project(&TournamentEvents{}) + require.ErrorIs(t, err, cause, "uncertified state must not bypass the pinned read") + require.Nil(t, batch) + require.Equal(t, before, *stored) + }) + } +} + +func TestTournamentEventBatchKeepsNewFactsForCertifiedDeletion(t *testing.T) { + const ( + matchCreationEventName = "creation" + matchAdvanceEventName = "advance" + leafSealEventName = "leaf seal" + matchDeletionEventName = "deletion" + ) + for _, eventName := range []string{matchCreationEventName, matchAdvanceEventName, leafSealEventName, matchDeletionEventName} { + t.Run(eventName, func(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + events := &TournamentEvents{} + switch eventName { + case matchCreationEventName: + events.MatchCreated = []*itournament.ITournamentMatchCreated{{ + MatchIdHash: stored.IDHash, One: stored.CommitmentOne, Two: stored.CommitmentTwo, + LeftOfTwo: stored.LeftOfTwo, EliminableAt: stored.EliminableAt, + Raw: types.Log{BlockNumber: stored.BlockNumber, TxHash: stored.TxHash, Index: uint(stored.LogIndex)}, + }} + case matchAdvanceEventName: + events.MatchAdvanced = []*itournament.ITournamentMatchAdvanced{{ + MatchIdHash: stored.IDHash, SegmentStartPosition: big.NewInt(8), + Raw: types.Log{BlockNumber: 30, TxHash: common.HexToHash("0x77"), Index: 1}, + }} + case leafSealEventName: + events.LeafMatchSealed = []*itournament.ITournamentLeafMatchSealed{{ + MatchIdHash: stored.IDHash, EliminableAt: 34, + Raw: types.Log{BlockNumber: 30, TxHash: common.HexToHash("0x77"), Index: 1}, + }} + case matchDeletionEventName: + events.MatchDeleted = []*itournament.ITournamentMatchDeleted{matchDeletionEvent(stored)} + } + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(ObservedMatchSnapshot{ + Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone, + }, nil).Once() + + batch, err := f.project(events) + require.NoError(t, err) + require.Len(t, batch.Matches, 1, "an incoming fact must not be hidden by snapshot reuse") + require.Equal(t, uint64(100), batch.Matches[0].Snapshot.AsOfBlock) + require.Equal(t, before, *stored) + if eventName == matchAdvanceEventName { + require.Len(t, batch.MatchAdvances, 1) + require.Equal(t, big.NewInt(8), batch.MatchAdvances[0].SegmentStartPosition.ToBig()) + } + if eventName == leafSealEventName { + require.NotNil(t, batch.Matches[0].LeafSeal) + require.Equal(t, uint64(34), batch.Matches[0].LeafSeal.EliminableAt) + } + }) + } +} + +func TestTournamentEventBatchRejectsConflictingCertifiedDeletion(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.app.LastTournamentCheckBlock = 50 + event := matchDeletionEvent(stored) + event.Raw.Index++ + batch, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + require.ErrorContains(t, err, "conflicting deletion events") + require.Nil(t, batch) + require.Equal(t, before, *stored) + require.Empty(t, f.adapter.Calls) +} + +func TestDeletedParentMatchStillObservesLiveChild(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + stored := certifiedDeletedMatch() + before := *stored + parent, adapter := expectDeletedMatchTournament(t, f, epoch, *epoch.TournamentAddress, 0, []*model.Match{stored}) + child, _ := expectDeletedMatchTournament(t, f, epoch, common.HexToAddress("0x101"), 1, nil) + f.children(epoch, parent, child) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(t, batches, 2) + require.Equal(t, parent.Address, batches[0].Tournament.Address) + require.Empty(t, batches[0].Matches) + require.Equal(t, child.Address, batches[1].Tournament.Address) + require.Equal(t, model.TournamentStandingMatchesActive, batches[1].Tournament.Snapshot.Standing) + require.Equal(t, uint64(100), batches[1].Tournament.Snapshot.AsOfBlock) + }).Return(nil).Once() + + _, _, err := f.s.observeApplicationTournaments(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, before, *stored) + adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func expectDeletedMatchTournament( + t *testing.T, f *observerCheckpointFixture, epoch *model.Epoch, address common.Address, level uint64, matches []*model.Match, +) (*model.Tournament, *tournamentAdapterMock) { + t.Helper() + kind := model.TournamentKindNonLeaf + if level == 1 { + kind = model.TournamentKindLeaf + } + tournament := &model.Tournament{Address: address, Level: level, MaxLevel: 2, Kind: kind, StartInstant: 10} + adapter := &tournamentAdapterMock{} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(tournament, nil).Once() + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: level, Kind: kind, StartInstant: 10, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingMatchesActive}, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, TournamentLevel(level), model.TournamentStandingMatchesActive) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 51 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + counts := zeroStructuralEventCounts() + if len(matches) != 0 { + counts.MatchCreated.SetUint64(1) + counts.MatchDeleted.SetUint64(1) + } + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(50))).Return(counts, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(counts, nil).Once() + adapter.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(50))). + Return(canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0), nil).Once() + filterAddress := address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*model.Commitment{}, uint64(0), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return(matches, uint64(len(matches)), nil).Once() + t.Cleanup(func() { adapter.AssertExpectations(t) }) + return tournament, adapter +} diff --git a/internal/prt/event_batch_test.go b/internal/prt/event_batch_test.go new file mode 100644 index 000000000..bbf134dd5 --- /dev/null +++ b/internal/prt/event_batch_test.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type eventBatchFixture struct { + *observerCheckpointFixture + epoch *Epoch + tournament *Tournament + adapter *tournamentAdapterMock +} + +func newEventBatchFixture(t *testing.T, commitments []*Commitment, matches []*Match) *eventBatchFixture { + t.Helper() + s, repo := newPRTServiceMock() + base := &observerCheckpointFixture{t: t, s: s, repo: repo, app: prtRevertTestApp()} + epoch := checkpointEpoch(0, "0x100") + f := &eventBatchFixture{observerCheckpointFixture: base, epoch: epoch, + tournament: &Tournament{Address: *epoch.TournamentAddress}, adapter: &tournamentAdapterMock{}} + address := f.tournament.Address.Hex() + repo.On("ListCommitments", mock.Anything, base.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return(commitments, uint64(len(commitments)), nil).Once() + repo.On("ListMatches", mock.Anything, base.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return(matches, uint64(len(matches)), nil).Once() + t.Cleanup(func() { repo.AssertExpectations(t); f.adapter.AssertExpectations(t) }) + return f +} + +func (f *eventBatchFixture) project(events *TournamentEvents) (*repository.TournamentEventBatch, error) { + return f.s.tournamentEventBatch(f.t.Context(), f.app, f.epoch, f.tournament, f.adapter, events, 100) +} + +func TestTournamentEventBatchKeepsFactsAndCurrentViewsSeparate(t *testing.T) { + f := newEventBatchFixture(t, nil, nil) + one, two, matchID := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + finalOne, finalTwo := common.HexToHash("0x44"), common.HexToHash("0x55") + tx := common.HexToHash("0x900") + logAt := func(index uint) types.Log { return types.Log{BlockNumber: 100, TxHash: tx, Index: index} } + large := new(big.Int).Lsh(big.NewInt(1), 200) + events := &TournamentEvents{ + CommitmentJoined: []*itournament.ITournamentCommitmentJoined{ + {Commitment: one, FinalStateHash: finalOne, Submitter: common.HexToAddress("0xa"), Raw: logAt(0)}, + {Commitment: two, FinalStateHash: finalTwo, Submitter: common.HexToAddress("0xb"), Raw: logAt(1)}, + }, + MatchCreated: []*itournament.ITournamentMatchCreated{{ + MatchIdHash: matchID, One: one, Two: two, LeftOfTwo: finalTwo, EliminableAt: 120, Raw: logAt(2), + }}, + MatchAdvanced: []*itournament.ITournamentMatchAdvanced{ + {MatchIdHash: matchID, OtherParent: finalOne, LeftNode: one, SegmentStartPosition: large, EliminableAt: 121, Raw: logAt(3)}, + {MatchIdHash: matchID, OtherParent: finalOne, LeftNode: two, + SegmentStartPosition: big.NewInt(8), EliminableAt: 122, Raw: logAt(4)}, + }, + LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{{MatchIdHash: matchID, EliminableAt: 123, Raw: logAt(5)}}, + MatchDeleted: []*itournament.ITournamentMatchDeleted{{MatchIdHash: matchID, One: one, Two: two, Reason: 1, Raw: logAt(6)}}, + PartialBondRefund: []*itournament.ITournamentPartialBondRefund{{ + Recipient: common.HexToAddress("0xc"), Value: large, Success: false, Raw: logAt(7), + }}, + BondRecovered: []*itournament.ITournamentBondRecovered{{ + Commitment: one, Claimer: common.HexToAddress("0xa"), Payment: big.NewInt(0), Burned: big.NewInt(7), Raw: logAt(8), + }}, + } + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.adapter.On("CommitmentStanding", opts, [32]byte(one)).Return(CommitmentStanding{ + Joined: true, FinalState: finalOne, ClockRunning: true, ClockDeadline: 110, ClockAllowance: 30, + }, nil).Once() + f.adapter.On("CommitmentStanding", opts, [32]byte(two)).Return(CommitmentStanding{ + Joined: true, FinalState: finalTwo, ClockAllowance: 20, + }, nil).Once() + f.adapter.On("MatchSnapshot", opts, [32]byte(one), [32]byte(two)).Return(ObservedMatchSnapshot{ + Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, + }, nil).Once() + batch, err := f.project(events) + require.NoError(t, err) + require.Len(t, batch.Commitments, 2) + require.Equal(t, common.HexToAddress("0xa"), batch.Commitments[0].SubmitterAddress) + require.Zero(t, batch.Commitments[0].Snapshot.Claimer, "recovery clears the current claimer, not the join submitter") + require.True(t, batch.Commitments[0].Snapshot.ClockRunning, "raw retained clocks are not a liveness classification") + require.Equal(t, uint64(100), batch.Commitments[0].Snapshot.AsOfBlock) + require.Len(t, batch.MatchAdvances, 2, "the same parent hash can occur in distinct events") + require.Equal(t, uint64(3), batch.MatchAdvances[0].LogIndex) + require.Equal(t, uint64(4), batch.MatchAdvances[1].LogIndex) + require.Equal(t, large, batch.MatchAdvances[0].SegmentStartPosition.ToBig()) + require.Len(t, batch.Matches, 1) + match := batch.Matches[0] + require.Equal(t, uint64(120), match.EliminableAt, "creation data must not become a later deadline") + require.Equal(t, uint64(123), match.LeafSeal.EliminableAt) + require.Equal(t, uint64(5), match.LeafSeal.LogIndex) + require.Equal(t, tx, *match.DeletionTxHash) + require.Equal(t, uint64(6), *match.DeletionLogIndex) + require.Equal(t, MatchDeletionReason_TIMEOUT, match.DeletionReason) + require.Equal(t, MatchPhaseUninitialized, match.Snapshot.Phase) + require.Nil(t, match.Snapshot.Bisection) + require.Nil(t, match.Snapshot.Sealed) + require.Len(t, batch.BondEvents, 2) + require.False(t, batch.BondEvents[0].Refund.Success) + require.Nil(t, batch.BondEvents[0].Recovery) + require.Nil(t, batch.BondEvents[1].Refund) + require.Zero(t, batch.BondEvents[1].Recovery.Payment.ToBig().Sign()) + large.SetUint64(0) + events.MatchDeleted[0].Raw.TxHash = common.Hash{} + require.Equal(t, 201, batch.MatchAdvances[0].SegmentStartPosition.ToBig().BitLen()) + require.Equal(t, 201, batch.BondEvents[0].Refund.Value.ToBig().BitLen()) + require.Equal(t, tx, *match.DeletionTxHash) + f.repo.AssertNotCalled(t, "StoreTournamentEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchRejectsUnknownOrContradictoryDeletion(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + name string + reason, winner uint8 + badPair bool + }{ + {name: "unknown reason", reason: 255}, {name: "unknown winner", winner: 3}, {name: "different pair", badPair: true}, + } { + t.Run(test.name, func(t *testing.T) { + stored := &Match{IDHash: common.HexToHash("0x33"), CommitmentOne: one, CommitmentTwo: two, + DeletionReason: MatchDeletionReason_NOT_DELETED} + before := *stored + f := newEventBatchFixture(t, nil, []*Match{stored}) + event := &itournament.ITournamentMatchDeleted{MatchIdHash: stored.IDHash, One: one, Two: two, + Reason: test.reason, WinnerCommitment: test.winner, Raw: types.Log{BlockNumber: 100}} + if test.badPair { + event.Two = one + } + _, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + require.Error(t, err) + require.Equal(t, before, *stored) + require.Empty(t, f.adapter.Calls) + }) + } +} + +func TestTournamentEventBatchReadFailuresDoNotMutateStoredRows(t *testing.T) { + for _, failed := range []string{"commitment", "match"} { + t.Run(failed, func(t *testing.T) { + commitment := &Commitment{Commitment: common.HexToHash("0x11"), FinalStateHash: common.HexToHash("0x22")} + match := &Match{IDHash: common.HexToHash("0x33"), CommitmentOne: commitment.Commitment} + beforeCommitment, beforeMatch := *commitment, *match + f := newEventBatchFixture(t, []*Commitment{commitment}, []*Match{match}) + cause := errors.New("snapshot unavailable") + var commitmentError error + if failed == "commitment" { + commitmentError = cause + } + f.adapter.On("CommitmentStanding", mock.Anything, [32]byte(commitment.Commitment)).Return( + CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 7}, commitmentError).Once() + if commitmentError == nil { + f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(match.CommitmentOne), [32]byte(match.CommitmentTwo)). + Return(ObservedMatchSnapshot{}, cause).Once() + } + _, err := f.project(&TournamentEvents{}) + require.ErrorIs(t, err, cause) + require.Equal(t, beforeCommitment, *commitment) + require.Equal(t, beforeMatch, *match) + }) + } +} + +func TestTournamentCreationEventChecksCloneStartAndKeepsIdentity(t *testing.T) { + matchID := common.HexToHash("0x11") + child := &Tournament{Address: common.HexToAddress("0x100"), ParentMatchIDHash: &matchID, StartInstant: 50} + event := &itournament.ITournamentNewInnerTournament{MatchIdHash: matchID, ChildTournament: child.Address, + Raw: types.Log{BlockNumber: 50, TxHash: common.HexToHash("0x22"), Index: 4}} + require.NoError(t, applyTournamentCreationEvent(child, event)) + require.Equal(t, uint64(4), child.CreationEvent.LogIndex) + before := *child.CreationEvent + require.NoError(t, applyTournamentCreationEvent(child, event), "an exact repeated fact is harmless") + event.Raw.Index++ + require.ErrorContains(t, applyTournamentCreationEvent(child, event), "conflicting creation events") + require.Equal(t, before, *child.CreationEvent) + event.Raw.BlockNumber++ + require.ErrorContains(t, applyTournamentCreationEvent(child, event), "descriptor does not match") +} diff --git a/internal/prt/event_filter_test.go b/internal/prt/event_filter_test.go new file mode 100644 index 000000000..5c0fe0999 --- /dev/null +++ b/internal/prt/event_filter_test.go @@ -0,0 +1,258 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "io" + "log/slog" + "maps" + "math/big" + "slices" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +// Keep expected event names independent of the production filter list. +const ( + testCommitmentJoinedEvent = "CommitmentJoined" + testLeafMatchSealedEvent = "LeafMatchSealed" +) + +func TestAllTournamentEventsFilter(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + end := uint64(20) + query, err := buildAllEventsFilterQuery(&bind.FilterOpts{Start: 10, End: &end}, address, contractABI) + require.NoError(t, err) + require.Equal(t, []common.Address{address}, query.Addresses) + require.Equal(t, uint64(10), query.FromBlock.Uint64()) + require.Equal(t, end, query.ToBlock.Uint64()) + require.Len(t, query.Topics, 1) + names := []string{testCommitmentJoinedEvent, "MatchAdvanced", "MatchCreated", "MatchDeleted", + "NewInnerTournament", testLeafMatchSealedEvent, "PartialBondRefund", "BondRecovered"} + expected := make([]common.Hash, 0, len(names)) + for _, name := range names { + expected = append(expected, contractABI.Events[name].ID) + t.Run("missing "+name, func(t *testing.T) { + incompleteABI := *contractABI + incompleteABI.Events = maps.Clone(contractABI.Events) + delete(incompleteABI.Events, name) + _, err := buildAllEventsFilterQuery(&bind.FilterOpts{}, address, &incompleteABI) + require.ErrorContains(t, err, "missing monitored event "+name) + }) + } + require.Equal(t, expected, query.Topics[0]) +} + +type tournamentLogsSource struct { + logs []types.Log +} + +func (s *tournamentLogsSource) GetLogs(context.Context, map[string]any) ([]types.Log, error) { + return s.logs, nil +} + +func TestRetrieveAllTournamentEventsParsesCurrentABI(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + one := common.HexToHash("0x11") + two := common.HexToHash("0x22") + three := common.HexToHash("0x33") + source := &tournamentLogsSource{} + large := new(big.Int).Lsh(big.NewInt(1), 200) + for index, event := range []struct { + name string + indexed []common.Hash + data []any + }{ + {name: testCommitmentJoinedEvent, indexed: []common.Hash{one, two}, data: []any{three}}, + {name: "MatchCreated", indexed: []common.Hash{one, two, three}, data: []any{one, uint64(20)}}, + {name: "MatchAdvanced", indexed: []common.Hash{one}, data: []any{two, three, large, uint64(21)}}, + {name: "MatchDeleted", indexed: []common.Hash{one, two, three}, data: []any{uint8(1), uint8(2)}}, + {name: "NewInnerTournament", indexed: []common.Hash{one, two}}, + {name: testLeafMatchSealedEvent, indexed: []common.Hash{one}, data: []any{uint64(22)}}, + {name: "PartialBondRefund", indexed: []common.Hash{two, common.HexToHash("0x01")}, data: []any{large}}, + {name: "BondRecovered", indexed: []common.Hash{one, two}, data: []any{large, big.NewInt(7)}}, + } { + definition := contractABI.Events[event.name] + data, err := definition.Inputs.NonIndexed().Pack(event.data...) + require.NoError(t, err) + source.logs = append(source.logs, types.Log{ + Address: address, BlockNumber: 10, Topics: append([]common.Hash{definition.ID}, event.indexed...), Data: data, + BlockHash: three, TxHash: two, Index: uint(index), + }) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", source)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(10) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + require.NoError(t, err) + require.Len(t, events.CommitmentJoined, 1) + require.Len(t, events.MatchCreated, 1) + require.Len(t, events.MatchAdvanced, 1) + require.Len(t, events.MatchDeleted, 1) + require.Len(t, events.NewInnerTournament, 1) + require.Len(t, events.LeafMatchSealed, 1) + require.Len(t, events.PartialBondRefund, 1) + require.Len(t, events.BondRecovered, 1) + require.Equal(t, [32]byte(one), events.CommitmentJoined[0].Commitment) + require.Equal(t, [32]byte(three), events.CommitmentJoined[0].FinalStateHash) + require.Equal(t, [32]byte(two), events.MatchCreated[0].One) + require.Equal(t, [32]byte(three), events.MatchCreated[0].Two) + require.Equal(t, [32]byte(one), events.MatchCreated[0].LeftOfTwo) + require.Equal(t, uint64(20), events.MatchCreated[0].EliminableAt) + require.Equal(t, [32]byte(two), events.MatchAdvanced[0].OtherParent) + require.Equal(t, [32]byte(three), events.MatchAdvanced[0].LeftNode) + require.Equal(t, large, events.MatchAdvanced[0].SegmentStartPosition) + require.Equal(t, uint64(21), events.MatchAdvanced[0].EliminableAt) + require.Equal(t, uint8(1), events.MatchDeleted[0].Reason) + require.Equal(t, uint8(2), events.MatchDeleted[0].WinnerCommitment) + require.Equal(t, common.BytesToAddress(two[:]), events.NewInnerTournament[0].ChildTournament) + require.Equal(t, uint64(22), events.LeafMatchSealed[0].EliminableAt) + require.Equal(t, [32]byte(one), events.LeafMatchSealed[0].MatchIdHash) + require.Equal(t, common.BytesToAddress(two[:]), events.PartialBondRefund[0].Recipient) + require.True(t, events.PartialBondRefund[0].Success) + require.Equal(t, large, events.PartialBondRefund[0].Value) + require.Equal(t, [32]byte(one), events.BondRecovered[0].Commitment) + require.Equal(t, common.BytesToAddress(two[:]), events.BondRecovered[0].Claimer) + require.Equal(t, large, events.BondRecovered[0].Payment) + require.Equal(t, big.NewInt(7), events.BondRecovered[0].Burned) + require.Equal(t, source.logs[7], events.BondRecovered[0].Raw) +} + +func TestRetrieveTournamentEventsRejectsInvalidLogs(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + definition := contractABI.Events["MatchDeleted"] + data, err := definition.Inputs.NonIndexed().Pack(uint8(1), uint8(2)) + require.NoError(t, err) + valid := types.Log{ + Address: address, BlockNumber: 10, BlockHash: common.HexToHash("0xb0"), TxHash: common.HexToHash("0xc0"), + Topics: []common.Hash{definition.ID, common.HexToHash("0x1"), common.HexToHash("0x2"), common.HexToHash("0x3")}, + Data: data, + } + for _, test := range []struct { + name string + mutate func(*types.Log) + duplicate bool + }{ + {name: "wrong address", mutate: func(log *types.Log) { log.Address = common.Address{} }}, + {name: "removed", mutate: func(log *types.Log) { log.Removed = true }}, + {name: "before range", mutate: func(log *types.Log) { log.BlockNumber = 9 }}, + {name: "after range", mutate: func(log *types.Log) { log.BlockNumber = 11 }}, + {name: "missing transaction", mutate: func(log *types.Log) { log.TxHash = common.Hash{} }}, + {name: "missing block hash", mutate: func(log *types.Log) { log.BlockHash = common.Hash{} }}, + {name: "missing topic", mutate: func(log *types.Log) { log.Topics = nil }}, + {name: "unexpected topic", mutate: func(log *types.Log) { log.Topics[0] = common.Hash{} }}, + {name: "bad payload", mutate: func(log *types.Log) { log.Data = nil }}, + {name: "unknown reason", mutate: func(log *types.Log) { log.Data[31] = 3 }}, + {name: "unknown winner", mutate: func(log *types.Log) { log.Data[63] = 3 }}, + {name: "node sentinel is not a contract reason", mutate: func(log *types.Log) { log.Data[31] = 255 }}, + {name: "maximum unknown winner", mutate: func(log *types.Log) { log.Data[63] = 255 }}, + {name: "duplicate identity", duplicate: true}, + } { + t.Run(test.name, func(t *testing.T) { + entry := valid + entry.Data = slices.Clone(valid.Data) + entry.Topics = slices.Clone(valid.Topics) + if test.mutate != nil { + test.mutate(&entry) + } + source := &tournamentLogsSource{logs: []types.Log{entry}} + if test.duplicate { + source.logs = append(source.logs, entry) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", source)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(10) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + require.Error(t, err) + require.Nil(t, events, "a rejected range must not return partial events") + }) + } +} + +func TestRetrieveTournamentEventsOrdersLogsAndChecksCrossLogIdentity(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + definition := contractABI.Events["PartialBondRefund"] + value := big.NewInt(17) + data, err := definition.Inputs.NonIndexed().Pack(value) + require.NoError(t, err) + first := types.Log{Address: address, BlockNumber: 10, BlockHash: common.HexToHash("0xb0"), TxHash: common.HexToHash("0xc0"), + Topics: []common.Hash{definition.ID, common.HexToHash("0x22"), {}}, Data: data} + second := first + second.Index = 1 + for _, test := range []struct { + name string + mutate func(*types.Log) + wantError string + }{ + {name: "same block order"}, + {name: "different block hashes", mutate: func(log *types.Log) { log.BlockHash = common.HexToHash("0xb1") }, + wantError: "disagree on the block hash"}, + {name: "same transaction identity in different blocks", mutate: func(log *types.Log) { + log.BlockNumber = 11 + log.Index = 0 + }, wantError: "transaction log identity"}, + } { + t.Run(test.name, func(t *testing.T) { + entry := second + if test.mutate != nil { + test.mutate(&entry) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", &tournamentLogsSource{logs: []types.Log{entry, first}})) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(11) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + require.Nil(t, events) + return + } + require.NoError(t, err) + require.Len(t, events.PartialBondRefund, 2) + require.Equal(t, first, events.PartialBondRefund[0].Raw) + require.Equal(t, second, events.PartialBondRefund[1].Raw) + for _, refund := range events.PartialBondRefund { + require.False(t, refund.Success) + require.Equal(t, value, refund.Value, "failed refund retains its requested amount") + } + }) + } +} diff --git a/internal/prt/fixture_test.go b/internal/prt/fixture_test.go index 24fc3fc47..19780e986 100644 --- a/internal/prt/fixture_test.go +++ b/internal/prt/fixture_test.go @@ -13,7 +13,6 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" - "github.com/cartesi/rollups-node/pkg/contracts/itournament" "github.com/cartesi/rollups-node/pkg/service" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -97,21 +96,23 @@ func (m *prtRepositoryMock) GetEpoch( return epoch, args.Error(1) } -func (m *prtRepositoryMock) UpdateEpochStatus( +func (m *prtRepositoryMock) UpdateEpochReconciledStaged( ctx context.Context, - nameOrAddress string, - e *model.Epoch, + applicationID int64, + index uint64, + stagedAtBlock uint64, ) error { - args := m.Called(ctx, nameOrAddress, e) + args := m.Called(ctx, applicationID, index, stagedAtBlock) return args.Error(0) } -func (m *prtRepositoryMock) CreateTournament( +func (m *prtRepositoryMock) UpdateEpochWithAcceptedClaim( ctx context.Context, - nameOrAddress string, - t *model.Tournament, + applicationID int64, + index uint64, + txHash *common.Hash, ) error { - args := m.Called(ctx, nameOrAddress, t) + args := m.Called(ctx, applicationID, index, txHash) return args.Error(0) } @@ -125,15 +126,6 @@ func (m *prtRepositoryMock) GetTournament( return tournament, args.Error(1) } -func (m *prtRepositoryMock) UpdateTournament( - ctx context.Context, - nameOrAddress string, - t *model.Tournament, -) error { - args := m.Called(ctx, nameOrAddress, t) - return args.Error(0) -} - func (m *prtRepositoryMock) ListTournaments( ctx context.Context, nameOrAddress string, @@ -149,13 +141,10 @@ func (m *prtRepositoryMock) ListTournaments( func (m *prtRepositoryMock) StoreTournamentEvents( ctx context.Context, appID int64, - commitments []*model.Commitment, - matches []*model.Match, - advancedMatches []*model.MatchAdvanced, - deletedMatches []*model.Match, + batches []*repository.TournamentEventBatch, blockNumber uint64, ) error { - args := m.Called(ctx, appID, commitments, matches, advancedMatches, deletedMatches, blockNumber) + args := m.Called(ctx, appID, batches, blockNumber) return args.Error(0) } @@ -171,11 +160,30 @@ func (m *prtRepositoryMock) GetCommitment( return c, args.Error(1) } +func (m *prtRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + func (m *prtRepositoryMock) SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { args := m.Called(ctx, key, rawJSON) return args.Error(0) } +func (m *prtRepositoryMock) ListCommitments(ctx context.Context, app string, filter repository.CommitmentFilter, + page repository.Pagination, descending bool) ([]*model.Commitment, uint64, error) { + args := m.Called(ctx, app, filter, page, descending) + rows, _ := args.Get(0).([]*model.Commitment) + return rows, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) ListMatches(ctx context.Context, app string, filter repository.MatchFilter, + page repository.Pagination, descending bool) ([]*model.Match, uint64, error) { + args := m.Called(ctx, app, filter, page, descending) + rows, _ := args.Get(0).([]*model.Match) + return rows, args.Get(1).(uint64), args.Error(2) +} + func (m *prtRepositoryMock) LoadNodeConfigRaw( ctx context.Context, key string, @@ -191,6 +199,12 @@ type ethClientMock struct { var _ EthClientInterface = (*ethClientMock)(nil) +func (m *ethClientMock) SuggestGasPrice(ctx context.Context) (*big.Int, error) { + args := m.Called(ctx) + price, _ := args.Get(0).(*big.Int) + return price, args.Error(1) +} + func (m *ethClientMock) TransactionReceipt( ctx context.Context, txHash common.Hash, @@ -211,6 +225,12 @@ func (m *ethClientMock) BlockNumber(ctx context.Context) (uint64, error) { return args.Get(0).(uint64), args.Error(1) } +func (m *ethClientMock) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + args := m.Called(ctx, number) + header, _ := args.Get(0).(*types.Header) + return header, args.Error(1) +} + func (m *ethClientMock) TransactionByHash( ctx context.Context, hash common.Hash, @@ -252,30 +272,54 @@ func (m *daveConsensusAdapterMock) ParseEpochSealed( return event, args.Error(1) } -func (m *daveConsensusAdapterMock) CanSettle(opts *bind.CallOpts) (CanSettleResult, error) { +func (m *daveConsensusAdapterMock) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { args := m.Called(opts) - result, _ := args.Get(0).(CanSettleResult) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *daveConsensusAdapterMock) GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CurrentSealedEpoch) return result, args.Error(1) } -func (m *daveConsensusAdapterMock) IsEpochSettled( +func (m *daveConsensusAdapterMock) CanStageTournamentResult( opts *bind.CallOpts, +) (CanStageTournamentResult, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CanStageTournamentResult) + return result, args.Error(1) +} + +func (m *daveConsensusAdapterMock) CanAcceptStagedTournamentResult( + opts *bind.CallOpts, +) (CanAcceptStagedTournamentResult, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CanAcceptStagedTournamentResult) + return result, args.Error(1) +} + +func (m *daveConsensusAdapterMock) StageTournamentResult( + opts *bind.TransactOpts, epochNumber uint64, -) (bool, error) { - args := m.Called(opts, epochNumber) - return args.Bool(0), args.Error(1) + proof model.StateProof, +) (*types.Transaction, error) { + args := m.Called(opts, epochNumber, proof) + tx, _ := args.Get(0).(*types.Transaction) + if fn, ok := args.Get(1).(func(*bind.TransactOpts, uint64, model.StateProof) error); ok { + return tx, fn(opts, epochNumber, proof) + } + return tx, args.Error(1) } -func (m *daveConsensusAdapterMock) Settle( +func (m *daveConsensusAdapterMock) AcceptStagedTournamentResult( opts *bind.TransactOpts, - epochNumber *big.Int, - outputsMerkleRoot [32]byte, - proof [][32]byte, + epochNumber uint64, ) (*types.Transaction, error) { - args := m.Called(opts, epochNumber, outputsMerkleRoot, proof) + args := m.Called(opts, epochNumber) tx, _ := args.Get(0).(*types.Transaction) - if fn, ok := args.Get(1).(func(*bind.TransactOpts, *big.Int, [32]byte, [][32]byte) error); ok { - return tx, fn(opts, epochNumber, outputsMerkleRoot, proof) + if fn, ok := args.Get(1).(func(*bind.TransactOpts, uint64) error); ok { + return tx, fn(opts, epochNumber) } return tx, args.Error(1) } @@ -286,66 +330,40 @@ type tournamentAdapterMock struct { var _ TournamentAdapter = (*tournamentAdapterMock)(nil) -func (m *tournamentAdapterMock) RetrieveCommitmentJoinedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentCommitmentJoined, error) { - args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentCommitmentJoined) - return events, args.Error(1) -} - -func (m *tournamentAdapterMock) RetrieveMatchAdvancedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchAdvanced, error) { - args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentMatchAdvanced) - return events, args.Error(1) -} - -func (m *tournamentAdapterMock) RetrieveMatchCreatedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchCreated, error) { - args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentMatchCreated) - return events, args.Error(1) -} - -func (m *tournamentAdapterMock) RetrieveMatchDeletedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchDeleted, error) { +func (m *tournamentAdapterMock) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentMatchDeleted) + events, _ := args.Get(0).(*TournamentEvents) return events, args.Error(1) } -func (m *tournamentAdapterMock) RetrieveNewInnerTournamentEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentNewInnerTournament, error) { +func (m *tournamentAdapterMock) Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) { args := m.Called(opts) - events, _ := args.Get(0).([]*itournament.ITournamentNewInnerTournament) - return events, args.Error(1) + result, _ := args.Get(0).(TournamentDescriptor) + return result, args.Error(1) } -func (m *tournamentAdapterMock) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { +func (m *tournamentAdapterMock) Standing(opts *bind.CallOpts) (TournamentStanding, error) { args := m.Called(opts) - events, _ := args.Get(0).(*TournamentEvents) - return events, args.Error(1) + result, _ := args.Get(0).(TournamentStanding) + return result, args.Error(1) } -func (m *tournamentAdapterMock) Result(opts *bind.CallOpts) (bool, [32]byte, [32]byte, error) { - args := m.Called(opts) - return args.Bool(0), args.Get(1).([32]byte), args.Get(2).([32]byte), args.Error(3) +func (m *tournamentAdapterMock) MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) { + args := m.Called(opts, one, two) + result, _ := args.Get(0).(ObservedMatchSnapshot) + return result, args.Error(1) } -func (m *tournamentAdapterMock) Constants(opts *bind.CallOpts) (TournamentConstants, error) { +func (m *tournamentAdapterMock) InnerResult(opts *bind.CallOpts) (InnerResult, error) { args := m.Called(opts) - constants, _ := args.Get(0).(TournamentConstants) - return constants, args.Error(1) + result, _ := args.Get(0).(InnerResult) + return result, args.Error(1) } -func (m *tournamentAdapterMock) TimeFinished(opts *bind.CallOpts) (bool, uint64, error) { +func (m *tournamentAdapterMock) StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) { args := m.Called(opts) - return args.Bool(0), args.Get(1).(uint64), args.Error(2) + result, _ := args.Get(0).(StructuralEventCounts) + return result, args.Error(1) } func (m *tournamentAdapterMock) BondValue(opts *bind.CallOpts) (*big.Int, error) { @@ -354,12 +372,19 @@ func (m *tournamentAdapterMock) BondValue(opts *bind.CallOpts) (*big.Int, error) return value, args.Error(1) } -func (m *tournamentAdapterMock) IsCommitmentJoined( +func (m *tournamentAdapterMock) BondRecovery(opts *bind.CallOpts) (BondRecovery, error) { + args := m.Called(opts) + recovery, _ := args.Get(0).(BondRecovery) + return recovery, args.Error(1) +} + +func (m *tournamentAdapterMock) CommitmentStanding( opts *bind.CallOpts, commitmentRoot [32]byte, -) (bool, error) { +) (CommitmentStanding, error) { args := m.Called(opts, commitmentRoot) - return args.Bool(0), args.Error(1) + result, _ := args.Get(0).(CommitmentStanding) + return result, args.Error(1) } func (m *tournamentAdapterMock) JoinTournament( @@ -377,6 +402,15 @@ func (m *tournamentAdapterMock) JoinTournament( return tx, args.Error(1) } +func (m *tournamentAdapterMock) TryRecoveringBond(opts *bind.TransactOpts) (*types.Transaction, error) { + args := m.Called(opts) + tx, _ := args.Get(0).(*types.Transaction) + if fn, ok := args.Get(1).(func(*bind.TransactOpts) error); ok { + return tx, fn(opts) + } + return tx, args.Error(1) +} + func newPRTServiceMock() (*Service, *prtRepositoryMock) { repo := &prtRepositoryMock{} s := &Service{ @@ -385,7 +419,12 @@ func newPRTServiceMock() (*Service, *prtRepositoryMock) { Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }, }, - repository: repo, + repository: repo, + pendingTransactions: map[int64]pendingTournamentTransaction{}, + disputeWarnings: map[common.Address]struct{}{}, + zeroStagingWarnings: map[int64]struct{}{}, + rootBondRecoveries: map[int64][]*rootBondRecovery{}, + observationFailures: map[int64]tournamentObservationFailure{}, } return s, repo } diff --git a/internal/prt/foreclosure_recovery_drain_test.go b/internal/prt/foreclosure_recovery_drain_test.go new file mode 100644 index 000000000..8b0a8c5b9 --- /dev/null +++ b/internal/prt/foreclosure_recovery_drain_test.go @@ -0,0 +1,138 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func TestForeclosedClaimsDrainWhileTournamentTransactionIsPending(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + app := prtForeclosedApp(1, 100) + s := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + s.submissionEnabled = true + s.queueRootBondRecovery(app.ID, 3, common.HexToAddress("0x300")) + txHash := common.HexToHash("0x400") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: action, Hash: txHash, EpochIndex: 3} + client := ðClientMock{} + s.client = client + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("TransactionByHash", mock.Anything, txHash). + Return((*types.Transaction)(nil), true, nil).Once() + repo := s.repository.(*prtRepositoryMock) + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(true, nil).Once() + repo.On("ListEpochs", mock.Anything, app.Name, + repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{{Index: 3}, {Index: 2, ClaimTransactionHash: &txHash}}, uint64(2), nil).Once() + repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(3)).Return(nil).Once() + require.NoError(t, s.handleForeclosedApp(t.Context(), app, expectEmptyForeclosedObservation(repo, app))) + require.Contains(t, s.pendingTransactions, app.ID) + require.Nil(t, s.rootBondRecoveries[app.ID][0].TxHash, "no recovery broadcast while another transaction is pending") + repo.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(2)) + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestForeclosedDrainRetainsRecoveryErrorOnEveryReturn(t *testing.T) { + const ( + observationErrorCase = "observation error" + ingestionIncompleteCase = "ingestion incomplete" + inputsPendingCase = "inputs pending" + inputQueryErrorCase = "input query error" + claimsDrainedCase = "claims drained" + claimQueryErrorCase = "claim query error" + epochQueryErrorCase = "epoch query error" + epochWriteErrorCase = "epoch write error" + shutdownCase = "shutdown" + ) + recoveryErr := errors.New("recovery provider unavailable") + drainErr := errors.New("drain failed") + for _, path := range []string{ + observationErrorCase, ingestionIncompleteCase, inputsPendingCase, inputQueryErrorCase, claimsDrainedCase, + claimQueryErrorCase, epochQueryErrorCase, epochWriteErrorCase, "claim terminalized", shutdownCase, + } { + t.Run(path, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + app := prtForeclosedApp(1, 100) + s := newRootBondTestService(common.HexToAddress("0x600"), &adapterFactoryMock{}) + s.submissionEnabled = true + s.queueRootBondRecovery(app.ID, 3, common.HexToAddress("0x300")) + client := ðClientMock{} + s.client = client + client.On("BlockNumber", mock.Anything).Run(func(mock.Arguments) { + if path == shutdownCase { + cancel() + } + }).Return(uint64(0), recoveryErr).Once() + repo := s.repository.(*prtRepositoryMock) + var observation func() (uint64, error) + if path == observationErrorCase { + observation = func() (uint64, error) { return 0, drainErr } + } else { + observation = expectEmptyForeclosedObservation(repo, app) + } + if path == ingestionIncompleteCase { + app.LastInputCheckBlock = 99 + } + if path != observationErrorCase && path != ingestionIncompleteCase && path != shutdownCase { + var inputErr error + if path == inputQueryErrorCase { + inputErr = drainErr + } + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock). + Return(path == inputsPendingCase, inputErr).Once() + if path != inputsPendingCase && inputErr == nil { + var claimErr error + if path == claimQueryErrorCase { + claimErr = drainErr + } + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock). + Return(path != claimsDrainedCase, claimErr).Once() + if path != claimsDrainedCase && claimErr == nil { + var epochErr error + if path == epochQueryErrorCase { + epochErr = drainErr + } + repo.On("ListEpochs", mock.Anything, app.Name, + repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{{Index: 3}}, uint64(1), epochErr).Once() + if epochErr == nil { + if path == epochWriteErrorCase { + epochErr = drainErr + } + repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(3)).Return(epochErr).Once() + } + } + } + } + + err := s.handleForeclosedApp(ctx, app, observation) + require.ErrorIs(t, err, recoveryErr) + if path == shutdownCase { + require.ErrorIs(t, err, context.Canceled) + } + if path == observationErrorCase || path == inputQueryErrorCase || path == claimQueryErrorCase || + path == epochQueryErrorCase || path == epochWriteErrorCase { + require.ErrorIs(t, err, drainErr) + } + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/handle_foreclosed_test.go b/internal/prt/handle_foreclosed_test.go index 2be4a46ae..8f41b0f0b 100644 --- a/internal/prt/handle_foreclosed_test.go +++ b/internal/prt/handle_foreclosed_test.go @@ -4,13 +4,18 @@ package prt import ( + "bytes" "context" "errors" + "log/slog" + "math/big" "testing" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -35,6 +40,14 @@ func prtForeclosedApp(id int64, block uint64) *model.Application { } } +// An empty root query still proves that passive observation ran. It must +// include all tournament-bearing epochs, not only particular claim states. +func expectEmptyForeclosedObservation(r *prtRepositoryMock, app *model.Application) func() (uint64, error) { + r.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch{}, uint64(0), nil).Once() + return func() (uint64, error) { return 120, nil } +} + // TestHandleForeclosedApp_NoOpWhenForecloseBlockZero verifies the guard at // the top of handleForeclosedApp. The PRT Tick passes every running app // through this function; only those with a non-zero ForecloseBlock should @@ -44,26 +57,182 @@ func TestHandleForeclosedApp_NoOpWhenForecloseBlockZero(t *testing.T) { defer r.AssertExpectations(t) app := &model.Application{ID: 1, ConsensusType: model.Consensus_PRT} - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + require.NoError(t, s.handleForeclosedApp(context.Background(), app, nil)) +} + +func TestHandleForeclosedAppRecoversQueuedRootBond(t *testing.T) { + app := prtForeclosedApp(1, 100) + app.IConsensusAddress = common.HexToAddress("0x200") + owned := common.HexToAddress("0x600") + tournamentAddress := common.HexToAddress("0x300") + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.submissionEnabled = true + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + acceptTx := common.HexToHash("0x400") + service.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionAccept, Hash: acceptTx, EpochIndex: 3, + } + client := ðClientMock{} + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("TransactionByHash", mock.Anything, acceptTx). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, TxHash: acceptTx, BlockNumber: big.NewInt(119), + }, nil).Once() + service.client = client + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + service.repository.(*prtRepositoryMock).On( + "HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Twice() + + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + client.On("BlockNumber", mock.Anything).Return(uint64(121), nil).Once() + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.NotNil(t, service.rootBondRecoveries[app.ID][0].TxHash) + factory.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + client.AssertExpectations(t) +} + +func TestHandleForeclosedAppReconcilesInFlightRootBondRecovery(t *testing.T) { + app := prtForeclosedApp(1, 100) + owned := common.HexToAddress("0x600") + tournamentAddress := common.HexToAddress("0x300") + recoveryTx := common.HexToHash("0x400") + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.submissionEnabled = true + service.rootBondRecoveries[app.ID] = []*rootBondRecovery{{ + EpochIndex: 3, + Tournament: tournamentAddress, + TxHash: &recoveryTx, + }} + client := ðClientMock{} + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("TransactionByHash", mock.Anything, recoveryTx). + Return(types.NewTx(&types.LegacyTx{}), false, nil).Once() + client.On("TransactionReceipt", mock.Anything, recoveryTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: recoveryTx, + BlockNumber: big.NewInt(119), + }, nil).Once() + service.client = client + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + service.repository.(*prtRepositoryMock).On( + "HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.Empty(t, service.rootBondRecoveries[app.ID]) + factory.AssertExpectations(t) + tournament.AssertExpectations(t) + client.AssertExpectations(t) } -func TestGetAllRunningApplications_UsesPRTTickFilter(t *testing.T) { +func TestHandleForeclosedAppReconcilesJoinBeforeQueuedRootBond(t *testing.T) { + for _, pending := range []bool{true, false} { + name := "mined" + if pending { + name = "pending" + } + t.Run(name, func(t *testing.T) { + app := prtForeclosedApp(1, 100) + app.IConsensusAddress = common.HexToAddress("0x200") + owned := common.HexToAddress("0x600") + tournamentAddress := common.HexToAddress("0x300") + joinTx := common.HexToHash("0x400") + factory := &adapterFactoryMock{} + service := newRootBondTestService(owned, factory) + service.submissionEnabled = true + service.queueRootBondRecovery(app.ID, 3, tournamentAddress) + service.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionJoin, Hash: joinTx, EpochIndex: 3, + } + client := ðClientMock{} + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("TransactionByHash", mock.Anything, joinTx). + Return(types.NewTx(&types.LegacyTx{}), pending, nil).Once() + service.client = client + + if pending { + service.repository.(*prtRepositoryMock).On( + "HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.Contains(t, service.pendingTransactions, app.ID) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + } else { + client.On("TransactionReceipt", mock.Anything, joinTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, TxHash: joinTx, BlockNumber: big.NewInt(119), + }, nil).Once() + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.Anything). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + service.repository.(*prtRepositoryMock).On( + "HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Twice() + + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.NotContains(t, service.pendingTransactions, app.ID) + require.Nil(t, service.rootBondRecoveries[app.ID][0].TxHash) + factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + client.On("BlockNumber", mock.Anything).Return(uint64(121), nil).Once() + require.NoError(t, service.handleForeclosedApp(context.Background(), app, + expectEmptyForeclosedObservation(service.repository.(*prtRepositoryMock), app))) + require.NotNil(t, service.rootBondRecoveries[app.ID][0].TxHash) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + } + service.repository.(*prtRepositoryMock).AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestGetObservableApplications_IncludesUnhealthyApps(t *testing.T) { r := &prtRepositoryMock{} r.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { return f.Enabled != nil && *f.Enabled && f.ConsensusType != nil && *f.ConsensusType == model.Consensus_PRT && - assert.ElementsMatch(t, - []model.ApplicationStatus{model.ApplicationStatus_OK}, - f.Statuses, - ) + len(f.Statuses) == 0 }), repository.Pagination{}, false, ).Return([]*model.Application{}, uint64(0), nil).Once() - _, _, err := getAllRunningApplications(context.Background(), r) + _, _, err := getObservableApplications(context.Background(), r) require.NoError(t, err) r.AssertExpectations(t) } @@ -72,7 +241,7 @@ func TestGetAllRunningApplications_UsesPRTTickFilter(t *testing.T) { // pre-foreclosure-work guard. While the advancer/validator have epochs to // process before the foreclose block, the PRT app must keep its current // status. Marking it terminal early would lose the last machine state needed -// to settle any in-flight tournament. +// to process its last pre-foreclosure epoch. func TestHandleForeclosedApp_DefersWhenUndrained(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) @@ -84,20 +253,12 @@ func TestHandleForeclosedApp_DefersWhenUndrained(t *testing.T) { // No UpdateApplicationStatus expectation — see TestProcessForeclosedApps_DefersWhenUndrained // in the claimer suite for the equivalent reasoning. - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + require.NoError(t, s.handleForeclosedApp(context.Background(), app, expectEmptyForeclosedObservation(r, app))) } -// TestHandleForeclosedApp_NoOpWhenFullyDrained verifies that once every -// pre-foreclosure epoch is terminal, handleForeclosedApp is a no-op: it does -// not reconcile (no chain reads), does not foreclose, and does not touch the -// application status. The app keeps health status OK with foreclose_block set; -// evmreader picks up the post-foreclosure observation work from here. -// -// The mock registers no UpdateApplicationStatus / ListEpochs / -// UpdateEpochWithForeclosedClaim expectation; testify/mock fails the test on an -// unexpected call, so any regression that re-runs drain work on an already -// terminal app trips this test loudly. -func TestHandleForeclosedApp_NoOpWhenFullyDrained(t *testing.T) { +// A completed local drain must not stop passive tournament observation. With +// no roots, this pass only queries eligibility and checks the existing gates. +func TestHandleForeclosedApp_ObservesAfterLocalDrain(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) @@ -109,7 +270,7 @@ func TestHandleForeclosedApp_NoOpWhenFullyDrained(t *testing.T) { mock.Anything, app.ID, app.ForecloseBlock, ).Return(false, nil).Once() - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + require.NoError(t, s.handleForeclosedApp(context.Background(), app, expectEmptyForeclosedObservation(r, app))) } // TestHandleForeclosedApp_SurfacesDrainCheckError verifies the surrounding @@ -126,7 +287,7 @@ func TestHandleForeclosedApp_SurfacesDrainCheckError(t *testing.T) { mock.Anything, app.ID, app.ForecloseBlock, ).Return(false, dbErr).Once() - err := s.handleForeclosedApp(context.Background(), app) + err := s.handleForeclosedApp(context.Background(), app, expectEmptyForeclosedObservation(r, app)) require.Error(t, err) assert.ErrorIs(t, err, dbErr) } @@ -144,35 +305,33 @@ func TestHandleForeclosedApp_SurfacesDrainCheckError(t *testing.T) { // expectation registered; testify/mock panics on an unexpected call, so // either reach attempt fails the test loudly. func TestHandleForeclosedApp_DefersWhenStillBackfilling(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - - app := prtForeclosedApp(1, 100) - app.LastEpochCheckBlock = 50 // scanner is well below the foreclose block - - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) -} - -// TestHandleForeclosedApp_ProceedsAfterBackfillCatchesUp verifies the -// guard does not over-defer. Once LastEpochCheckBlock reaches the -// foreclose block, the gate is consulted normally; on a "drained=false" -// response the function returns nil silently (no terminal action — see -// TestHandleForeclosedApp_NoTransitionWhenDrained). -func TestHandleForeclosedApp_ProceedsAfterBackfillCatchesUp(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - - app := prtForeclosedApp(1, 100) - app.LastEpochCheckBlock = app.ForecloseBlock // exact-boundary case: caught up - - r.On("HasUndrainedEpochsBeforeBlock", - mock.Anything, app.ID, app.ForecloseBlock, - ).Return(false, nil).Once() - r.On("HasUnreconciledClaimsBeforeBlock", - mock.Anything, app.ID, app.ForecloseBlock, - ).Return(false, nil).Once() - - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + for _, test := range []struct { + name string + epochCursor uint64 + inputCursor uint64 + }{ + {name: "sealed epochs still landing", epochCursor: 50, inputCursor: 100}, + {name: "same-block open inputs still landing", epochCursor: 100, inputCursor: 99}, + } { + t.Run(test.name, func(t *testing.T) { + s, r := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := prtForeclosedApp(1, 100) + app.LastEpochCheckBlock = test.epochCursor + app.LastInputCheckBlock = test.inputCursor + + require.NoError(t, s.handleForeclosedApp(t.Context(), app, expectEmptyForeclosedObservation(r, app))) + require.Contains(t, output.String(), "sealed epochs and inputs") + require.Contains(t, output.String(), "last_epoch_check_block="+new(big.Int).SetUint64(test.epochCursor).String()) + require.Contains(t, output.String(), "last_input_check_block="+new(big.Int).SetUint64(test.inputCursor).String()) + require.Contains(t, output.String(), "foreclose_block=100") + r.AssertNotCalled(t, "HasUndrainedEpochsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + r.AssertNotCalled(t, "HasUnreconciledClaimsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) + }) + } } // TestHandleForeclosedApp_SurfacesReconciliationCheckError verifies the @@ -191,25 +350,25 @@ func TestHandleForeclosedApp_SurfacesReconciliationCheckError(t *testing.T) { mock.Anything, app.ID, app.ForecloseBlock, ).Return(false, dbErr).Once() - err := s.handleForeclosedApp(context.Background(), app) + err := s.handleForeclosedApp(context.Background(), app, expectEmptyForeclosedObservation(r, app)) require.Error(t, err) assert.ErrorIs(t, err, dbErr) } // TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass models -// the race where checkEpochs takes its CLAIM_COMPUTED snapshot before the -// validator computes a later epoch, but forecloseComputedEpochs sees that later -// epoch in the same tick. If the epoch already has an on-chain EpochSealed -// transaction hash, it must stay CLAIM_COMPUTED for the next reconciliation -// pass instead of being terminalized to CLAIM_FORECLOSED. +// an epoch that appears between the observation query and the local drain +// query. An on-chain EpochSealed transaction hash requires reconciliation on +// the next pass; the local drain must not mark that epoch CLAIM_FORECLOSED. func TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) app := prtForeclosedApp(1, 100) client := ðClientMock{} - client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(120)}, nil).Once() s.client = client + s.defaultBlock = model.DefaultBlock_Finalized claimTx := common.HexToHash("0xbeef") r.On("HasUndrainedEpochsBeforeBlock", @@ -219,23 +378,25 @@ func TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass(t *test mock.Anything, app.ID, app.ForecloseBlock, ).Return(true, nil).Once() r.On("ListEpochs", - mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false, + mock.Anything, app.Name, repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false, ).Return([]*model.Epoch{}, uint64(0), nil).Once() r.On("ListEpochs", - mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false, + mock.Anything, app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false, ).Return([]*model.Epoch{ {Index: 1, ClaimTransactionHash: &claimTx}, }, uint64(1), nil).Once() - require.NoError(t, s.handleForeclosedApp(context.Background(), app)) + require.NoError(t, s.handleForeclosedApp(context.Background(), app, func() (uint64, error) { + return s.getDefaultBlockNumber(context.Background()) + })) r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(1)) client.AssertExpectations(t) } -// TestForecloseComputedEpochs_TerminalizesEachComputedEpoch verifies the -// foreclose step: every CLAIM_COMPUTED epoch without an on-chain sealed-event +// TestForeclosePendingClaimEpochs_TerminalizesEachPendingEpoch verifies the +// foreclose step: every pending claim epoch without an on-chain sealed-event // transaction is transitioned to CLAIM_FORECLOSED. -func TestForecloseComputedEpochs_TerminalizesEachComputedEpoch(t *testing.T) { +func TestForeclosePendingClaimEpochs_TerminalizesEachPendingEpoch(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) @@ -243,25 +404,37 @@ func TestForecloseComputedEpochs_TerminalizesEachComputedEpoch(t *testing.T) { claimTx := common.HexToHash("0xbeef") r.On("ListEpochs", mock.Anything, app.Name, - mock.MatchedBy(func(f repository.EpochFilter) bool { - return len(f.Status) == 1 && f.Status[0] == model.EpochStatus_ClaimComputed - }), + repository.EpochFilter{Status: []model.EpochStatus{ + model.EpochStatus_Open, model.EpochStatus_Closed, model.EpochStatus_InputsProcessed, + model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged, + }}, repository.Pagination{}, false, ).Return([]*model.Epoch{ + {Index: 0, Status: model.EpochStatus_Open, FirstBlock: 100, LastBlock: 150}, + {Index: 1, Status: model.EpochStatus_Closed}, + {Index: 2, Status: model.EpochStatus_InputsProcessed}, {Index: 3}, - {Index: 4}, + {Index: 4, Status: model.EpochStatus_ClaimStaged}, {Index: 5, ClaimTransactionHash: &claimTx}, - }, uint64(3), nil).Once() + {Index: 6, Status: model.EpochStatus_ClaimSubmitted}, + {Index: 7, Status: model.EpochStatus_Open, FirstBlock: 101}, + }, uint64(8), nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(0)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(1)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(2)).Return(nil).Once() r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(3)).Return(nil).Once() r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(4)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(6)).Return(nil).Once() - require.NoError(t, s.forecloseComputedEpochs(context.Background(), app)) + require.NoError(t, s.foreclosePendingClaimEpochs(context.Background(), app)) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(5)) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(7)) } -// TestForecloseComputedEpochs_PropagatesUpdateError verifies a failed +// TestForeclosePendingClaimEpochs_PropagatesUpdateError verifies a failed // terminalization surfaces so the Tick retries rather than silently dropping // a still-non-terminal epoch. -func TestForecloseComputedEpochs_PropagatesUpdateError(t *testing.T) { +func TestForeclosePendingClaimEpochs_PropagatesUpdateError(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) @@ -271,7 +444,7 @@ func TestForecloseComputedEpochs_PropagatesUpdateError(t *testing.T) { Return([]*model.Epoch{{Index: 7}}, uint64(1), nil).Once() r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(7)).Return(dbErr).Once() - err := s.forecloseComputedEpochs(context.Background(), app) + err := s.foreclosePendingClaimEpochs(context.Background(), app) require.Error(t, err) assert.ErrorIs(t, err, dbErr) } diff --git a/internal/prt/idaveconsensus_adapter.go b/internal/prt/idaveconsensus_adapter.go index af9913d65..6eb7d9e90 100644 --- a/internal/prt/idaveconsensus_adapter.go +++ b/internal/prt/idaveconsensus_adapter.go @@ -4,19 +4,25 @@ package prt import ( + "errors" + "fmt" "math/big" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/imultileveltournamentfactory" ) // DaveConsensusAdapterImpl wraps the generated IDaveConsensus binding. type DaveConsensusAdapterImpl struct { consensus *idaveconsensus.IDaveConsensus + client *ethclient.Client } // NewDaveConsensusAdapter creates a new DaveConsensusAdapter backed by the IDaveConsensus contract. @@ -28,42 +34,142 @@ func NewDaveConsensusAdapter( if err != nil { return nil, err } - return &DaveConsensusAdapterImpl{consensus: consensus}, nil + return &DaveConsensusAdapterImpl{consensus: consensus, client: client}, nil } func (a *DaveConsensusAdapterImpl) ParseEpochSealed(log types.Log) (*idaveconsensus.IDaveConsensusEpochSealed, error) { return a.consensus.ParseEpochSealed(log) } -func (a *DaveConsensusAdapterImpl) CanSettle(opts *bind.CallOpts) (CanSettleResult, error) { - result, err := a.consensus.CanSettle(opts) +func (a *DaveConsensusAdapterImpl) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { + factoryAddress, err := a.consensus.GetTournamentFactory(opts) + if err != nil { + return 0, err + } + factory, err := imultileveltournamentfactory.NewIMultiLevelTournamentFactory(factoryAddress, a.client) + if err != nil { + return 0, err + } + levelCount, err := factory.TournamentLevelCount(opts) + if err != nil { + return 0, err + } + if levelCount == 0 { + return 0, errors.New("tournament level count is zero") + } + return levelCount, nil +} + +func checkedUint64(value *big.Int, field string) (uint64, error) { + if value == nil || value.Sign() < 0 || !value.IsUint64() { + return 0, fmt.Errorf("%s is not a uint64", field) + } + return value.Uint64(), nil +} + +func (a *DaveConsensusAdapterImpl) GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) { + result, err := a.consensus.GetCurrentSealedEpoch(opts) + if err != nil { + return CurrentSealedEpoch{}, err + } + epoch, err := checkedUint64(result.EpochNumber, "sealed epoch number") + if err != nil { + return CurrentSealedEpoch{}, err + } + lowerBound, err := checkedUint64(result.InputIndexLowerBound, "sealed epoch input lower bound") + if err != nil { + return CurrentSealedEpoch{}, err + } + upperBound, err := checkedUint64(result.InputIndexUpperBound, "sealed epoch input upper bound") if err != nil { - return CanSettleResult{}, err + return CurrentSealedEpoch{}, err } - return CanSettleResult{ - IsFinished: result.IsFinished, - EpochNumber: result.EpochNumber, - WinnerCommitment: result.WinnerCommitment, + stagingBlock, err := checkedUint64(result.StagingBlockNumber, "sealed epoch staging block") + if err != nil { + return CurrentSealedEpoch{}, err + } + return CurrentSealedEpoch{ + EpochNumber: epoch, + InputIndexLowerBound: lowerBound, + InputIndexUpperBound: upperBound, + Tournament: result.Tournament, + IsTournamentResultStaged: result.IsTournamentResultStaged, + StagingBlockNumber: stagingBlock, + StagedPostEpochMachineStateHash: result.StagedPostEpochMachineStateHash, + StagedPostEpochOutputsMerkleRoot: result.StagedPostEpochOutputsMerkleRoot, + }, nil +} + +func (a *DaveConsensusAdapterImpl) CanStageTournamentResult( + opts *bind.CallOpts, +) (CanStageTournamentResult, error) { + result, err := a.consensus.CanStageTournamentResult(opts) + if err != nil { + return CanStageTournamentResult{}, err + } + epoch, err := checkedUint64(result.EpochNumber, "stage readiness epoch number") + if err != nil { + return CanStageTournamentResult{}, err + } + return CanStageTournamentResult{ + IsFinished: result.IsFinished, + IsTournamentFailed: result.IsTournamentFailed, + IsTournamentResultStaged: result.IsTournamentResultStaged, + EpochNumber: epoch, + WinnerCommitment: result.WinnerCommitment, + WinnerPostEpochMachineStateHash: result.WinnerPostEpochMachineStateHash, }, nil } -// IsEpochSettled checks on-chain whether an epoch has already been settled by -// comparing the given epoch number against the current sealed epoch. If the -// sealed epoch has advanced past it, the settlement was already performed. -// This prevents duplicate Settle calls after a node restart. -func (a *DaveConsensusAdapterImpl) IsEpochSettled( - opts *bind.CallOpts, epochNumber uint64, -) (bool, error) { - sealed, err := a.consensus.GetCurrentSealedEpoch(opts) +func (a *DaveConsensusAdapterImpl) CanAcceptStagedTournamentResult( + opts *bind.CallOpts, +) (CanAcceptStagedTournamentResult, error) { + result, err := a.consensus.CanAcceptStagedTournamentResult(opts) if err != nil { - return false, err + return CanAcceptStagedTournamentResult{}, err } - return sealed.EpochNumber.Uint64() > epochNumber, nil + epoch, err := checkedUint64(result.EpochNumber, "accept readiness epoch number") + if err != nil { + return CanAcceptStagedTournamentResult{}, err + } + return CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: result.IsTournamentResultStaged, + DoAllSentriesAgreeWithStagedTournamentResult: result.DoAllSentriesAgreeWithStagedTournamentResult, + IsClaimStagingPeriodOver: result.IsClaimStagingPeriodOver, + EpochNumber: epoch, + StagedPostEpochMachineStateHash: result.StagedPostEpochMachineStateHash, + StagedPostEpochOutputsMerkleRoot: result.StagedPostEpochOutputsMerkleRoot, + }, nil +} + +func daveMachineValidityProof(proof StateProof) idaveconsensus.MachineValidityProof { + return idaveconsensus.MachineValidityProof{ + IflagsYProof: idaveconsensus.LeafProof{ + DataBlock: proof.IflagsYDataBlock, + Siblings: append([][32]byte(nil), proof.IflagsYProof...), + }, + HtifTohostProof: idaveconsensus.LeafProof{ + DataBlock: proof.HtifTohostDataBlock, + Siblings: append([][32]byte(nil), proof.HtifTohostProof...), + }, + TxBufferProof: idaveconsensus.LeafProof{ + DataBlock: proof.TxBufferDataBlock, + Siblings: append([][32]byte(nil), proof.TxBufferProof...), + }, + } +} + +func (a *DaveConsensusAdapterImpl) StageTournamentResult( + opts *bind.TransactOpts, + epochNumber uint64, + proof StateProof, +) (*types.Transaction, error) { + return a.consensus.StageTournamentResult(opts, new(big.Int).SetUint64(epochNumber), daveMachineValidityProof(proof)) } -func (a *DaveConsensusAdapterImpl) Settle( - opts *bind.TransactOpts, epochNumber *big.Int, - outputsMerkleRoot [32]byte, proof [][32]byte, +func (a *DaveConsensusAdapterImpl) AcceptStagedTournamentResult( + opts *bind.TransactOpts, + epochNumber uint64, ) (*types.Transaction, error) { - return a.consensus.Settle(opts, epochNumber, outputsMerkleRoot, proof) + return a.consensus.AcceptStagedTournamentResult(opts, new(big.Int).SetUint64(epochNumber)) } diff --git a/internal/prt/idaveconsensus_adapter_test.go b/internal/prt/idaveconsensus_adapter_test.go new file mode 100644 index 000000000..73608d94b --- /dev/null +++ b/internal/prt/idaveconsensus_adapter_test.go @@ -0,0 +1,101 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type daveConsensusViewRPC struct{ mock.Mock } + +func (m *daveConsensusViewRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +func TestDaveConsensusAdapterDecodesContractViews(t *testing.T) { + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x100") + tournament := common.HexToAddress("0x11") + winner := common.HexToHash("0x22") + machineHash := common.HexToHash("0x33") + outputsRoot := common.HexToHash("0x44") + block := big.NewInt(20) + + for i, name := range []string{"first flag", "second flag", "third flag"} { + t.Run(name, func(t *testing.T) { + var flags [3]bool + flags[i] = true + backend := &daveConsensusViewRPC{} + for _, view := range []struct { + method string + values []any + }{ + {"getCurrentSealedEpoch", []any{ + big.NewInt(1), big.NewInt(2), big.NewInt(3), tournament, flags[0], big.NewInt(4), machineHash, outputsRoot, + }}, + {"canStageTournamentResult", []any{flags[0], flags[1], flags[2], big.NewInt(5), winner, machineHash}}, + {"canAcceptStagedTournamentResult", []any{flags[0], flags[1], flags[2], big.NewInt(6), machineHash, outputsRoot}}, + } { + method := contractABI.Methods[view.method] + encoded, err := method.Outputs.Pack(view.values...) + require.NoError(t, err) + backend.On("Call", address, method.ID, rpc.BlockNumber(block.Int64())).Return(encoded, nil).Once() + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewDaveConsensusAdapter(address, client) + require.NoError(t, err) + opts := &bind.CallOpts{Context: t.Context(), BlockNumber: block} + + sealed, err := adapter.GetCurrentSealedEpoch(opts) + require.NoError(t, err) + require.Equal(t, CurrentSealedEpoch{ + EpochNumber: 1, InputIndexLowerBound: 2, InputIndexUpperBound: 3, Tournament: tournament, + IsTournamentResultStaged: flags[0], StagingBlockNumber: 4, + StagedPostEpochMachineStateHash: machineHash, StagedPostEpochOutputsMerkleRoot: outputsRoot, + }, sealed) + stage, err := adapter.CanStageTournamentResult(opts) + require.NoError(t, err) + require.Equal(t, CanStageTournamentResult{ + IsFinished: flags[0], IsTournamentFailed: flags[1], IsTournamentResultStaged: flags[2], + EpochNumber: 5, WinnerCommitment: winner, WinnerPostEpochMachineStateHash: machineHash, + }, stage) + accept, err := adapter.CanAcceptStagedTournamentResult(opts) + require.NoError(t, err) + require.Equal(t, CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: flags[0], DoAllSentriesAgreeWithStagedTournamentResult: flags[1], + IsClaimStagingPeriodOver: flags[2], EpochNumber: 6, + StagedPostEpochMachineStateHash: machineHash, StagedPostEpochOutputsMerkleRoot: outputsRoot, + }, accept) + backend.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/itournament_adapter.go b/internal/prt/itournament_adapter.go index 0c08f1ff3..ee4c8739f 100644 --- a/internal/prt/itournament_adapter.go +++ b/internal/prt/itournament_adapter.go @@ -4,9 +4,13 @@ package prt import ( + "cmp" + "errors" + "fmt" "math/big" + "slices" - . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/contracts/itournament" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -15,9 +19,16 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" ) +var ( + errInvalidBondRecovery = errors.New("invalid tournament bond recovery state") + errTournamentMatchPhaseChanged = errors.New("tournament match phase changed within pinned observation") + errUnpinnedTournamentRead = errors.New("tournament observation requires a pinned block") +) + // ITournament Wrapper type ITournamentAdapterImpl struct { tournament *itournament.ITournament @@ -43,246 +54,511 @@ func NewITournamentAdapter( }, nil } -func (a *ITournamentAdapterImpl) Result(opts *bind.CallOpts) (bool, [32]byte, [32]byte, error) { - result, err := a.tournament.ArbitrationResult(opts) - // ArbitrationResult reverts with TournamentFailedNoWinner when the - // tournament finished with every participant eliminated. - if isTournamentError(err, "TournamentFailedNoWinner") { - return true, [32]byte{}, [32]byte{}, nil +func (a *ITournamentAdapterImpl) Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) { + descriptor, err := a.tournament.TournamentDescriptor(opts) + if err != nil { + return TournamentDescriptor{}, err } - return result.Finished, result.WinnerCommitment, result.FinalState, err + return tournamentDescriptorFromBinding(descriptor) } -func (a *ITournamentAdapterImpl) Constants(opts *bind.CallOpts) (TournamentConstants, error) { - c, err := a.tournament.TournamentLevelConstants(opts) - return TournamentConstants{ - MaxLevel: c.MaxLevel, - Level: c.Level, - Log2step: c.Log2step, - Height: c.Height, - }, err +func tournamentDescriptorFromBinding(descriptor itournament.ITournamentTournamentDescriptor) (TournamentDescriptor, error) { + kind, err := tournamentEnum("kind", descriptor.Kind, model.TournamentKindLeaf, model.TournamentKindNonLeaf) + if err != nil { + return TournamentDescriptor{}, err + } + baseCycle, err := tournamentUint256("base cycle", descriptor.BaseCycle) + if err != nil { + return TournamentDescriptor{}, err + } + return TournamentDescriptor{ + InitialHash: descriptor.InitialHash, + BaseCycle: baseCycle, + Log2Stride: descriptor.Log2Stride, + Height: descriptor.Height, + Level: descriptor.Level, + Kind: kind, + StartInstant: descriptor.StartInstant, + Allowance: descriptor.Allowance, + }, nil } -func (a *ITournamentAdapterImpl) TimeFinished(opts *bind.CallOpts) (bool, uint64, error) { - return a.tournament.TimeFinished(opts) +func (a *ITournamentAdapterImpl) Standing(opts *bind.CallOpts) (TournamentStanding, error) { + standing, err := a.tournament.TournamentStanding(opts) + if err != nil { + return TournamentStanding{}, err + } + return tournamentStandingFromBinding(standing) } -func (a *ITournamentAdapterImpl) BondValue(opts *bind.CallOpts) (*big.Int, error) { - return a.tournament.BondValue(opts) +func tournamentStandingFromBinding(standing itournament.ITournamentTournamentStandingView) (TournamentStanding, error) { + state, err := tournamentEnum("standing", standing.Standing, + model.TournamentStandingMatchesActive, model.TournamentStandingAwaitingClosure, + model.TournamentStandingRootWinner, model.TournamentStandingRootFailed, model.TournamentStandingInnerWinner, + model.TournamentStandingInnerEliminableNoWinner, model.TournamentStandingInnerEliminableWinnerExpired) + if err != nil { + return TournamentStanding{}, err + } + return TournamentStanding{ + State: state, + AcceptsJoins: standing.AcceptsJoins, + HasCandidate: standing.HasCandidate, + Candidate: standing.Candidate, + FinalState: standing.FinalState, + ParentCommitment: standing.ParentCommitment, + FinishedAt: standing.FinishedAt, + WinnerExpiresAt: standing.WinnerExpiresAt, + }, nil } -// IsCommitmentJoined checks on-chain whether a commitment has already been -// joined to this tournament. It calls the contract's getCommitment method -// and checks if the returned finalState is non-zero (indicating the commitment -// exists). This prevents duplicate JoinTournament calls after a node restart. -func (a *ITournamentAdapterImpl) IsCommitmentJoined( - opts *bind.CallOpts, commitmentRoot [32]byte, -) (bool, error) { - result, err := a.tournament.GetCommitment(opts, commitmentRoot) +func (a *ITournamentAdapterImpl) BondValue(opts *bind.CallOpts) (*big.Int, error) { + value, err := a.tournament.BondValue(opts) if err != nil { - return false, err + return nil, err } - return result.FinalState != [32]byte{}, nil + return tournamentUint256("bond value", value) } -func (a *ITournamentAdapterImpl) JoinTournament( - opts *bind.TransactOpts, finalState [32]byte, proof [][32]byte, - leftNode [32]byte, rightNode [32]byte, -) (*types.Transaction, error) { - return a.tournament.JoinTournament(opts, finalState, proof, leftNode, rightNode) +func (a *ITournamentAdapterImpl) BondRecovery(opts *bind.CallOpts) (BondRecovery, error) { + recovery, err := a.tournament.BondRecovery(opts) + if err != nil { + return BondRecovery{}, err + } + return bondRecoveryFromBinding(recovery.Disposition, recovery.Claimer, recovery.Payment) } -// buildFilterQuery creates a filter query for a specific tournament event -func buildFilterQuery( - opts *bind.FilterOpts, - tournamentAddress common.Address, - eventName string, -) (q ethereum.FilterQuery, err error) { - c, err := itournament.ITournamentMetaData.GetAbi() +func bondRecoveryFromBinding( + disposition uint8, + claimer common.Address, + payment *big.Int, +) (BondRecovery, error) { + d, err := tournamentEnum("bond disposition", disposition, model.BondDispositionTournamentRunning, + model.BondDispositionNoWinner, model.BondDispositionRecoverable, model.BondDispositionRecovered) + if err != nil { + return BondRecovery{}, fmt.Errorf("%w: %w", errInvalidBondRecovery, err) + } + amount, err := tournamentUint256("payment", payment) if err != nil { - return q, err + return BondRecovery{}, fmt.Errorf("%w: %w", errInvalidBondRecovery, err) + } + + if d == model.BondDispositionRecoverable { + if claimer == (common.Address{}) { + return BondRecovery{}, fmt.Errorf("%w: recoverable bond has zero claimer", errInvalidBondRecovery) + } + } else if claimer != (common.Address{}) || payment.Sign() != 0 { + return BondRecovery{}, fmt.Errorf( + "%w: inactive disposition %s has claimer %s or payment %s", + errInvalidBondRecovery, + d, + claimer, + payment, + ) } - topics, err := abi.MakeTopics( - []any{c.Events[eventName].ID}, - ) + return BondRecovery{ + Disposition: d, + Claimer: claimer, + Payment: amount, + }, nil +} + +func (a *ITournamentAdapterImpl) CommitmentStanding( + opts *bind.CallOpts, commitmentRoot [32]byte, +) (CommitmentStanding, error) { + standing, err := a.tournament.CommitmentStanding(opts, commitmentRoot) if err != nil { - return q, err + return CommitmentStanding{}, err } + return commitmentStandingFromBinding(standing), nil +} - q = ethereum.FilterQuery{ - Addresses: []common.Address{tournamentAddress}, - FromBlock: new(big.Int).SetUint64(opts.Start), - Topics: topics, +func commitmentStandingFromBinding(standing itournament.ITournamentCommitmentStandingView) CommitmentStanding { + return CommitmentStanding{ + Joined: standing.Joined, + FinalState: standing.FinalState, + Claimer: standing.Claimer, + ClockRunning: standing.ClockRunning, + ClockDeadline: standing.ClockDeadline, + ClockAllowance: standing.ClockAllowance, } - if opts.End != nil { - q.ToBlock = new(big.Int).SetUint64(*opts.End) +} + +// tournamentEnum maps ABI ordinal values to the shared model vocabulary. The +// explicit order at each call site follows the deployed Solidity declaration. +func tournamentEnum[T ~string](name string, raw uint8, values ...T) (T, error) { + if int(raw) >= len(values) { + var zero T + return zero, fmt.Errorf("tournament has unknown %s %d", name, raw) } - return q, err + return values[raw], nil } -// retrieveEvents retrieves and parses events of a specific type -func retrieveEvents[T any]( - a *ITournamentAdapterImpl, - opts *bind.FilterOpts, - eventName string, - parseFunc func(types.Log) (T, error), -) ([]T, error) { - q, err := buildFilterQuery(opts, a.tournamentAddress, eventName) - if err != nil { - return nil, err +const tournamentIntegerBits = 256 + +func tournamentUint256(name string, value *big.Int) (*big.Int, error) { + if value == nil { + return nil, fmt.Errorf("tournament %s is nil", name) + } + if value.Sign() < 0 { + return nil, fmt.Errorf("tournament %s is negative", name) + } + if value.BitLen() > tournamentIntegerBits { + return nil, fmt.Errorf("tournament %s exceeds uint256", name) } + return new(big.Int).Set(value), nil +} - itr, err := a.filter.ChunkedFilterLogs(opts.Context, a.client, q) - if err != nil { - return nil, err +func requirePinnedTournamentRead(opts *bind.CallOpts) error { + if opts == nil || opts.Pending { + return errUnpinnedTournamentRead + } + if opts.BlockHash != (common.Hash{}) && opts.BlockNumber == nil { + return nil + } + if opts.BlockNumber == nil || !opts.BlockNumber.IsUint64() || opts.BlockHash != (common.Hash{}) { + return errUnpinnedTournamentRead } + return nil +} - var events []T - for log, err := range itr { +func (a *ITournamentAdapterImpl) MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) { + if err := requirePinnedTournamentRead(opts); err != nil { + return ObservedMatchSnapshot{}, err + } + timeout, err := a.tournament.ClassifyMatchTimeout(opts, itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}) + if err != nil { + return ObservedMatchSnapshot{}, err + } + phase, err := tournamentEnum("match phase", timeout.ActualPhase, + model.MatchPhaseUninitialized, model.MatchPhaseBisecting, model.MatchPhaseReadyToSeal, model.MatchPhaseSealed) + if err != nil { + return ObservedMatchSnapshot{}, err + } + outcome, err := tournamentEnum("match timeout outcome", timeout.Outcome, + model.MatchTimeoutNone, model.MatchTimeoutOneWins, model.MatchTimeoutTwoWins, model.MatchTimeoutEliminateBoth) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if (outcome == model.MatchTimeoutNone || outcome == model.MatchTimeoutEliminateBoth) && timeout.DeferredCharge != 0 { + return ObservedMatchSnapshot{}, errors.New("tournament timeout has an inactive deferred charge") + } + snapshot := ObservedMatchSnapshot{Phase: phase, TimeoutOutcome: outcome, DeferredCharge: timeout.DeferredCharge} + matchHash := crypto.Keccak256Hash(one[:], two[:]) + switch phase { + case model.MatchPhaseUninitialized: + if outcome != model.MatchTimeoutNone { + return ObservedMatchSnapshot{}, errors.New("absent tournament match has a timeout outcome") + } + case model.MatchPhaseBisecting: + view, err := a.tournament.BisectingMatch(opts, matchHash) if err != nil { - return nil, err + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged } - ev, err := parseFunc(*log) + value := view.Value + position, cycle, side, err := bisectionFields(value.SegmentStartPosition, value.SegmentStartCycle, value.Responder) if err != nil { - return nil, err + return ObservedMatchSnapshot{}, err + } + if value.CurrentHeight <= 1 { + return ObservedMatchSnapshot{}, errors.New("bisecting tournament match has height below two") + } + snapshot.Bisecting = &BisectingMatch{RevealingParent: value.RevealingParent, + WaitingLeft: value.WaitingLeft, WaitingRight: value.WaitingRight, SegmentStartPosition: position, + SegmentStartCycle: cycle, CurrentHeight: value.CurrentHeight, Responder: side} + case model.MatchPhaseReadyToSeal: + view, err := a.tournament.ReadyToSealMatch(opts, matchHash) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged + } + value := view.Value + position, cycle, side, err := bisectionFields(value.SegmentStartPosition, value.SegmentStartCycle, value.Responder) + if err != nil { + return ObservedMatchSnapshot{}, err + } + snapshot.ReadyToSeal = &ReadyToSealMatch{RevealingParent: value.RevealingParent, + WaitingLeft: value.WaitingLeft, WaitingRight: value.WaitingRight, SegmentStartPosition: position, + SegmentStartCycle: cycle, Responder: side} + case model.MatchPhaseSealed: + view, err := a.tournament.SealedMatch(opts, matchHash) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged + } + value := view.Value + position, err := tournamentUint256("divergence position", value.DivergencePosition) + if err != nil { + return ObservedMatchSnapshot{}, err + } + cycle, err := tournamentUint256("divergence cycle", value.DivergenceCycle) + if err != nil { + return ObservedMatchSnapshot{}, err } - events = append(events, ev) + if timeout.DeferredCharge != 0 { + return ObservedMatchSnapshot{}, errors.New("sealed tournament match has a deferred charge") + } + snapshot.Sealed = &SealedMatch{AgreeState: value.AgreeState, DivergencePosition: position, + DivergenceCycle: cycle, FinalStateOne: value.FinalStateOne, FinalStateTwo: value.FinalStateTwo} } - return events, nil + return snapshot, nil } -func (a *ITournamentAdapterImpl) RetrieveCommitmentJoinedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentCommitmentJoined, error) { - return retrieveEvents(a, opts, MonitoredEvent_CommitmentJoined.String(), a.tournament.ParseCommitmentJoined) +func bisectionFields(position, cycle *big.Int, rawSide uint8) (*big.Int, *big.Int, model.CommitmentSide, error) { + positionCopy, err := tournamentUint256("segment start position", position) + if err != nil { + return nil, nil, "", err + } + cycleCopy, err := tournamentUint256("segment start cycle", cycle) + if err != nil { + return nil, nil, "", err + } + side, err := tournamentEnum("responder", rawSide, model.CommitmentSideOne, model.CommitmentSideTwo) + return positionCopy, cycleCopy, side, err } -func (a *ITournamentAdapterImpl) RetrieveMatchAdvancedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchAdvanced, error) { - return retrieveEvents(a, opts, MonitoredEvent_MatchAdvanced.String(), a.tournament.ParseMatchAdvanced) +func (a *ITournamentAdapterImpl) InnerResult(opts *bind.CallOpts) (InnerResult, error) { + value, err := a.tournament.InnerResult(opts) + if err != nil { + return InnerResult{}, err + } + disposition, err := tournamentEnum("inner disposition", value.Disposition, + model.InnerTournamentUnsettled, model.InnerTournamentWinner, model.InnerTournamentEliminable) + if err != nil { + return InnerResult{}, err + } + if disposition != model.InnerTournamentWinner && (value.ParentCommitment != [32]byte{} || value.PausedAllowance != 0) { + return InnerResult{}, errors.New("inactive tournament inner result has winner fields") + } + return InnerResult{Disposition: disposition, ParentCommitment: value.ParentCommitment, PausedAllowance: value.PausedAllowance}, nil } -func (a *ITournamentAdapterImpl) RetrieveMatchCreatedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchCreated, error) { - return retrieveEvents(a, opts, MonitoredEvent_MatchCreated.String(), a.tournament.ParseMatchCreated) +func (a *ITournamentAdapterImpl) StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) { + if err := requirePinnedTournamentRead(opts); err != nil { + return StructuralEventCounts{}, err + } + var counts StructuralEventCounts + for _, counter := range []struct { + name string + read func(*bind.CallOpts) (*big.Int, error) + value **big.Int + }{ + {tournamentEventCommitmentJoined, a.tournament.GetCommitmentJoinedCount, &counts.CommitmentJoined}, + {tournamentEventMatchCreated, a.tournament.GetMatchCreatedCount, &counts.MatchCreated}, + {tournamentEventMatchAdvanced, a.tournament.GetMatchAdvancedCount, &counts.MatchAdvanced}, + {tournamentEventLeafMatchSealed, a.tournament.GetLeafMatchSealedCount, &counts.LeafMatchSealed}, + {tournamentEventMatchDeleted, a.tournament.GetMatchDeletedCount, &counts.MatchDeleted}, + {tournamentEventNewInnerTournament, a.tournament.GetNewInnerTournamentCount, &counts.NewInnerTournament}, + } { + value, err := counter.read(opts) + if err != nil { + return StructuralEventCounts{}, fmt.Errorf("reading %s count: %w", counter.name, err) + } + *counter.value, err = tournamentUint256(counter.name+" count", value) + if err != nil { + return StructuralEventCounts{}, err + } + } + return counts, nil } -func (a *ITournamentAdapterImpl) RetrieveMatchDeletedEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentMatchDeleted, error) { - return retrieveEvents(a, opts, MonitoredEvent_MatchDeleted.String(), a.tournament.ParseMatchDeleted) +func (a *ITournamentAdapterImpl) JoinTournament( + opts *bind.TransactOpts, finalState [32]byte, proof [][32]byte, + leftNode [32]byte, rightNode [32]byte, +) (*types.Transaction, error) { + return a.tournament.JoinTournament(opts, finalState, proof, leftNode, rightNode) } -func (a *ITournamentAdapterImpl) RetrieveNewInnerTournamentEvents( - opts *bind.FilterOpts, -) ([]*itournament.ITournamentNewInnerTournament, error) { - return retrieveEvents(a, opts, MonitoredEvent_NewInnerTournament.String(), a.tournament.ParseNewInnerTournament) +func (a *ITournamentAdapterImpl) TryRecoveringBond(opts *bind.TransactOpts) (*types.Transaction, error) { + return a.tournament.TryRecoveringBond(opts) +} + +var tournamentEventNames = [...]string{ + "CommitmentJoined", "MatchAdvanced", "MatchCreated", "MatchDeleted", + "NewInnerTournament", "LeafMatchSealed", "PartialBondRefund", "BondRecovered", } func buildAllEventsFilterQuery( opts *bind.FilterOpts, tournamentAddress common.Address, -) (q ethereum.FilterQuery, err error) { - c, err := itournament.ITournamentMetaData.GetAbi() - if err != nil { - return q, err - } - - topics, err := abi.MakeTopics( - []any{ - c.Events[MonitoredEvent_CommitmentJoined.String()].ID, - c.Events[MonitoredEvent_MatchAdvanced.String()].ID, - c.Events[MonitoredEvent_MatchCreated.String()].ID, - c.Events[MonitoredEvent_MatchDeleted.String()].ID, - c.Events[MonitoredEvent_NewInnerTournament.String()].ID, - }, - ) - if err != nil { - return q, err + contractABI *abi.ABI, +) (ethereum.FilterQuery, error) { + if opts == nil { + return ethereum.FilterQuery{}, errors.New("tournament event filter is nil") } - - q = ethereum.FilterQuery{ + topics := make([]common.Hash, 0, len(tournamentEventNames)) + for _, name := range tournamentEventNames { + event, ok := contractABI.Events[name] + if !ok { + return ethereum.FilterQuery{}, fmt.Errorf("ITournament ABI is missing monitored event %s", name) + } + topics = append(topics, event.ID) + } + q := ethereum.FilterQuery{ Addresses: []common.Address{tournamentAddress}, FromBlock: new(big.Int).SetUint64(opts.Start), - Topics: topics, + Topics: [][]common.Hash{topics}, } if opts.End != nil { q.ToBlock = new(big.Int).SetUint64(*opts.End) } - return q, err + return q, nil } -func (a *ITournamentAdapterImpl) RetrieveAllEvents( - opts *bind.FilterOpts, -) (*TournamentEvents, error) { - q, err := buildAllEventsFilterQuery(opts, a.tournamentAddress) +func (a *ITournamentAdapterImpl) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { + if opts == nil || opts.End == nil || opts.Start > *opts.End { + return nil, errors.New("tournament events require a fixed, nonempty block range") + } + contractABI, err := itournament.ITournamentMetaData.GetAbi() if err != nil { return nil, err } - - itr, err := a.filter.ChunkedFilterLogs(opts.Context, a.client, q) + query, err := buildAllEventsFilterQuery(opts, a.tournamentAddress, contractABI) if err != nil { return nil, err } - - var commitmentJoined []*itournament.ITournamentCommitmentJoined - var matchAdvanced []*itournament.ITournamentMatchAdvanced - var matchCreated []*itournament.ITournamentMatchCreated - var matchDeleted []*itournament.ITournamentMatchDeleted - var newInnerTournament []*itournament.ITournamentNewInnerTournament - - c, err := itournament.ITournamentMetaData.GetAbi() + // The filter owns its chunk sizing. A local copy prevents concurrent reads + // through one adapter from changing each other's filter state. + filter := a.filter + iterator, err := filter.ChunkedFilterLogs(opts.Context, a.client, query) if err != nil { return nil, err } - - for log, err := range itr { + type logPosition struct { + block uint64 + index uint + } + type eventIdentity struct { + transaction common.Hash + index uint + } + positions := make(map[logPosition]struct{}) + identities := make(map[eventIdentity]struct{}) + blockHashes := make(map[uint64]common.Hash) + var logs []types.Log + for entry, err := range iterator { if err != nil { return nil, err } + if entry == nil || entry.Address != a.tournamentAddress || entry.Removed { + return nil, errors.New("tournament event has an invalid address or removed flag") + } + if entry.BlockNumber < opts.Start || entry.BlockNumber > *opts.End { + return nil, errors.New("tournament event is outside the requested block range") + } + if entry.TxHash == (common.Hash{}) || entry.BlockHash == (common.Hash{}) { + return nil, errors.New("tournament event has an incomplete log identity") + } + position := logPosition{entry.BlockNumber, entry.Index} + identity := eventIdentity{entry.TxHash, entry.Index} + if _, exists := positions[position]; exists { + return nil, errors.New("tournament event repeats a block log position") + } + if _, exists := identities[identity]; exists { + return nil, errors.New("tournament event repeats a transaction log identity") + } + if hash, exists := blockHashes[entry.BlockNumber]; exists && hash != entry.BlockHash { + return nil, errors.New("tournament events disagree on the block hash") + } + positions[position] = struct{}{} + identities[identity] = struct{}{} + blockHashes[entry.BlockNumber] = entry.BlockHash + logs = append(logs, *entry) + } + slices.SortFunc(logs, func(one, two types.Log) int { + if order := cmp.Compare(one.BlockNumber, two.BlockNumber); order != 0 { + return order + } + return cmp.Compare(one.Index, two.Index) + }) + events := &TournamentEvents{} + for _, entry := range logs { + if err := a.appendTournamentEvent(contractABI, entry, events); err != nil { + return nil, err + } + } + return events, nil +} - switch log.Topics[0] { - case c.Events[MonitoredEvent_CommitmentJoined.String()].ID: - ev, err := a.tournament.ParseCommitmentJoined(*log) - if err != nil { - return nil, err - } - commitmentJoined = append(commitmentJoined, ev) - case c.Events[MonitoredEvent_MatchAdvanced.String()].ID: - ev, err := a.tournament.ParseMatchAdvanced(*log) - if err != nil { - return nil, err - } - matchAdvanced = append(matchAdvanced, ev) - case c.Events[MonitoredEvent_MatchCreated.String()].ID: - ev, err := a.tournament.ParseMatchCreated(*log) - if err != nil { - return nil, err - } - matchCreated = append(matchCreated, ev) - case c.Events[MonitoredEvent_MatchDeleted.String()].ID: - ev, err := a.tournament.ParseMatchDeleted(*log) - if err != nil { - return nil, err - } - matchDeleted = append(matchDeleted, ev) - case c.Events[MonitoredEvent_NewInnerTournament.String()].ID: - ev, err := a.tournament.ParseNewInnerTournament(*log) - if err != nil { - return nil, err - } - newInnerTournament = append(newInnerTournament, ev) - } - } - - return &TournamentEvents{ - CommitmentJoined: commitmentJoined, - MatchAdvanced: matchAdvanced, - MatchCreated: matchCreated, - MatchDeleted: matchDeleted, - NewInnerTournament: newInnerTournament, - }, nil +func (a *ITournamentAdapterImpl) appendTournamentEvent(contractABI *abi.ABI, entry types.Log, events *TournamentEvents) error { + if len(entry.Topics) == 0 { + return errors.New("tournament event has no topic") + } + definition, err := contractABI.EventByID(entry.Topics[0]) + if err != nil { + return fmt.Errorf("tournament event has unexpected topic %s: %w", entry.Topics[0], err) + } + // Every current tournament event field occupies one static ABI word. + // UnpackLog skips decoding empty data, so validate its exact size first. + const abiWordBytes = 32 + nonIndexed := len(definition.Inputs.NonIndexed()) + if len(entry.Data) != nonIndexed*abiWordBytes || len(entry.Topics) != len(definition.Inputs)-nonIndexed+1 { + return fmt.Errorf("tournament %s event has invalid ABI field lengths", definition.Name) + } + switch entry.Topics[0] { + case contractABI.Events["CommitmentJoined"].ID: + event, err := a.tournament.ParseCommitmentJoined(entry) + if err != nil { + return err + } + events.CommitmentJoined = append(events.CommitmentJoined, event) + case contractABI.Events["MatchAdvanced"].ID: + event, err := a.tournament.ParseMatchAdvanced(entry) + if err != nil { + return err + } + events.MatchAdvanced = append(events.MatchAdvanced, event) + case contractABI.Events["MatchCreated"].ID: + event, err := a.tournament.ParseMatchCreated(entry) + if err != nil { + return err + } + events.MatchCreated = append(events.MatchCreated, event) + case contractABI.Events["MatchDeleted"].ID: + event, err := a.tournament.ParseMatchDeleted(entry) + if err != nil { + return err + } + if _, err := model.MatchDeletionReasonFromUint8(event.Reason); err != nil { + return fmt.Errorf("tournament deletion reason: %w", err) + } + if _, err := model.WinnerCommitmentFromUint8(event.WinnerCommitment); err != nil { + return fmt.Errorf("tournament deletion winner: %w", err) + } + events.MatchDeleted = append(events.MatchDeleted, event) + case contractABI.Events["NewInnerTournament"].ID: + event, err := a.tournament.ParseNewInnerTournament(entry) + if err != nil { + return err + } + events.NewInnerTournament = append(events.NewInnerTournament, event) + case contractABI.Events["LeafMatchSealed"].ID: + event, err := a.tournament.ParseLeafMatchSealed(entry) + if err != nil { + return err + } + events.LeafMatchSealed = append(events.LeafMatchSealed, event) + case contractABI.Events["PartialBondRefund"].ID: + event, err := a.tournament.ParsePartialBondRefund(entry) + if err != nil { + return err + } + events.PartialBondRefund = append(events.PartialBondRefund, event) + case contractABI.Events["BondRecovered"].ID: + event, err := a.tournament.ParseBondRecovered(entry) + if err != nil { + return err + } + events.BondRecovered = append(events.BondRecovered, event) + default: + return fmt.Errorf("tournament event has unexpected topic %s", entry.Topics[0]) + } + return nil } diff --git a/internal/prt/join_log_test.go b/internal/prt/join_log_test.go new file mode 100644 index 000000000..7648dae39 --- /dev/null +++ b/internal/prt/join_log_test.go @@ -0,0 +1,68 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" +) + +func TestJoinTransactionSuccessDiagnostic(t *testing.T) { + for _, sendErr := range []error{nil, errors.New("send failed")} { + name := "sent" + if sendErr != nil { + name = "failed" + } + t.Run(name, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + factory := &adapterFactoryMock{} + s := newRootBondTestService(common.HexToAddress("0x600"), factory) + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + repo := s.repository.(*prtRepositoryMock) + repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index, + epoch.TournamentAddress.Hex(), epoch.Commitment.Hex()).Return(nil, nil).Once() + adapter := &tournamentAdapterMock{} + factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(20)) + adapter.On("CommitmentStanding", opts, [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, nil).Once() + adapter.On("Descriptor", opts). + Return(TournamentDescriptor{Height: model.Log2EpochComputationHashLeafCount}, nil).Once() + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + adapter.On("JoinTournament", mock.Anything, [32]byte(*epoch.MachineHash), + mock.Anything, mock.Anything, mock.Anything).Return(tx, sendErr).Once() + + joined, err := s.reactToTournament(t.Context(), app, epoch, 20) + require.False(t, joined) + if sendErr != nil { + require.ErrorIs(t, err, sendErr) + require.NotContains(t, output.String(), "Sent tournament join transaction") + require.Empty(t, s.pendingTransactions) + } else { + require.NoError(t, err) + require.Contains(t, output.String(), `level=INFO msg="Sent tournament join transaction"`) + require.Contains(t, output.String(), "application="+app.Name) + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), "tournament="+epoch.TournamentAddress.Hex()) + require.Contains(t, output.String(), "tx="+tx.Hash().Hex()) + } + repo.AssertExpectations(t) + factory.AssertExpectations(t) + adapter.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/legacy_fees_test.go b/internal/prt/legacy_fees_test.go new file mode 100644 index 000000000..72a6b4acc --- /dev/null +++ b/internal/prt/legacy_fees_test.go @@ -0,0 +1,135 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" +) + +func TestCreateWiresLegacyFees(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(fmt.Sprint(legacy), func(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "private_key") + viper.Set(config.PRT_AUTH_PRIVATE_KEY, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + t.Cleanup(func() { + viper.Set(config.PRT_AUTH_KIND, nil) + viper.Set(config.PRT_AUTH_PRIVATE_KEY, nil) + }) + raw, err := json.Marshal(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: true, + }) + require.NoError(t, err) + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return(raw, nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + if legacy { + client.On("SuggestGasPrice", mock.Anything).Return(big.NewInt(101), nil).Once() + client.On("SuggestGasPrice", mock.Anything).Return(big.NewInt(102), nil).Once() + } + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: time.Second, + BlockchainLegacyEnabled: legacy, + }, + EthClient: client, Repository: repo, AdapterFactory: &adapterFactoryMock{}, + }) + require.NoError(t, err) + s := svc.(*Service) + for sequence := int64(1); sequence <= 2; sequence++ { + opts, err := s.txOptsFactory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Zero(t, opts.GasLimit, "keep estimation enabled") + if legacy { + require.Equal(t, big.NewInt(100+sequence), opts.GasPrice) + } else { + require.Nil(t, opts.GasPrice) + } + } + if !legacy { + client.AssertNotCalled(t, "SuggestGasPrice", mock.Anything) + } + client.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestCreateWiresLegacyFeesWithDefaultClient(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "private_key") + viper.Set(config.PRT_AUTH_PRIVATE_KEY, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + t.Cleanup(func() { + viper.Set(config.PRT_AUTH_KIND, nil) + viper.Set(config.PRT_AUTH_PRIVATE_KEY, nil) + }) + backend := &prtLegacyFeeRPC{} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + endpoint, err := config.ToURLFromString(httpServer.URL) + require.NoError(t, err) + raw, err := json.Marshal(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: true, + }) + require.NoError(t, err) + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return(raw, nil).Once() + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + BlockchainHttpEndpoint: endpoint, BlockchainHttpRequestTimeout: time.Second, + FeatureClaimSubmissionEnabled: true, BlockchainLegacyEnabled: true, + }, + Repository: repo, + }) + require.NoError(t, err) + s := svc.(*Service) + client, ok := s.client.(*ethclient.Client) + require.True(t, ok) + t.Cleanup(client.Close) + factory, ok := s.adapterFactory.(*DefaultAdapterFactory) + require.True(t, ok) + require.Same(t, client, factory.client) + for sequence := int64(1); sequence <= 2; sequence++ { + opts, err := s.txOptsFactory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Zero(t, opts.GasLimit, "keep estimation enabled") + require.Equal(t, big.NewInt(100+sequence), opts.GasPrice) + } + require.EqualValues(t, 2, backend.prices.Load()) + repo.AssertExpectations(t) +} + +type prtLegacyFeeRPC struct { + prices atomic.Int32 +} + +func (*prtLegacyFeeRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // RPC registration requires eth_chainId. + return (*hexutil.Big)(big.NewInt(42)), nil +} + +func (r *prtLegacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} diff --git a/internal/prt/missing_transaction_test.go b/internal/prt/missing_transaction_test.go new file mode 100644 index 000000000..9b1b92130 --- /dev/null +++ b/internal/prt/missing_transaction_test.go @@ -0,0 +1,286 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func expectMissingTournamentTransaction(client *ethClientMock, hash common.Hash) { + client.On("TransactionByHash", mock.Anything, hash). + Return((*types.Transaction)(nil), false, fmt.Errorf("lookup: %w", ethereum.NotFound)).Once() + client.On("TransactionReceipt", mock.Anything, hash). + Return((*types.Receipt)(nil), fmt.Errorf("receipt: %w", ethereum.NotFound)).Once() +} + +func TestMissingTournamentTransactionExpiresAfterBlockBudget(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + for _, start := range []uint64{0, 100, math.MaxUint64 - 64} { + t.Run(fmt.Sprintf("%s/from_%d", action, start), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + tx := pendingTournamentTransaction{Action: action, Hash: common.HexToHash("0xbeef"), EpochIndex: 3} + s.pendingTransactions[app.ID] = tx + s.pendingTransactions[app.ID+1] = tx + client := ðClientMock{} + s.client = client + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + + // Pin the 64-block policy independently of the implementation constant. + // Neither repeated ticks nor a much older observation head may age it. + for i, head := range []uint64{start, start, start + 63, start + 64} { + expectMissingTournamentTransaction(client, tx.Hash) + epoch, recovery, err := s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Nil(t, epoch) + require.False(t, recovery, "do not broadcast a refund on the release tick") + require.Equal(t, i != 3, s.hasNonRecoveryMutationInFlight(app.ID)) + require.Equal(t, tx, s.pendingTransactions[app.ID+1]) + require.Empty(t, repo.Calls, "release cannot change epoch or application state") + if i != 3 { + require.Empty(t, logs.String(), "a normal bounded wait is not an operational error") + } + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Contains(t, logs.String(), "level=WARN") + require.Contains(t, logs.String(), "action="+string(action)) + require.Contains(t, logs.String(), tx.Hash.Hex()) + client.AssertExpectations(t) + }) + } + } +} + +func TestMissingTournamentTransactionWaitRestartsWhenKnown(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + tx := pendingTournamentTransaction{Action: tournamentActionJoin, Hash: common.HexToHash("0xbeef")} + s.pendingTransactions[app.ID] = tx + client := ðClientMock{} + s.client = client + + expectMissingTournamentTransaction(client, tx.Hash) + _, _, err := s.progressTournamentResult(t.Context(), app, 0, 100) + require.NoError(t, err) + // Even a very old transaction must stay tracked while it is known pending. + client.On("TransactionByHash", mock.Anything, tx.Hash).Return((*types.Transaction)(nil), true, nil).Once() + _, _, err = s.progressTournamentResult(t.Context(), app, 0, 1_000) + require.NoError(t, err) + require.Equal(t, tx, s.pendingTransactions[app.ID]) + for i, head := range []uint64{1_001, 1_064, 1_065} { + expectMissingTournamentTransaction(client, tx.Hash) + _, _, err = s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Equal(t, i != 2, s.hasNonRecoveryMutationInFlight(app.ID)) + } + require.Empty(t, repo.Calls) + client.AssertExpectations(t) +} + +func TestMissingTournamentTransactionWaitRestartsOnHeadRegression(t *testing.T) { + s, _ := newPRTServiceMock() + app := prtRevertTestApp() + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: tournamentActionStage, Hash: hash} + client := ðClientMock{} + s.client = client + for i, head := range []uint64{100, 99, 100, 162, 163} { + expectMissingTournamentTransaction(client, hash) + _, _, err := s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Equal(t, i != 4, s.hasNonRecoveryMutationInFlight(app.ID)) + } + client.AssertExpectations(t) +} + +func TestMissingTournamentTransactionErrorsDoNotReleaseSlot(t *testing.T) { + for _, test := range []struct { + name string + err error + lookup bool + }{ + {"lookup failure", errors.New("provider unavailable"), true}, + {"receipt failure", errors.New("provider unavailable"), false}, + {"lookup canceled", context.Canceled, true}, + {"receipt deadline", context.DeadlineExceeded, false}, + } { + t.Run(test.name, func(t *testing.T) { + s, _ := newPRTServiceMock() + app := prtRevertTestApp() + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: tournamentActionAccept, Hash: hash} + client := ðClientMock{} + s.client = client + expectMissingTournamentTransaction(client, hash) + _, _, err := s.progressTournamentResult(t.Context(), app, 0, 100) + require.NoError(t, err) + tracked := s.pendingTransactions[app.ID] + lookupErr := test.err + if !test.lookup { + lookupErr = ethereum.NotFound + client.On("TransactionReceipt", mock.Anything, hash).Return((*types.Receipt)(nil), test.err).Once() + } + client.On("TransactionByHash", mock.Anything, hash).Return((*types.Transaction)(nil), false, lookupErr).Once() + _, _, err = s.progressTournamentResult(t.Context(), app, 0, 164) + require.ErrorIs(t, err, test.err) + require.Equal(t, tracked, s.pendingTransactions[app.ID]) + client.AssertExpectations(t) + }) + } +} + +func TestMissingTournamentTransactionUsesFreshEpochAfterRelease(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage.IsFinished = false + s, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{Action: action, Hash: hash, EpochIndex: epoch.Index - 1} + client := ðClientMock{} + s.client = client + for _, head := range []uint64{100, 164} { + expectMissingTournamentTransaction(client, hash) + join, recovery, err := s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Nil(t, join) + require.False(t, recovery) + require.Empty(t, repo.Calls) + require.Empty(t, consensus.Calls) + } + // Another participant accepted the old epoch. Select the new epoch; + // do not replay the expired action from the old in-memory record. + join, _, err := s.progressTournamentResult(t.Context(), app, 0, 165) + require.NoError(t, err) + require.Same(t, epoch, join) + require.Empty(t, s.pendingTransactions) + client.AssertExpectations(t) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + }) + } +} + +func TestMissingStageObservesExternalStageAfterRelease(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + s, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, true), true) + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 165 + repo.On("UpdateEpochReconciledStaged", mock.Anything, app.ID, epoch.Index, uint64(10)).Return(nil).Once() + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionStage, Hash: hash, EpochIndex: epoch.Index, + } + client := ðClientMock{} + s.client = client + for _, head := range []uint64{100, 164} { + expectMissingTournamentTransaction(client, hash) + _, _, err := s.progressTournamentResult(t.Context(), app, head, head) + require.NoError(t, err) + require.Empty(t, repo.Calls) + require.Empty(t, consensus.Calls) + } + _, _, err := s.progressTournamentResult(t.Context(), app, 165, 165) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Empty(t, s.pendingTransactions, "the external stage must not trigger another stage broadcast") + client.AssertExpectations(t) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestMissingStageRetriesAfterFreshSnapshotWithEstimationEnabled(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + applyPRTStateProof(epoch, repotest.KeccakStateProof(common.HexToHash("0x1234"))) + s, repo, consensus := resultTestService(t, epoch, resultTestSnapshot(epoch, false), true) + app := prtRevertTestApp() + oldHash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionStage, Hash: oldHash, EpochIndex: epoch.Index, + } + client := ðClientMock{} + s.client = client + for _, head := range []uint64{100, 164} { + expectMissingTournamentTransaction(client, oldHash) + _, _, err := s.progressTournamentResult(t.Context(), app, 0, head) + require.NoError(t, err) + require.Empty(t, repo.Calls) + require.Empty(t, consensus.Calls) + } + newTx := types.NewTx(&types.LegacyTx{Nonce: 8}) + consensus.On("StageTournamentResult", mock.Anything, epoch.Index, mock.Anything). + Run(func(args mock.Arguments) { + require.Zero(t, args.Get(0).(*bind.TransactOpts).GasLimit, "a retry must keep gas estimation enabled") + }).Return(newTx, nil).Once() + _, _, err := s.progressTournamentResult(t.Context(), app, 0, 165) + require.NoError(t, err) + require.Equal(t, pendingTournamentTransaction{ + Action: tournamentActionStage, Hash: newTx.Hash(), EpochIndex: epoch.Index, + }, s.pendingTransactions[app.ID]) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status, "broadcast is not stage confirmation") + client.AssertExpectations(t) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestMissingTournamentTransactionDefersForeclosedBondRecovery(t *testing.T) { + app := prtForeclosedApp(1, 100) + app.IConsensusAddress = common.HexToAddress("0x200") + owned := common.HexToAddress("0x600") + factory := &adapterFactoryMock{} + s := newRootBondTestService(owned, factory) + s.submissionEnabled = true + tournamentAddress := common.HexToAddress("0x300") + s.queueRootBondRecovery(app.ID, 3, tournamentAddress) + hash := common.HexToHash("0xbeef") + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionAccept, Hash: hash, EpochIndex: 3, + } + client := ðClientMock{} + s.client = client + for _, head := range []uint64{100, 164} { + client.On("BlockNumber", mock.Anything).Return(head, nil).Once() + expectMissingTournamentTransaction(client, hash) + require.NoError(t, s.recoverForeclosedRootBonds(t.Context(), app)) + require.Empty(t, factory.Calls) + require.Nil(t, s.rootBondRecoveries[app.ID][0].TxHash) + } + require.Empty(t, s.pendingTransactions) + // Recovery starts only after a new tick reads current bond ownership. + client.On("BlockNumber", mock.Anything).Return(uint64(165), nil).Once() + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(165))). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(165))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + refund := types.NewTx(&types.LegacyTx{Nonce: 1}) + tournament.On("TryRecoveringBond", mock.Anything).Return(refund, nil).Once() + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + require.NoError(t, s.recoverForeclosedRootBonds(t.Context(), app)) + require.Equal(t, refund.Hash(), *s.rootBondRecoveries[app.ID][0].TxHash) + client.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + factory.AssertExpectations(t) +} diff --git a/internal/prt/observation_counts.go b/internal/prt/observation_counts.go new file mode 100644 index 000000000..ba52fd828 --- /dev/null +++ b/internal/prt/observation_counts.go @@ -0,0 +1,82 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" +) + +const ( + tournamentEventCommitmentJoined = "CommitmentJoined" + tournamentEventMatchCreated = "MatchCreated" + tournamentEventMatchAdvanced = "MatchAdvanced" + tournamentEventLeafMatchSealed = "LeafMatchSealed" + tournamentEventMatchDeleted = "MatchDeleted" + tournamentEventNewInnerTournament = "NewInnerTournament" +) + +func zeroStructuralEventCounts() StructuralEventCounts { + return StructuralEventCounts{ + CommitmentJoined: new(big.Int), MatchCreated: new(big.Int), MatchAdvanced: new(big.Int), + LeafMatchSealed: new(big.Int), MatchDeleted: new(big.Int), NewInnerTournament: new(big.Int), + } +} + +// Each successful refundable action emits one advance, leaf seal, deletion, +// or child creation, followed by one refund event even if payment fails. +// Successful bond recovery is a one-shot transition and has its own event. +func validateTournamentFinancialEvents(before, after model.BondDisposition, events *TournamentEvents) error { + expectedRefunds := len(events.MatchAdvanced) + len(events.LeafMatchSealed) + len(events.MatchDeleted) + len(events.NewInnerTournament) + if len(events.PartialBondRefund) != expectedRefunds { + return fmt.Errorf("PartialBondRefund event count mismatch: expected %d, fetched %d", expectedRefunds, len(events.PartialBondRefund)) + } + if before == model.BondDispositionRecovered && after != model.BondDispositionRecovered { + return fmt.Errorf("bond recovery disposition regressed from RECOVERED to %s", after) + } + expectedRecoveries := 0 + if before != model.BondDispositionRecovered && after == model.BondDispositionRecovered { + expectedRecoveries = 1 + } + if len(events.BondRecovered) != expectedRecoveries { + return fmt.Errorf("BondRecovered event count mismatch: expected %d, fetched %d", expectedRecoveries, len(events.BondRecovered)) + } + return nil +} + +// validateTournamentEventCounts checks each independent structural stream. +// A mismatch is an observation failure, not proof of local data corruption. +func validateTournamentEventCounts(before, after StructuralEventCounts, events *TournamentEvents) error { + for _, stream := range []struct { + name string + before, after *big.Int + fetched int + }{ + {tournamentEventCommitmentJoined, before.CommitmentJoined, after.CommitmentJoined, len(events.CommitmentJoined)}, + {tournamentEventMatchCreated, before.MatchCreated, after.MatchCreated, len(events.MatchCreated)}, + {tournamentEventMatchAdvanced, before.MatchAdvanced, after.MatchAdvanced, len(events.MatchAdvanced)}, + {tournamentEventLeafMatchSealed, before.LeafMatchSealed, after.LeafMatchSealed, len(events.LeafMatchSealed)}, + {tournamentEventMatchDeleted, before.MatchDeleted, after.MatchDeleted, len(events.MatchDeleted)}, + {tournamentEventNewInnerTournament, before.NewInnerTournament, after.NewInnerTournament, len(events.NewInnerTournament)}, + } { + previous, err := tournamentUint256(stream.name+" previous count", stream.before) + if err != nil { + return err + } + current, err := tournamentUint256(stream.name+" current count", stream.after) + if err != nil { + return err + } + expected := new(big.Int).Sub(current, previous) + if expected.Sign() < 0 { + return fmt.Errorf("%s count decreased from %s to %s", stream.name, previous, current) + } + if expected.Cmp(big.NewInt(int64(stream.fetched))) != 0 { + return fmt.Errorf("%s event count mismatch: expected %s, fetched %d", stream.name, expected, stream.fetched) + } + } + return nil +} diff --git a/internal/prt/observation_counts_test.go b/internal/prt/observation_counts_test.go new file mode 100644 index 000000000..22b2e4bda --- /dev/null +++ b/internal/prt/observation_counts_test.go @@ -0,0 +1,207 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func structuralCountsForEvents(events *TournamentEvents) StructuralEventCounts { + return StructuralEventCounts{ + CommitmentJoined: new(big.Int).SetUint64(uint64(len(events.CommitmentJoined))), + MatchCreated: new(big.Int).SetUint64(uint64(len(events.MatchCreated))), + MatchAdvanced: new(big.Int).SetUint64(uint64(len(events.MatchAdvanced))), + LeafMatchSealed: new(big.Int).SetUint64(uint64(len(events.LeafMatchSealed))), + MatchDeleted: new(big.Int).SetUint64(uint64(len(events.MatchDeleted))), + NewInnerTournament: new(big.Int).SetUint64(uint64(len(events.NewInnerTournament))), + } +} + +func TestTournamentObservationRejectsIncompleteFinancialEvents(t *testing.T) { + for _, omission := range []string{"refund", "recovery", "previous recovery view"} { + t.Run(omission, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + previousOpts := mock.MatchedBy(resultCallOptsAtBlock(50)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + address := *epoch.TournamentAddress + projection := &model.Tournament{Address: address, MaxLevel: 1, Kind: model.TournamentKindLeaf, StartInstant: 10} + before := *projection + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(projection, nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{BaseCycle: new(big.Int), Kind: model.TournamentKindLeaf, + StartInstant: 10}, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingRootWinner, + HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: 90}, nil).Once() + disposition := model.BondDispositionRecoverable + if omission == "recovery" { + disposition = model.BondDispositionRecovered + } + adapter.On("BondRecovery", opts).Return(canonicalBondRecovery(disposition, common.HexToAddress("0x777"), 0), nil).Once() + events := &TournamentEvents{} + if omission == "refund" { + events.MatchDeleted = []*itournament.ITournamentMatchDeleted{{}} + } + adapter.On("RetrieveAllEvents", mock.Anything).Return(events, nil).Once() + adapter.On("StructuralEventCounts", previousOpts).Return(zeroStructuralEventCounts(), nil).Once() + if omission == "previous recovery view" { + adapter.On("BondRecovery", previousOpts).Return(BondRecovery{}, fmt.Errorf("previous recovery view unavailable")).Once() + } else { + adapter.On("BondRecovery", previousOpts). + Return(canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0), nil).Once() + adapter.On("StructuralEventCounts", opts).Return(structuralCountsForEvents(events), nil).Once() + } + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.Error(t, err) + require.Equal(t, before, *projection) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, uint8(1), f.s.observationFailures[f.app.ID].failedHeads) + f.repo.AssertNotCalled(t, "StoreTournamentEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + adapter.AssertExpectations(t) + }) + } +} + +func TestTournamentObservationPublishesLateRecovery(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.Status = model.EpochStatus_ClaimAccepted + f.epochs(epoch) + opts, previousOpts := mock.MatchedBy(resultCallOptsAtBlock(100)), mock.MatchedBy(resultCallOptsAtBlock(50)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + address := *epoch.TournamentAddress + projection := &model.Tournament{Address: address, MaxLevel: 1, Kind: model.TournamentKindLeaf, StartInstant: 10, + Snapshot: model.TournamentSnapshot{AsOfBlock: 50, FinishedAtBlock: 40, Standing: model.TournamentStandingRootWinner, + BondRecovery: model.TournamentBondRecovery{Disposition: model.BondDispositionRecoverable}}} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(projection, nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{BaseCycle: new(big.Int), Kind: model.TournamentKindLeaf, + StartInstant: 10}, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingRootWinner, + HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: 40}, nil).Once() + adapter.On("BondRecovery", opts).Return(canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0), nil).Once() + adapter.On("BondRecovery", previousOpts). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, common.HexToAddress("0x777"), 0), nil).Once() + events := &TournamentEvents{BondRecovered: []*itournament.ITournamentBondRecovered{{ + Commitment: *epoch.Commitment, Claimer: common.HexToAddress("0x777"), Payment: new(big.Int), Burned: big.NewInt(8), + }}} + adapter.On("RetrieveAllEvents", mock.Anything).Return(events, nil).Once() + adapter.On("StructuralEventCounts", previousOpts).Return(zeroStructuralEventCounts(), nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, address) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && len(batches[0].BondEvents) == 1 && + batches[0].Tournament.Snapshot.BondRecovery.Disposition == model.BondDispositionRecovered && + batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, uint64(50), projection.Snapshot.AsOfBlock) + adapter.AssertExpectations(t) +} + +func TestTournamentFinancialEventsRequireEachRefund(t *testing.T) { + for _, test := range []struct { + name string + events TournamentEvents + }{ + {"advance", TournamentEvents{MatchAdvanced: []*itournament.ITournamentMatchAdvanced{{}}}}, + {"leaf seal", TournamentEvents{LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{{}}}}, + {"deletion", TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{{}}}}, + {"child creation", TournamentEvents{NewInnerTournament: []*itournament.ITournamentNewInnerTournament{{}}}}, + } { + t.Run(test.name, func(t *testing.T) { + require.ErrorContains(t, validateTournamentFinancialEvents(model.BondDispositionTournamentRunning, + model.BondDispositionTournamentRunning, &test.events), "PartialBondRefund event count mismatch") + for _, paid := range []bool{false, true} { + test.events.PartialBondRefund = []*itournament.ITournamentPartialBondRefund{{Value: big.NewInt(0), Success: paid}} + require.NoError(t, validateTournamentFinancialEvents(model.BondDispositionTournamentRunning, + model.BondDispositionTournamentRunning, &test.events)) + } + test.events.PartialBondRefund = append(test.events.PartialBondRefund, test.events.PartialBondRefund[0]) + require.ErrorContains(t, validateTournamentFinancialEvents(model.BondDispositionTournamentRunning, + model.BondDispositionTournamentRunning, &test.events), "PartialBondRefund event count mismatch") + }) + } +} + +func TestTournamentFinancialEventsRequireOneRecoveryTransition(t *testing.T) { + for _, before := range []model.BondDisposition{model.BondDispositionTournamentRunning, model.BondDispositionRecoverable} { + t.Run(string(before), func(t *testing.T) { + events := &TournamentEvents{} + require.ErrorContains(t, validateTournamentFinancialEvents(before, model.BondDispositionRecovered, events), + "BondRecovered event count mismatch") + events.BondRecovered = []*itournament.ITournamentBondRecovered{{Payment: new(big.Int), Burned: new(big.Int)}} + require.NoError(t, validateTournamentFinancialEvents(before, model.BondDispositionRecovered, events)) + events.BondRecovered = append(events.BondRecovered, events.BondRecovered[0]) + require.ErrorContains(t, validateTournamentFinancialEvents(before, model.BondDispositionRecovered, events), + "BondRecovered event count mismatch") + }) + } + for _, disposition := range []model.BondDisposition{model.BondDispositionTournamentRunning, model.BondDispositionNoWinner, + model.BondDispositionRecoverable, model.BondDispositionRecovered} { + // Failed payment remains recoverable; an already recovered retry is a no-op. + require.NoError(t, validateTournamentFinancialEvents(disposition, disposition, &TournamentEvents{})) + require.Error(t, validateTournamentFinancialEvents(disposition, disposition, + &TournamentEvents{BondRecovered: []*itournament.ITournamentBondRecovered{{}}})) + } + require.ErrorContains(t, validateTournamentFinancialEvents(model.BondDispositionRecovered, model.BondDispositionRecoverable, + &TournamentEvents{}), "regressed") +} + +func TestTournamentEventCountsCheckEveryStructuralStream(t *testing.T) { + for _, test := range []struct { + name string + events TournamentEvents + }{ + {tournamentEventCommitmentJoined, TournamentEvents{CommitmentJoined: []*itournament.ITournamentCommitmentJoined{{}}}}, + {tournamentEventMatchCreated, TournamentEvents{MatchCreated: []*itournament.ITournamentMatchCreated{{}}}}, + {tournamentEventMatchAdvanced, TournamentEvents{MatchAdvanced: []*itournament.ITournamentMatchAdvanced{{}}}}, + {tournamentEventLeafMatchSealed, TournamentEvents{LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{{}}}}, + {tournamentEventMatchDeleted, TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{{}}}}, + {tournamentEventNewInnerTournament, TournamentEvents{NewInnerTournament: []*itournament.ITournamentNewInnerTournament{{}}}}, + } { + t.Run(test.name, func(t *testing.T) { + before, after := zeroStructuralEventCounts(), structuralCountsForEvents(&test.events) + require.NoError(t, validateTournamentEventCounts(before, after, &test.events)) + err := validateTournamentEventCounts(before, after, &TournamentEvents{}) + require.ErrorContains(t, err, test.name+" event count mismatch") + err = validateTournamentEventCounts(before, before, &test.events) + require.ErrorContains(t, err, test.name+" event count mismatch") + }) + } +} + +func TestTournamentEventCountsKeepFullWidthAndRejectInvalidCounters(t *testing.T) { + before, after := zeroStructuralEventCounts(), zeroStructuralEventCounts() + before.MatchAdvanced.Lsh(big.NewInt(1), 200) + after.MatchAdvanced.Add(before.MatchAdvanced, big.NewInt(1)) + beforeCopy, afterCopy := new(big.Int).Set(before.MatchAdvanced), new(big.Int).Set(after.MatchAdvanced) + events := &TournamentEvents{MatchAdvanced: []*itournament.ITournamentMatchAdvanced{{}}} + require.NoError(t, validateTournamentEventCounts(before, after, events)) + require.Equal(t, beforeCopy, before.MatchAdvanced) + require.Equal(t, afterCopy, after.MatchAdvanced) + for _, invalid := range []*big.Int{nil, big.NewInt(-1), new(big.Int).Lsh(big.NewInt(1), 256)} { + after.MatchAdvanced = invalid + require.Error(t, validateTournamentEventCounts(before, after, events)) + } + after.MatchAdvanced = new(big.Int).Sub(before.MatchAdvanced, big.NewInt(1)) + require.ErrorContains(t, validateTournamentEventCounts(before, after, events), "count decreased") +} diff --git a/internal/prt/observation_health.go b/internal/prt/observation_health.go new file mode 100644 index 000000000..69cb7d687 --- /dev/null +++ b/internal/prt/observation_health.go @@ -0,0 +1,90 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/model" +) + +// This is an observation-health threshold, not a transaction retry budget. +// Only failed atomic event windows at increasing configured heads count. +const tournamentObservationFailureThreshold = 5 + +type tournamentObservationFailure struct { + fromBlock uint64 + lastHead uint64 + failedHeads uint8 +} + +func (s *Service) Ready() bool { + s.observationHealthMu.RLock() + defer s.observationHealthMu.RUnlock() + for _, failure := range s.observationFailures { + if failure.failedHeads >= tournamentObservationFailureThreshold { + return false + } + } + return true +} + +func (s *Service) recordTournamentObservationFailure( + ctx context.Context, app *model.Application, head, windowEnd uint64, err error, +) { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + return + } + + s.observationHealthMu.Lock() + failure, exists := s.observationFailures[app.ID] + if exists && head <= failure.lastHead { + s.observationHealthMu.Unlock() + return + } + if !exists { + // The caller starts an observation only when its end exceeds this + // cursor, so the addition cannot overflow. + failure.fromBlock = app.LastTournamentCheckBlock + 1 + } + failure.lastHead = head + reachedThreshold := failure.failedHeads == tournamentObservationFailureThreshold-1 + if failure.failedHeads < tournamentObservationFailureThreshold { + failure.failedHeads++ + } + s.observationFailures[app.ID] = failure + s.observationHealthMu.Unlock() + + if reachedThreshold { + s.Logger.Error("PRT tournament observation is stalled; readiness is degraded; observation will retry", + "application", app.Name, "address", app.IApplicationAddress, + "operation", "tournament_event_window", "from_block", failure.fromBlock, + "through_block", windowEnd, "configured_head", head, "failed_heads", failure.failedHeads, + "error", err) + } +} + +// Only successful atomic publication or loss of application eligibility clears +// a failure. A normal wait does not prove that the failed window can be read. +func (s *Service) clearTournamentObservationFailure(appID int64) { + s.observationHealthMu.Lock() + defer s.observationHealthMu.Unlock() + delete(s.observationFailures, appID) +} + +func (s *Service) pruneTournamentObservationFailures(apps []*model.Application) { + eligible := make(map[int64]struct{}, len(apps)) + for _, app := range apps { + eligible[app.ID] = struct{}{} + } + s.observationHealthMu.Lock() + defer s.observationHealthMu.Unlock() + for appID := range s.observationFailures { + if _, exists := eligible[appID]; !exists { + delete(s.observationFailures, appID) + } + } +} diff --git a/internal/prt/observation_health_test.go b/internal/prt/observation_health_test.go new file mode 100644 index 000000000..8c347962d --- /dev/null +++ b/internal/prt/observation_health_test.go @@ -0,0 +1,302 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "sync" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func failTournamentObservation(ctx context.Context, f *observerCheckpointFixture, epoch *model.Epoch, head uint64, cause error) { + f.t.Helper() + f.epochs(epoch) + windowEnd := min(head, f.app.LastEpochCheckBlock) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(windowEnd))). + Return(uint64(0), cause).Once() + deferActions, err := f.s.checkEpochs(ctx, f.app, head) + require.ErrorIs(f.t, err, cause) + require.True(f.t, deferActions) + require.Equal(f.t, uint64(50), f.app.LastTournamentCheckBlock) + require.Equal(f.t, model.ApplicationStatus_OK, f.app.Status) +} + +func TestTournamentObservationHealthCountsIncreasingHeads(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + for i, test := range []struct { + head uint64 + count uint8 + }{ + {100, 1}, {100, 1}, {99, 1}, {101, 2}, {102, 3}, {103, 4}, {104, 5}, {105, 5}, + } { + // Neither a new error message nor a changing head resets the failure. + failTournamentObservation(t.Context(), f, epoch, test.head, fmt.Errorf("provider response %d at head %d", i, test.head)) + require.Equal(t, test.count, f.s.observationFailures[f.app.ID].failedHeads) + require.Equal(t, test.count < tournamentObservationFailureThreshold, f.s.Ready()) + } + require.Equal(t, uint64(51), f.s.observationFailures[f.app.ID].fromBlock) + require.Equal(t, 1, strings.Count(output.String(), "readiness is degraded")) + require.Contains(t, output.String(), "application="+f.app.Name) + require.Contains(t, output.String(), "operation=tournament_event_window") + require.Contains(t, output.String(), "from_block=51") + require.Contains(t, output.String(), "configured_head=104") + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentObservationHealthClearsOnlyAfterPublication(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + for head := uint64(100); head < 105; head++ { + failTournamentObservation(t.Context(), f, epoch, head, errors.New("observation unavailable")) + } + require.False(t, f.s.Ready()) + + // A head with no new observation window does not prove recovery. + f.epochs() + _, err := f.s.checkEpochs(t.Context(), f.app, 105) + require.NoError(t, err) + require.False(t, f.s.Ready()) + require.Equal(t, uint8(5), f.s.observationFailures[f.app.ID].failedHeads) + + // An unrelated query failure and an empty result cannot clear it either. + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), errors.New("epoch query unavailable")).Once() + _, err = f.s.checkEpochs(t.Context(), f.app, 106) + require.ErrorContains(t, err, "epoch query unavailable") + f.epochs() + _, err = f.s.checkEpochs(t.Context(), f.app, 107) + require.NoError(t, err) + require.False(t, f.s.Ready()) + + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + f.unaccepted(epoch, 90) + _, err = f.s.checkEpochs(t.Context(), f.app, 108) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.True(t, f.s.Ready()) + require.Empty(t, f.s.observationFailures) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentObservationHealthIncludesFailedPublication(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + cause := errors.New("no match found for update") + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(cause).Once() + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.ErrorIs(t, err, cause) + require.Equal(t, uint8(1), f.s.observationFailures[f.app.ID].failedHeads) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) +} + +func TestTournamentObservationHealthIgnoresAcceptanceFailure(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Once() + tournament := f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + cause := errors.New("acceptance write unavailable") + f.acceptance(epoch, tournament, cause) + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.ErrorIs(t, err, cause) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Empty(t, f.s.observationFailures) + require.True(t, f.s.Ready()) +} + +func TestTournamentObservationHealthKeepsHealthyApplicationsRunning(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = true + epoch := checkpointEpoch(0, "0x100") + healthy := prtRevertTestApp() + healthy.ID++ + healthy.Name = "healthy-app" + healthy.IApplicationAddress = common.HexToAddress("0x999") + healthy.IConsensusAddress = common.HexToAddress("0x998") + healthy.LastEpochCheckBlock = 200 + unprepared := &model.Epoch{Status: model.EpochStatus_Closed, LastBlock: 201} + f.factory.On("CreateDaveConsensusAdapter", healthy.IConsensusAddress).Return(&daveConsensusAdapterMock{}, nil) + f.s.pendingTransactions[healthy.ID] = pendingTournamentTransaction{Hash: common.HexToHash("0x777")} + f.client.On("TransactionByHash", mock.Anything, common.HexToHash("0x777")).Return((*types.Transaction)(nil), true, nil) + for head := uint64(100); head < 106; head++ { + f.repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{f.app, healthy}, uint64(2), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil).Once() + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(0), errors.New("invalid descriptor")).Once() + f.repo.On("ListEpochs", mock.Anything, healthy.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{unprepared}, uint64(1), nil).Once() + f.repo.On("StoreTournamentEvents", mock.Anything, healthy.ID, mock.Anything, head).Return(nil).Once() + f.client.On("BlockNumber", mock.Anything).Return(head, nil).Once() + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.ErrorContains(t, err, f.app.IApplicationAddress.Hex()) + require.Equal(t, head, healthy.LastTournamentCheckBlock) + } + require.False(t, f.s.Ready()) + require.Len(t, f.s.observationFailures, 1) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.ApplicationStatus_OK, healthy.Status) +} + +func TestTournamentObservationHealthIgnoresPendingAndMissingTransactions(t *testing.T) { + for _, pending := range []bool{true, false} { + t.Run(fmt.Sprintf("pending=%t", pending), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = true + f.app.LastEpochCheckBlock = 200 + epoch := &model.Epoch{Status: model.EpochStatus_Closed, LastBlock: 201} + hash := common.HexToHash("0x777") + f.s.pendingTransactions[f.app.ID] = pendingTournamentTransaction{Action: tournamentActionJoin, Hash: hash} + var lookupError error + if !pending { + lookupError = ethereum.NotFound + } + for head := uint64(100); head < 106; head++ { + f.repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil).Once() + f.epochs(epoch) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, head).Return(nil).Once() + f.client.On("BlockNumber", mock.Anything).Return(head, nil).Once() + f.client.On("TransactionByHash", mock.Anything, hash). + Return((*types.Transaction)(nil), pending, lookupError).Once() + if !pending { + f.client.On("TransactionReceipt", mock.Anything, hash). + Return((*types.Receipt)(nil), ethereum.NotFound).Once() + } + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.True(t, f.s.Ready()) + require.Empty(t, f.s.observationFailures) + } + require.Contains(t, f.s.pendingTransactions, f.app.ID) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + }) + } +} + +func TestTournamentObservationHealthPrunesIneligibleApplications(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + for head := uint64(100); head < 105; head++ { + failTournamentObservation(t.Context(), f, epoch, head, errors.New("observation unavailable")) + } + f.repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), errors.New("application list unavailable")).Once() + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.ErrorContains(t, err, "application list unavailable") + require.False(t, f.s.Ready(), "a failed eligibility query cannot clear a failure") + + // Only disabled or removed PRT applications lose observer eligibility. + f.repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(filter repository.ApplicationFilter) bool { + return filter.Enabled != nil && *filter.Enabled && + len(filter.Statuses) == 0 && + filter.ConsensusType != nil && *filter.ConsensusType == model.Consensus_PRT + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + reschedule, err = f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.True(t, f.s.Ready()) + require.Empty(t, f.s.observationFailures) +} + +func TestTournamentObservationHealthIgnoresShutdownCancellation(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + stopping bool + cause error + counted bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"joined shutdown cancellations", true, errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), false}, + {"shutdown database failure", true, dbErr, true}, + {"shutdown mixed database failure", true, fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {"shutdown joined deadline", true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + failTournamentObservation(ctx, f, checkpointEpoch(0, "0x100"), 100, test.cause) + require.Equal(t, test.counted, len(f.s.observationFailures) == 1) + }) + } +} + +func TestTournamentObservationReadinessConcurrentWithUpdates(t *testing.T) { + s, _ := newPRTServiceMock() + app := prtRevertTestApp() + cause := errors.New("observation unavailable") + var readers sync.WaitGroup + readers.Go(func() { + for range 1000 { + s.Ready() + } + }) + for head := uint64(1); head <= 1000; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, cause) + s.clearTournamentObservationFailure(app.ID) + } + readers.Wait() + require.True(t, s.Ready()) +} + +func TestTournamentObservationHealthIncludesLocalFailureStates(t *testing.T) { + for _, status := range []model.ApplicationStatus{ + model.ApplicationStatus_OK, model.ApplicationStatus_Failed, + model.ApplicationStatus_Corrupted, model.ApplicationStatus_Diverged, + } { + t.Run(status.String(), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.Status = status + for head := uint64(1); head <= tournamentObservationFailureThreshold; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, errors.New("window unavailable")) + } + s.pruneTournamentObservationFailures([]*model.Application{app}) + require.False(t, s.Ready()) + require.Equal(t, status, app.Status) + require.Empty(t, repo.Calls) + }) + } +} diff --git a/internal/prt/observation_projection.go b/internal/prt/observation_projection.go new file mode 100644 index 000000000..a1e42f1a4 --- /dev/null +++ b/internal/prt/observation_projection.go @@ -0,0 +1,272 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + "slices" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +// tournamentEventBatch folds immutable event facts into copies of the stored +// participants, then refreshes mutable views at the same pinned block. +func (s *Service) tournamentEventBatch( + ctx context.Context, app *Application, epoch *Epoch, tournament *Tournament, + adapter TournamentAdapter, events *TournamentEvents, block uint64, +) (*repository.TournamentEventBatch, error) { + address := tournament.Address.Hex() + commitments, _, err := s.repository.ListCommitments(ctx, app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false) + if err != nil { + return nil, fmt.Errorf("loading tournament commitments: %w", err) + } + matches, _, err := s.repository.ListMatches(ctx, app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false) + if err != nil { + return nil, fmt.Errorf("loading tournament matches: %w", err) + } + commitmentByHash := make(map[common.Hash]*Commitment, len(commitments)+len(events.CommitmentJoined)) + for _, stored := range commitments { + projection := *stored + commitmentByHash[stored.Commitment] = &projection + } + matchByHash := make(map[common.Hash]*Match, len(matches)+len(events.MatchCreated)) + unchangedDeletedMatches := make(map[common.Hash]bool, len(matches)) + for _, stored := range matches { + projection := *stored + matchByHash[stored.IDHash] = &projection + if deletedMatchObservationComplete(stored, app.LastTournamentCheckBlock, block) { + unchangedDeletedMatches[stored.IDHash] = true + } + } + batch := &repository.TournamentEventBatch{Tournament: tournament} + for _, event := range events.CommitmentJoined { + joined := &Commitment{ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Commitment: event.Commitment, FinalStateHash: event.FinalStateHash, SubmitterAddress: event.Submitter, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index)} + if previous := commitmentByHash[joined.Commitment]; previous != nil { + if previous.FinalStateHash != joined.FinalStateHash || previous.SubmitterAddress != joined.SubmitterAddress || + previous.BlockNumber != joined.BlockNumber || previous.TxHash != joined.TxHash || previous.LogIndex != joined.LogIndex { + return nil, fmt.Errorf("commitment %s has conflicting join events", joined.Commitment) + } + } else { + commitmentByHash[joined.Commitment] = joined + } + } + for _, event := range events.MatchCreated { + delete(unchangedDeletedMatches, event.MatchIdHash) + created := &Match{ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + IDHash: event.MatchIdHash, CommitmentOne: event.One, CommitmentTwo: event.Two, LeftOfTwo: event.LeftOfTwo, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + EliminableAt: event.EliminableAt, Winner: WinnerCommitment_NONE, DeletionReason: MatchDeletionReason_NOT_DELETED} + if previous := matchByHash[created.IDHash]; previous != nil { + if previous.CommitmentOne != created.CommitmentOne || previous.CommitmentTwo != created.CommitmentTwo || + previous.LeftOfTwo != created.LeftOfTwo || previous.BlockNumber != created.BlockNumber || + previous.TxHash != created.TxHash || previous.LogIndex != created.LogIndex || + previous.EliminableAt != created.EliminableAt { + return nil, fmt.Errorf("match %s has conflicting creation events", created.IDHash) + } + } else { + matchByHash[created.IDHash] = created + } + } + for _, event := range events.MatchAdvanced { + delete(unchangedDeletedMatches, event.MatchIdHash) + if matchByHash[event.MatchIdHash] == nil { + return nil, fmt.Errorf("match advance has no creation event for %x", event.MatchIdHash) + } + position, err := Uint256FromBig(event.SegmentStartPosition) + if err != nil { + return nil, fmt.Errorf("match advance position: %w", err) + } + batch.MatchAdvances = append(batch.MatchAdvances, &MatchAdvanced{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + IDHash: event.MatchIdHash, OtherParent: event.OtherParent, LeftNode: event.LeftNode, + SegmentStartPosition: position, EliminableAt: event.EliminableAt, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + }) + } + for _, event := range events.LeafMatchSealed { + delete(unchangedDeletedMatches, event.MatchIdHash) + match := matchByHash[event.MatchIdHash] + if match == nil { + return nil, fmt.Errorf("leaf seal has no creation event for %x", event.MatchIdHash) + } + seal := &LeafMatchSeal{EliminableAt: event.EliminableAt, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index)} + if match.LeafSeal != nil && *match.LeafSeal != *seal { + return nil, fmt.Errorf("match %s has conflicting leaf seal events", match.IDHash) + } + match.LeafSeal = seal + } + for _, event := range events.MatchDeleted { + delete(unchangedDeletedMatches, event.MatchIdHash) + match := matchByHash[event.MatchIdHash] + if match == nil || match.CommitmentOne != event.One || match.CommitmentTwo != event.Two { + return nil, fmt.Errorf("match deletion does not match its creation pair for %x", event.MatchIdHash) + } + reason, err := MatchDeletionReasonFromUint8(event.Reason) + if err != nil { + return nil, err + } + winner, err := WinnerCommitmentFromUint8(event.WinnerCommitment) + if err != nil { + return nil, err + } + if match.DeletionTxHash != nil && (*match.DeletionTxHash != event.Raw.TxHash || + match.DeletionLogIndex == nil || *match.DeletionLogIndex != uint64(event.Raw.Index) || + match.DeletionBlockNumber != event.Raw.BlockNumber || match.DeletionReason != reason || match.Winner != winner) { + return nil, fmt.Errorf("match %s has conflicting deletion events", match.IDHash) + } + match.DeletionReason, match.Winner = reason, winner + match.DeletionBlockNumber, match.DeletionTxHash = event.Raw.BlockNumber, new(event.Raw.TxHash) + match.DeletionLogIndex = new(uint64(event.Raw.Index)) + } + if err := appendTournamentBondEvents(batch, app, epoch, events); err != nil { + return nil, err + } + for _, commitment := range commitmentByHash { + standing, err := adapter.CommitmentStanding(pinnedCallOpts(ctx, block), commitment.Commitment) + if err != nil { + return nil, fmt.Errorf("reading commitment %s standing: %w", commitment.Commitment, err) + } + if !standing.Joined || standing.FinalState != commitment.FinalStateHash { + return nil, fmt.Errorf("commitment %s standing does not match its join event", commitment.Commitment) + } + commitment.Snapshot = CommitmentSnapshot{AsOfBlock: block, Claimer: standing.Claimer, + ClockRunning: standing.ClockRunning, ClockDeadline: standing.ClockDeadline, ClockAllowance: standing.ClockAllowance} + batch.Commitments = append(batch.Commitments, commitment) + } + for _, match := range matchByHash { + if unchangedDeletedMatches[match.IDHash] { + // Keep the stored certification block. There is no new match view to publish. + continue + } + view, err := adapter.MatchSnapshot(pinnedCallOpts(ctx, block), match.CommitmentOne, match.CommitmentTwo) + if err != nil { + return nil, fmt.Errorf("reading match %s snapshot: %w", match.IDHash, err) + } + match.Snapshot, err = projectMatchSnapshot(view, block, match.DeletionTxHash != nil) + if err != nil { + return nil, fmt.Errorf("match %s: %w", match.IDHash, err) + } + batch.Matches = append(batch.Matches, match) + } + // Stable ordering makes the batch deterministic without changing event chronology. + slices.SortFunc(batch.Commitments, func(a, b *Commitment) int { return a.Commitment.Cmp(b.Commitment) }) + slices.SortFunc(batch.Matches, func(a, b *Match) int { return a.IDHash.Cmp(b.IDHash) }) + return batch, nil +} + +// Deleted matches have no mutable contract state. Reuse only a snapshot that +// was validated after deletion and committed within the published observation. +func deletedMatchObservationComplete(match *Match, cursor, head uint64) bool { + if match.DeletionTxHash == nil || *match.DeletionTxHash == (common.Hash{}) || match.DeletionLogIndex == nil || + match.DeletionBlockNumber == 0 || match.DeletionBlockNumber > match.Snapshot.AsOfBlock || + match.Snapshot.AsOfBlock > cursor || cursor > head || + match.DeletionReason == MatchDeletionReason_NOT_DELETED || !slices.Contains(MatchDeletionReasonAllValues, match.DeletionReason) || + !slices.Contains(WinnerCommitmentAllValues, match.Winner) { + return false + } + return match.Snapshot == (MatchSnapshot{AsOfBlock: match.Snapshot.AsOfBlock, + Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone}) +} + +func appendTournamentBondEvents(batch *repository.TournamentEventBatch, app *Application, epoch *Epoch, events *TournamentEvents) error { + for _, event := range events.PartialBondRefund { + value, err := Uint256FromBig(event.Value) + if err != nil { + return fmt.Errorf("partial bond refund value: %w", err) + } + batch.BondEvents = append(batch.BondEvents, &BondEvent{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: batch.Tournament.Address, + Type: BondEventPartialRefund, BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + Refund: &PartialBondRefund{Recipient: event.Recipient, Value: value, Success: event.Success}, + }) + } + for _, event := range events.BondRecovered { + payment, err := Uint256FromBig(event.Payment) + if err != nil { + return fmt.Errorf("bond recovery payment: %w", err) + } + burned, err := Uint256FromBig(event.Burned) + if err != nil { + return fmt.Errorf("bond recovery burn: %w", err) + } + batch.BondEvents = append(batch.BondEvents, &BondEvent{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: batch.Tournament.Address, + Type: BondEventRecovered, BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + Recovery: &BondRecovered{Commitment: event.Commitment, Claimer: event.Claimer, Payment: payment, Burned: burned}, + }) + } + return nil +} + +func projectMatchSnapshot(view ObservedMatchSnapshot, block uint64, deleted bool) (MatchSnapshot, error) { + result := MatchSnapshot{AsOfBlock: block, Phase: view.Phase, + TimeoutOutcome: view.TimeoutOutcome, DeferredCharge: view.DeferredCharge} + if deleted && view.Phase != MatchPhaseUninitialized { + return MatchSnapshot{}, fmt.Errorf("deleted match has phase %s", view.Phase) + } + switch view.Phase { + case MatchPhaseBisecting: + if view.Bisecting == nil || view.ReadyToSeal != nil || view.Sealed != nil { + return MatchSnapshot{}, fmt.Errorf("invalid bisecting match payload") + } + v := view.Bisecting + position, err := Uint256FromBig(v.SegmentStartPosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.SegmentStartCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Bisection = &MatchBisectionSnapshot{RevealingParent: v.RevealingParent, WaitingLeft: v.WaitingLeft, + WaitingRight: v.WaitingRight, SegmentStartPosition: position, SegmentStartCycle: cycle, + CurrentHeight: new(v.CurrentHeight), Responder: v.Responder} + case MatchPhaseReadyToSeal: + if view.ReadyToSeal == nil || view.Bisecting != nil || view.Sealed != nil { + return MatchSnapshot{}, fmt.Errorf("invalid ready-to-seal match payload") + } + v := view.ReadyToSeal + position, err := Uint256FromBig(v.SegmentStartPosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.SegmentStartCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Bisection = &MatchBisectionSnapshot{RevealingParent: v.RevealingParent, WaitingLeft: v.WaitingLeft, + WaitingRight: v.WaitingRight, SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: v.Responder} + case MatchPhaseSealed: + if view.Sealed == nil || view.Bisecting != nil || view.ReadyToSeal != nil { + return MatchSnapshot{}, fmt.Errorf("invalid sealed match payload") + } + v := view.Sealed + position, err := Uint256FromBig(v.DivergencePosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.DivergenceCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Sealed = &MatchSealedSnapshot{AgreeState: v.AgreeState, DivergencePosition: position, DivergenceCycle: cycle, + FinalStateOne: v.FinalStateOne, FinalStateTwo: v.FinalStateTwo} + case MatchPhaseUninitialized: + if !deleted || view.Bisecting != nil || view.ReadyToSeal != nil || view.Sealed != nil || + view.TimeoutOutcome != MatchTimeoutNone || view.DeferredCharge != 0 { + return MatchSnapshot{}, fmt.Errorf("uninitialized match has no matching deletion or has live payload") + } + default: + return MatchSnapshot{}, fmt.Errorf("unknown match phase %s", view.Phase) + } + return result, nil +} diff --git a/internal/prt/observation_retirement_test.go b/internal/prt/observation_retirement_test.go new file mode 100644 index 000000000..4d015583b --- /dev/null +++ b/internal/prt/observation_retirement_test.go @@ -0,0 +1,86 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentRetirementRequiresImmutableCommittedSnapshot(t *testing.T) { + base := Tournament{Snapshot: TournamentSnapshot{AsOfBlock: 50, FinishedAtBlock: 40, + Standing: TournamentStandingRootWinner, BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}}} + for _, test := range []struct { + name string + change func(*Tournament) + retired bool + }{ + {"recovered root", func(*Tournament) {}, true}, + {"failed root", func(v *Tournament) { + v.Snapshot.Standing, v.Snapshot.BondRecovery.Disposition = TournamentStandingRootFailed, BondDispositionNoWinner + }, true}, + {"unfinished", func(v *Tournament) { v.Snapshot.FinishedAtBlock = 0 }, false}, + {"finish not covered", func(v *Tournament) { v.Snapshot.FinishedAtBlock = 51 }, false}, + {"snapshot not committed", func(v *Tournament) { v.Snapshot.AsOfBlock = 51 }, false}, + {"recoverable bond", func(v *Tournament) { v.Snapshot.BondRecovery.Disposition = BondDispositionRecoverable }, false}, + {"inner winner still expires", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerWinner + }, false}, + {"expired winner recovered", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerEliminableWinnerExpired + }, true}, + {"missing child creation", func(v *Tournament) { + v.Level, v.Snapshot.Standing = 1, TournamentStandingInnerEliminableNoWinner + }, false}, + {"child no winner", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerEliminableNoWinner + v.Snapshot.BondRecovery.Disposition = BondDispositionNoWinner + }, true}, + } { + t.Run(test.name, func(t *testing.T) { + value := base + test.change(&value) + require.Equal(t, test.retired, tournamentObservationComplete(&value, 50, 100)) + }) + } + require.False(t, tournamentObservationComplete(&base, 50, 49), "a later snapshot cannot decide retirement at an older head") +} + +func TestRetiredParentStillObservesMutableChildren(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.Status = EpochStatus_ClaimAccepted + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := &Tournament{Address: *epoch.TournamentAddress, MaxLevel: 2, Snapshot: TournamentSnapshot{ + AsOfBlock: 50, FinishedAtBlock: 40, Standing: TournamentStandingRootWinner, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}, + }} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), parent.Address.Hex()).Return(parent, nil).Once() + child := f.tournament(epoch, common.HexToAddress("0x101"), 1, 2, 45, 100, &TournamentEvents{}, nil) + retiredChild := &Tournament{Address: common.HexToAddress("0x102"), Level: 1, MaxLevel: 2, + CreationEvent: &TournamentCreationEvent{BlockNumber: 10}, Snapshot: TournamentSnapshot{ + AsOfBlock: 50, FinishedAtBlock: 45, Standing: TournamentStandingInnerEliminableNoWinner, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionNoWinner}, + }} + f.children(epoch, parent, child, retiredChild) + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), retiredChild.Address.Hex()).Return(retiredChild, nil).Once() + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Address == child.Address && + batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(50), parent.Snapshot.AsOfBlock) + require.Equal(t, uint64(50), retiredChild.Snapshot.AsOfBlock) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", parent.Address) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", retiredChild.Address) +} diff --git a/internal/prt/observation_shutdown_test.go b/internal/prt/observation_shutdown_test.go new file mode 100644 index 000000000..b4a34d665 --- /dev/null +++ b/internal/prt/observation_shutdown_test.go @@ -0,0 +1,314 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + shutdownCancellationCase = "shutdown cancellation" + shutdownDeadlineCase = "shutdown deadline" + shutdownMixedDeadlineCase = "shutdown cancellation and deadline" + runningCancellationCase = "running cancellation" +) + +func TestTournamentPublicationShutdown(t *testing.T) { + dbErr := errors.New("database write failed") + for _, test := range []struct { + name string + cause error + wantErr bool + }{ + {"canceled write", fmt.Errorf("write: %w", context.Canceled), false}, + {"database failure", dbErr, true}, + {"mixed failure", errors.Join(context.Canceled, dbErr), true}, + {"deadline", context.DeadlineExceeded, true}, + } { + for _, throughServe := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/serve=%t", test.name, throughServe), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + f := newObserverCheckpointFixture(t) + var logs bytes.Buffer + require.NoError(t, service.InitTickServiceTemplate(&f.s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: "prt", Logger: slog.New(slog.NewTextHandler(&logs, nil))}, + }, f.s)) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.repo.On("ListApplications", ctx, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + root := f.tournament(epoch, *epoch.TournamentAddress, 0, 2, 0, 100, &TournamentEvents{}, nil) + f.children(epoch, root) + f.repo.On("StoreTournamentEvents", ctx, f.app.ID, mock.Anything, uint64(100)). + Run(func(mock.Arguments) { cancel() }).Return(test.cause).Once() + pending := pendingTournamentTransaction{Action: tournamentActionStage, Hash: common.HexToHash("0x999")} + f.s.pendingTransactions[f.app.ID] = pending + + if throughServe { + require.ErrorIs(t, f.s.Serve(ctx), context.Canceled) + require.Equal(t, test.wantErr, strings.Contains(logs.String(), "level=ERROR msg=Tick"), logs.String()) + } else { + reschedule, err := f.s.Tick(ctx) + require.False(t, reschedule) + if test.wantErr { + require.ErrorIs(t, err, test.cause) + } else { + require.NoError(t, err) + } + } + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock, "an interrupted write must not publish its cursor") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Equal(t, pending, f.s.pendingTransactions[f.app.ID], "failed observation must not release a pending action") + require.Equal(t, test.wantErr, len(f.s.observationFailures) == 1) + }) + } + } +} + +func TestTournamentObservationShutdownLogs(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + stopping bool + cause error + wantLog bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"joined shutdown cancellations", true, errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), false}, + {"shutdown database failure", true, dbErr, true}, + {"shutdown mixed database failure", true, fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {shutdownMixedDeadlineCase, true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + for _, operation := range []string{ + "new descriptor", "stored descriptor", "new standing", "stored standing", "new result", "stored result", + "projection load", "children list", "epochs list", "acceptance receipt", "acceptance update", + } { + t.Run(test.name+"/"+operation, func(t *testing.T) { + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, factory: &adapterFactoryMock{}, + consensus: &daveConsensusAdapterMock{}, client: ðClientMock{}, app: prtRevertTestApp()} + f.app.LastTournamentCheckBlock = 50 + f.s.adapterFactory, f.s.client = f.factory, f.client + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.client.AssertExpectations(t) + }) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + err := runObservationWithIOError(ctx, t, f, operation, test.cause) + require.ErrorIs(t, err, test.cause) + require.Equal(t, test.wantLog, strings.Contains(output.String(), "level=ERROR"), output.String()) + if test.wantLog { + require.Contains(t, output.String(), "application="+f.app.Name) + } + }) + } + } +} + +func runObservationWithIOError(ctx context.Context, t *testing.T, f *observerCheckpointFixture, operation string, cause error) error { + t.Helper() + epoch := checkpointEpoch(0, "0x100") + if operation == "epochs list" { + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), cause).Once() + _, err := f.s.checkEpochs(ctx, f.app, 100) + return err + } + if strings.HasPrefix(operation, "acceptance") { + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + tournament := &model.Tournament{Address: *epoch.TournamentAddress, Snapshot: model.TournamentSnapshot{FinishedAtBlock: 50}} + if operation == "acceptance update" { + f.app.LastTournamentCheckBlock = 100 + f.acceptance(epoch, tournament, cause) + } else { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Once() + f.client.On("TransactionReceipt", mock.Anything, *epoch.ClaimTransactionHash). + Return((*types.Receipt)(nil), cause).Once() + } + _, err := f.s.reconcileAcceptedEpochs(ctx, f.app, []*model.Epoch{epoch}, f.consensus, 100) + return err + } + + address := *epoch.TournamentAddress + adapter := &tournamentAdapterMock{} + t.Cleanup(func() { adapter.AssertExpectations(t) }) + var stored *model.Tournament + if !strings.HasPrefix(operation, "new ") { + stored = &model.Tournament{Address: address, Level: 1, MaxLevel: 3, Kind: model.TournamentKindNonLeaf} + } + var loadError error + if operation == "projection load" { + loadError = cause + } + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(stored, loadError).Once() + if loadError == nil { + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + descriptor := TournamentDescriptor{BaseCycle: big.NewInt(0), Level: 1, Kind: model.TournamentKindNonLeaf} + var descriptorError error + if strings.HasSuffix(operation, "descriptor") { + descriptorError = cause + } + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(descriptor, descriptorError).Once() + if descriptorError == nil { + var standingError error + if strings.HasSuffix(operation, "standing") { + standingError = cause + } + standing := TournamentStanding{State: model.TournamentStandingAwaitingClosure} + if strings.HasSuffix(operation, "result") { + standing = TournamentStanding{State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: 90} + adapter.On("InnerResult", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(InnerResult{}, cause).Once() + } + adapter.On("Standing", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(standing, standingError).Once() + } + if operation == "children list" { + expectTournamentAuxiliaryReads(adapter, mock.MatchedBy(resultCallOptsAtBlock(100)), 1, model.TournamentStandingAwaitingClosure) + adapter.On("RetrieveAllEvents", mock.Anything).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(50))).Return(zeroStructuralEventCounts(), nil).Once() + adapter.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(50))). + Return(canonicalBondRecovery(model.BondDispositionTournamentRunning, common.Address{}, 0), nil).Once() + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, address) + f.repo.On("ListTournaments", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Tournament{}, uint64(0), cause).Once() + } + } + _, err := f.s.gatherTournamentData(ctx, f.app, epoch, 1, nil, nil, address, 3, 100) + return err +} + +func TestTournamentSubmissionShutdownLogs(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, action := range []struct { + name string + run func(*Service, context.Context, *model.Application, *model.Epoch, error) error + }{ + {"join", func(s *Service, ctx context.Context, app *model.Application, epoch *model.Epoch, err error) error { + return s.handleJoinTournamentRevert(ctx, app, epoch, nil, err) + }}, + {"stage", (*Service).handleStageTournamentResultRevert}, + {"accept", (*Service).handleAcceptTournamentResultRevert}, + } { + for _, test := range []struct { + name string + stopping bool + cause error + wantLog bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"shutdown mixed failure", true, errors.Join(context.Canceled, dbErr), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {shutdownMixedDeadlineCase, true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + t.Run(action.name+"/"+test.name, func(t *testing.T) { + s, _ := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + err := action.run(s, ctx, prtRevertTestApp(), prtRevertTestEpoch(), test.cause) + require.ErrorIs(t, err, test.cause) + require.Equal(t, test.wantLog, strings.Contains(output.String(), "level=ERROR"), output.String()) + }) + } + } +} + +func TestTournamentJoinReadsShutdownLogs(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, operation := range []string{"commitment record", "commitment standing", "bond value"} { + for _, test := range []struct { + name string + stopping bool + cause error + wantLog bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"shutdown mixed failure", true, errors.Join(context.Canceled, dbErr), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {shutdownMixedDeadlineCase, true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + t.Run(operation+"/"+test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app, epoch := prtRevertTestApp(), resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + var recordErr error + if operation == "commitment record" { + recordErr = test.cause + } + repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index, + epoch.TournamentAddress.Hex(), epoch.Commitment.Hex()).Return((*model.Commitment)(nil), recordErr).Once() + if recordErr == nil { + factory := &adapterFactoryMock{} + adapter := &tournamentAdapterMock{} + s.adapterFactory = factory + factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + var standingErr error + if operation == "commitment standing" { + standingErr = test.cause + } + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + adapter.On("CommitmentStanding", opts, [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, standingErr).Once() + if standingErr == nil { + adapter.On("Descriptor", opts). + Return(TournamentDescriptor{Height: model.Log2EpochComputationHashLeafCount}, nil).Once() + adapter.On("BondValue", opts).Return((*big.Int)(nil), test.cause).Once() + } + t.Cleanup(func() { factory.AssertExpectations(t); adapter.AssertExpectations(t) }) + } + _, err := s.reactToTournament(ctx, app, epoch, 100) + require.ErrorIs(t, err, test.cause) + require.Equal(t, test.wantLog, strings.Contains(output.String(), "level=ERROR"), output.String()) + repo.AssertExpectations(t) + }) + } + } +} diff --git a/internal/prt/observer_checkpoint_test.go b/internal/prt/observer_checkpoint_test.go new file mode 100644 index 000000000..de01cc2c0 --- /dev/null +++ b/internal/prt/observer_checkpoint_test.go @@ -0,0 +1,445 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "strings" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type observerCheckpointFixture struct { + t *testing.T + s *Service + repo *prtRepositoryMock + factory *adapterFactoryMock + consensus *daveConsensusAdapterMock + client *ethClientMock + app *Application +} + +func newObserverCheckpointFixture(t *testing.T) *observerCheckpointFixture { + t.Helper() + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, factory: &adapterFactoryMock{}, + consensus: &daveConsensusAdapterMock{}, client: ðClientMock{}, app: prtRevertTestApp()} + f.app.LastTournamentCheckBlock = 50 + f.app.LastEpochCheckBlock = 100 + s.adapterFactory, s.client = f.factory, f.client + f.factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Return(f.consensus, nil) + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.client.AssertExpectations(t) + }) + return f +} + +func (f *observerCheckpointFixture) epochs(epochs ...*Epoch) { + f.t.Helper() + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return(epochs, uint64(len(epochs)), nil).Once() +} + +func checkpointEpoch(index uint64, address string) *Epoch { + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + epoch.Index = index + epoch.LastBlock = 10 + epoch.TournamentAddress = new(common.HexToAddress(address)) + return epoch +} + +func (f *observerCheckpointFixture) tournament( + epoch *Epoch, address common.Address, level, levels, finish, scanEnd uint64, events *TournamentEvents, scanError error, +) *Tournament { + f.t.Helper() + tournament := &Tournament{ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: address, + Level: level, MaxLevel: levels, StartInstant: epoch.LastBlock, Snapshot: TournamentSnapshot{FinishedAtBlock: finish}} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(tournament, nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + kind := TournamentKindNonLeaf + if level+1 == levels { + kind = TournamentKindLeaf + } + tournament.Kind = kind + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(scanEnd))). + Return(TournamentDescriptor{BaseCycle: big.NewInt(0), Level: level, Kind: kind, StartInstant: epoch.LastBlock}, nil).Once() + candidate := common.HexToHash("0x777") + if epoch.Commitment != nil { + candidate = *epoch.Commitment + } + state := TournamentStandingRootWinner + if level != uint64(RootLevel) { + state = TournamentStandingInnerWinner + } + finishedAt := finish + if finishedAt == 0 { + finishedAt = scanEnd + } + adapter.On("Standing", mock.MatchedBy(resultCallOptsAtBlock(scanEnd))).Return(TournamentStanding{ + State: state, HasCandidate: true, Candidate: candidate, FinishedAt: finishedAt, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.MatchedBy(resultCallOptsAtBlock(scanEnd)), TournamentLevel(level), state) + end := scanEnd + start := max(epoch.LastBlock, f.app.LastTournamentCheckBlock+1) + if start <= end { + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts != nil && opts.Context != nil && opts.Start == start && opts.End != nil && *opts.End == end + })).Return(events, scanError).Once() + } + if scanError == nil { + if start > tournament.StartInstant { + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(start-1))). + Return(zeroStructuralEventCounts(), nil).Once() + adapter.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(start-1))). + Return(canonicalBondRecovery(BondDispositionTournamentRunning, common.Address{}, 0), nil).Once() + } + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(end))). + Return(structuralCountsForEvents(events), nil).Once() + f.emptyParticipants(epoch, address) + } + f.t.Cleanup(func() { adapter.AssertExpectations(f.t) }) + return tournament +} + +func expectTournamentAuxiliaryReads(adapter *tournamentAdapterMock, opts any, level TournamentLevel, state TournamentStandingState) { + disposition := BondDispositionTournamentRunning + if isTerminalTournamentStanding(state) { + disposition = BondDispositionRecoverable + if state == TournamentStandingRootFailed || state == TournamentStandingInnerEliminableNoWinner { + disposition = BondDispositionNoWinner + } + } + if level != RootLevel { + result := InnerResult{Disposition: InnerTournamentUnsettled} + switch state { + case TournamentStandingInnerWinner: + result = InnerResult{Disposition: InnerTournamentWinner, PausedAllowance: 1} + case TournamentStandingInnerEliminableWinnerExpired, TournamentStandingInnerEliminableNoWinner: + result.Disposition = InnerTournamentEliminable + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, TournamentStandingRootFailed: + } + adapter.On("InnerResult", opts).Return(result, nil).Once() + } + adapter.On("BondRecovery", opts).Return(canonicalBondRecovery(disposition, common.HexToAddress("0x777"), 0), nil).Once() +} + +func (f *observerCheckpointFixture) emptyParticipants(epoch *Epoch, address common.Address) { + f.t.Helper() + filterAddress := address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*Commitment{}, uint64(0), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*Match{}, uint64(0), nil).Once() +} + +func (f *observerCheckpointFixture) children(epoch *Epoch, parent *Tournament, children ...*Tournament) { + f.t.Helper() + for _, child := range children { + child.ParentTournamentAddress = &parent.Address + child.ParentMatchIDHash = new(common.HexToHash("0xabc")) + } + f.repo.On("ListTournaments", mock.Anything, f.app.Name, repository.TournamentFilter{ + EpochIndex: &epoch.Index, ParentTournamentAddress: &parent.Address, Level: new(parent.Level + 1), + }, repository.Pagination{}, false).Return(children, uint64(len(children)), nil).Once() +} + +func (f *observerCheckpointFixture) acceptance(epoch *Epoch, tournament *Tournament, writeError error) { + f.t.Helper() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Once() + log := types.Log{Address: f.app.IConsensusAddress, TxHash: *epoch.ClaimTransactionHash, BlockNumber: 100} + receipt := &types.Receipt{TxHash: *epoch.ClaimTransactionHash, BlockNumber: big.NewInt(100), + Status: types.ReceiptStatusSuccessful, Logs: []*types.Log{&log}} + f.client.On("TransactionReceipt", mock.Anything, *epoch.ClaimTransactionHash).Return(receipt, nil).Once() + f.consensus.On("ParseEpochSealed", log).Return(&idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: new(big.Int).SetUint64(epoch.Index + 1), InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, Raw: log, + }, nil).Once() + f.repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, f.app.ID, epoch.Index, epoch.ClaimTransactionHash). + Run(func(mock.Arguments) { require.Equal(f.t, uint64(100), f.app.LastTournamentCheckBlock) }).Return(writeError).Once() +} + +func (f *observerCheckpointFixture) unaccepted(epoch *Epoch, finish uint64) { + f.t.Helper() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return( + &Tournament{Address: *epoch.TournamentAddress, Snapshot: TournamentSnapshot{ + FinishedAtBlock: finish, WinnerCommitment: epoch.Commitment, + }}, nil).Once() +} + +func TestObserverCheckpointCommitsAllRootsAndChildren(t *testing.T) { + f := newObserverCheckpointFixture(t) + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + first.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(first, second) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + childAddress := common.HexToAddress("0x101") + childEpoch := *first + childEpoch.LastBlock = 60 // The child is created inside the parent scan window, 51..100. + parentEvents := &TournamentEvents{NewInnerTournament: []*itournament.ITournamentNewInnerTournament{{ + ChildTournament: childAddress, MatchIdHash: common.HexToHash("0xabc"), + Raw: types.Log{BlockNumber: childEpoch.LastBlock}, + }}, PartialBondRefund: []*itournament.ITournamentPartialBondRefund{{Value: new(big.Int)}}} + parent := f.tournament(first, *first.TournamentAddress, 0, 2, 90, 100, parentEvents, nil) + child := f.tournament(&childEpoch, childAddress, 1, 2, 88, 100, &TournamentEvents{}, nil) + f.children(first, parent, child) + secondRoot := f.tournament(second, *second.TournamentAddress, 0, 2, 95, 100, &TournamentEvents{}, nil) + lastChild := f.tournament(second, common.HexToAddress("0x201"), 1, 2, 89, 100, &TournamentEvents{}, nil) + f.children(second, secondRoot, lastChild) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + if len(batches) != 4 { + return false + } + for i, expected := range []*Tournament{parent, child, secondRoot, lastChild} { + if batches[i].Tournament.Address != expected.Address { + return false + } + } + return true + }), uint64(100)).Return(nil).Once() + f.acceptance(first, parent, nil) + f.unaccepted(second, 95) + deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) +} + +func TestObserverCheckpointKeepsFinishedSubtreesCurrent(t *testing.T) { + for _, finish := range []uint64{0, 49, 50, 51} { + t.Run(new(big.Int).SetUint64(finish).String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := f.tournament(epoch, *epoch.TournamentAddress, 0, 2, finish, 100, &TournamentEvents{}, nil) + child := f.tournament(epoch, common.HexToAddress("0x101"), 1, 2, finish, 100, &TournamentEvents{}, nil) + f.children(epoch, parent, child) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 2 + }), uint64(100)).Return(nil).Once() + f.unaccepted(epoch, finish) + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + finishedLogs := strings.Count(output.String(), "Found finished tournament") + if finish == 0 { + require.Equal(t, 2, finishedLogs, "each newly finished clone reports its transition") + } else { + require.Zero(t, finishedLogs, "a current refresh must not repeat the finish announcement") + } + }) + } +} + +func TestGatherTournamentDataKeepsNewFinishedProjectionBelowCursor(t *testing.T) { + // This checks the gather helper's contract. Normal atomic publication does + // not advance a cursor without the corresponding tournament projection. + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 50 + epoch := checkpointEpoch(0, "0x100") + address := *epoch.TournamentAddress + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), address.Hex()). + Return((*Tournament)(nil), nil).Once() + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: TournamentKindLeaf, StartInstant: epoch.LastBlock, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: TournamentStandingRootWinner, HasCandidate: true, Candidate: *epoch.Commitment, + FinalState: *epoch.MachineHash, FinishedAt: 49, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, TournamentStandingRootWinner) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == epoch.LastBlock && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, app: app} + f.emptyParticipants(epoch, address) + factory := &adapterFactoryMock{} + factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + s.adapterFactory = factory + + batches, err := s.gatherTournamentData(t.Context(), app, epoch, RootLevel, nil, nil, address, 1, 100) + require.NoError(t, err) + require.Len(t, batches, 1, "a new projection must reach the caller even when no event scan is needed") + require.Equal(t, address, batches[0].Tournament.Address) + require.Equal(t, uint64(49), batches[0].Tournament.Snapshot.FinishedAtBlock) + require.Equal(t, epoch.Commitment, batches[0].Tournament.Snapshot.WinnerCommitment) + require.Equal(t, epoch.MachineHash, batches[0].Tournament.Snapshot.FinalStateHash) + require.Equal(t, uint64(50), app.LastTournamentCheckBlock) + require.Len(t, repo.Calls, 3, "gather reads projections but must not publish them or their cursor") + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + factory.AssertExpectations(t) +} + +func TestObserverCheckpointRetriesWholeWindowAfterFailure(t *testing.T) { + const transactionFailure = "transaction" + for _, fault := range []string{"child RPC", "last root RPC", transactionFailure} { + t.Run(fault, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + failure := errors.New(fault) + for attempt := range 2 { + f.epochs(first, second) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := f.tournament(first, *first.TournamentAddress, 0, 2, 0, 100, &TournamentEvents{}, nil) + var childError, rootError, writeError error + if attempt == 0 { + switch fault { + case "child RPC": + childError = failure + case "last root RPC": + rootError = failure + case transactionFailure: + writeError = failure + } + } + child := f.tournament(first, common.HexToAddress("0x101"), 1, 2, 90, 100, &TournamentEvents{}, childError) + f.children(first, parent, child) + if childError == nil { + lastRoot := f.tournament(second, *second.TournamentAddress, 0, 2, 95, 100, &TournamentEvents{}, rootError) + if rootError == nil { + f.children(second, lastRoot) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)). + Return(writeError).Once() + } + } + if attempt == 1 { + f.unaccepted(first, 100) + } + deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.Zero(t, parent.Snapshot.FinishedAtBlock, "gather must not mutate repository-owned projections") + if attempt == 0 { + require.ErrorIs(t, err, failure) + require.True(t, deferActions) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + if fault != transactionFailure { + f.repo.AssertNotCalled(t, "StoreTournamentEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + } else { + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + } + } + }) + } +} + +func TestObserverCheckpointObservesUnpreparedRootsWithinIngestionFloor(t *testing.T) { + for _, status := range []EpochStatus{EpochStatus_Closed, EpochStatus_InputsProcessed} { + t.Run(status.String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + ready, unprepared := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + unprepared.Status, unprepared.LastBlock = status, 80 + unprepared.Commitment, unprepared.MachineHash, unprepared.TxBufferDataBlock = nil, nil, nil + f.epochs(ready, unprepared) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(ready, *ready.TournamentAddress, 0, 1, 0, 100, &TournamentEvents{}, nil) + f.tournament(unprepared, *unprepared.TournamentAddress, 0, 1, 0, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + f.unaccepted(ready, 100) + _, err := f.s.checkEpochs(t.Context(), f.app, 120) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }) + } + t.Run("ingestion head limits prepared roots", func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 0, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + f.unaccepted(epoch, 100) + _, err := f.s.checkEpochs(t.Context(), f.app, 120) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }) +} + +func TestObserverCheckpointRetriesAcceptanceWithoutNewWindow(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + tournament := f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + failure := errors.New("acceptance write failed") + f.acceptance(epoch, tournament, failure) + deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.ErrorIs(t, err, failure) + require.True(t, deferActions) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.epochs(epoch) + f.acceptance(epoch, tournament, nil) + deferActions, err = f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.False(t, deferActions) + f.repo.AssertNumberOfCalls(t, "StoreTournamentEvents", 1) + f.consensus.AssertNumberOfCalls(t, "TournamentLevelCount", 1) +} + +func TestObserverCheckpointIncludesUnpreparedCreationBlock(t *testing.T) { + for _, creation := range []uint64{0, 80} { + t.Run(new(big.Int).SetUint64(creation).String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastTournamentCheckBlock = 0 + ready, unprepared := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + ready.LastBlock = creation + unprepared.Status, unprepared.LastBlock = EpochStatus_Closed, creation + unprepared.Commitment, unprepared.MachineHash, unprepared.TxBufferDataBlock = nil, nil, nil + f.epochs(ready, unprepared) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(ready, *ready.TournamentAddress, 0, 1, 100, 100, &TournamentEvents{}, nil) + f.tournament(unprepared, *unprepared.TournamentAddress, 0, 1, 100, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { return len(batches) == 2 }), uint64(100)). + Return(nil).Once() + f.unaccepted(ready, 100) + _, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }) + } +} + +func TestObserverCheckpointDoesNotMoveBackward(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastTournamentCheckBlock = 100 + f.epochs(checkpointEpoch(0, "0x100")) + _, err := f.s.checkEpochs(t.Context(), f.app, 90) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.repo.AssertNotCalled(t, "StoreTournamentEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "TournamentLevelCount", mock.Anything) +} diff --git a/internal/prt/observer_projection_test.go b/internal/prt/observer_projection_test.go new file mode 100644 index 000000000..b2e01969a --- /dev/null +++ b/internal/prt/observer_projection_test.go @@ -0,0 +1,296 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "encoding/json" + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + unknownEnumValue = "UNKNOWN" + projectionCreateCase = "create" + projectionUpdateCase = "update" + unknownStandingCase = "unknown standing" +) + +func TestTournamentProjectionPreservesWinnerPresence(t *testing.T) { + for _, test := range []struct { + name string + state model.TournamentStandingState + level TournamentLevel + winner bool + }{ + {name: "failed root", state: model.TournamentStandingRootFailed}, + {name: "root result exists", state: model.TournamentStandingRootWinner, winner: true}, + {name: "inner without winner", state: model.TournamentStandingInnerEliminableNoWinner, level: 1}, + {name: "inner result exists", state: model.TournamentStandingInnerWinner, level: 1, winner: true}, + {name: "expired inner winner", state: model.TournamentStandingInnerEliminableWinnerExpired, level: 1}, + } { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(test.name+"/"+operation, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo := newPRTServiceMock() + descriptor := TournamentDescriptor{BaseCycle: big.NewInt(0), Level: uint64(test.level), Height: 7, + Kind: model.TournamentKindNonLeaf} + hasCandidate := test.winner || test.state == model.TournamentStandingInnerEliminableWinnerExpired + standing := TournamentStanding{State: test.state, FinishedAt: 9, HasCandidate: hasCandidate} + if hasCandidate { + standing.Candidate = *epoch.Commitment + } + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(descriptor, nil).Once() + adapter.On("Standing", opts).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, test.level, test.state) + var stored *model.Tournament + if update { + tournament := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, + Level: uint64(test.level), Height: descriptor.Height, + Kind: model.TournamentKindNonLeaf, Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + require.NoError(t, service.refreshTournament(t.Context(), app, epoch, test.level, adapter, tournament, 10)) + stored = tournament + } else { + tournament, err := service.readTournament(t.Context(), app, epoch, test.level, + nil, nil, *epoch.TournamentAddress, adapter, 3, 10) + require.NoError(t, err) + require.NotNil(t, tournament) + stored = tournament + } + require.NotNil(t, stored) + require.Equal(t, uint64(9), stored.Snapshot.FinishedAtBlock) + data, err := json.Marshal(stored) + require.NoError(t, err) + var projection map[string]any + require.NoError(t, json.Unmarshal(data, &projection)) + projection = projection["snapshot"].(map[string]any) + require.Equal(t, uint64(10), stored.Snapshot.AsOfBlock) + require.Equal(t, test.state, stored.Snapshot.Standing) + if hasCandidate { + require.Equal(t, epoch.Commitment, stored.Snapshot.Candidate) + } else { + require.Nil(t, stored.Snapshot.Candidate) + } + if test.winner { + require.Equal(t, epoch.Commitment, stored.Snapshot.WinnerCommitment) + require.NotNil(t, stored.Snapshot.FinalStateHash, "a zero final-state hash is a value, not absence") + require.Zero(t, *stored.Snapshot.FinalStateHash) + require.Equal(t, epoch.Commitment.Hex(), projection["winner_commitment"]) + require.Equal(t, (common.Hash{}).Hex(), projection["final_state_hash"]) + } else { + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + require.Nil(t, projection["winner_commitment"]) + require.Nil(t, projection["final_state_hash"]) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls, "projection reads must not write tournament state") + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + }) + } + } +} + +func TestTournamentProjectionClearsNonTerminalResult(t *testing.T) { + for _, test := range []struct { + name string + state model.TournamentStandingState + }{ + {name: "active matches", state: model.TournamentStandingMatchesActive}, + {name: "awaiting closure", state: model.TournamentStandingAwaitingClosure}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Log2Step: 20, Height: 7, + Kind: model.TournamentKindNonLeaf, Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: test.state}, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, test.state) + + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10)) + require.Nil(t, projection.Snapshot.WinnerCommitment) + require.Nil(t, projection.Snapshot.FinalStateHash) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } +} + +func TestTournamentProjectionRejectsChangedStoredGeometry(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*model.Tournament) + }{ + {name: "level", mutate: func(p *model.Tournament) { p.Level++ }}, + {name: "stride", mutate: func(p *model.Tournament) { p.Log2Step++ }}, + {name: "height", mutate: func(p *model.Tournament) { p.Height++ }}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Log2Step: 20, Height: 7} + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(10))).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + test.mutate(projection) + before := *projection + + err := s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10) + require.ErrorContains(t, err, "descriptor does not match stored geometry") + require.Equal(t, before, *projection) + require.Empty(t, repo.Calls) + adapter.AssertNotCalled(t, "Standing", mock.Anything) + adapter.AssertExpectations(t) + }) + } +} + +func TestTournamentProjectionLeavesReadErrorsUnchanged(t *testing.T) { + for _, test := range []struct { + name string + standing TournamentStanding + readErr error + }{ + {name: "standing RPC error", readErr: errors.New("standing RPC unavailable")}, + {name: unknownStandingCase, standing: TournamentStanding{State: unknownEnumValue}}, + {name: "future result", standing: TournamentStanding{State: model.TournamentStandingRootWinner, FinishedAt: 11}}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, + Log2Step: 20, Height: 7, Kind: model.TournamentKindNonLeaf, + Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + before := *projection + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, + Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + adapter.On("Standing", opts).Return(test.standing, test.readErr).Once() + + err := s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10) + require.Error(t, err) + if test.readErr != nil { + require.ErrorIs(t, err, test.readErr) + } + require.Equal(t, before, *projection) + require.Same(t, before.Snapshot.WinnerCommitment, projection.Snapshot.WinnerCommitment) + require.Same(t, before.Snapshot.FinalStateHash, projection.Snapshot.FinalStateHash) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } +} + +func TestJoinChecksSupportedRootGeometry(t *testing.T) { + for _, height := range []uint64{model.Log2EpochComputationHashLeafCount, 7} { + t.Run(new(big.Int).SetUint64(height).String(), func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + service, repo := newPRTServiceMock() + service.submissionTimeout = time.Second + service.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: common.HexToAddress("0x600")}) + factory := &adapterFactoryMock{} + service.adapterFactory = factory + adapter := &tournamentAdapterMock{} + factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index, + epoch.TournamentAddress.Hex(), epoch.Commitment.Hex()).Return(nil, nil).Once() + opts := mock.MatchedBy(func(opts *bind.CallOpts) bool { return resultCallOptsAtBlock(20)(opts) }) + adapter.On("CommitmentStanding", opts, [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{Height: height}, nil).Once() + if height == model.Log2EpochComputationHashLeafCount { + adapter.On("BondValue", opts).Return(big.NewInt(0), nil).Once() + adapter.On("JoinTournament", mock.Anything, [32]byte(*epoch.MachineHash), + mock.Anything, mock.Anything, mock.Anything).Return(types.NewTx(&types.LegacyTx{Nonce: 1}), nil).Once() + joined, err := service.reactToTournament(t.Context(), app, epoch, 20) + require.NoError(t, err) + require.False(t, joined) + require.Equal(t, tournamentActionJoin, service.pendingTransactions[app.ID].Action) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } else { + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(reasonContains("commitment height 48", "factory and node versions"))).Return(nil).Once() + joined, err := service.reactToTournament(t.Context(), app, epoch, 20) + require.NoError(t, err) + require.False(t, joined) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + adapter.AssertNotCalled(t, "JoinTournament", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + adapter.AssertExpectations(t) + factory.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestRootWinnerMismatchDoesNotChangeLocalHealthDuringObservation(t *testing.T) { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(operation, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo := newPRTServiceMock() + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, + }, nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{ + State: model.TournamentStandingRootWinner, FinishedAt: 9, HasCandidate: true, Candidate: common.HexToHash("0xbad"), + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, model.TournamentStandingRootWinner) + if update { + err := service.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, + &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Kind: model.TournamentKindNonLeaf}, 10) + require.NoError(t, err) + } else { + tournament, err := service.readTournament(t.Context(), app, epoch, RootLevel, + nil, nil, *epoch.TournamentAddress, adapter, 3, 10) + require.NoError(t, err) + require.Equal(t, common.HexToHash("0xbad"), *tournament.Snapshot.WinnerCommitment) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/observer_test.go b/internal/prt/observer_test.go new file mode 100644 index 000000000..037344724 --- /dev/null +++ b/internal/prt/observer_test.go @@ -0,0 +1,367 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentDescriptorFromBinding_MapsFieldsAndOwnsBaseCycle(t *testing.T) { + baseCycle := big.NewInt(123) + binding := itournament.ITournamentTournamentDescriptor{ + BaseCycle: baseCycle, + Log2Stride: 20, + Height: 48, + Level: 2, + Kind: 0, + } + + descriptor, err := tournamentDescriptorFromBinding(binding) + require.NoError(t, err) + require.Equal(t, int64(123), descriptor.BaseCycle.Int64()) + require.Equal(t, binding.Log2Stride, descriptor.Log2Stride) + require.Equal(t, binding.Height, descriptor.Height) + require.Equal(t, binding.Level, descriptor.Level) + require.Equal(t, model.TournamentKindLeaf, descriptor.Kind) + + baseCycle.SetInt64(456) + require.Equal(t, int64(123), descriptor.BaseCycle.Int64(), "the DTO must own its base-cycle value") + descriptor.BaseCycle.SetInt64(789) + require.Equal(t, int64(456), binding.BaseCycle.Int64(), "the binding must not share the DTO base-cycle value") + + _, err = tournamentDescriptorFromBinding(itournament.ITournamentTournamentDescriptor{}) + require.ErrorContains(t, err, "base cycle is nil") +} + +func TestTournamentStandingFromBinding_MapsAllFields(t *testing.T) { + for _, flags := range [][2]bool{{true, false}, {false, true}} { + binding := itournament.ITournamentTournamentStandingView{ + Standing: 4, + AcceptsJoins: flags[0], HasCandidate: flags[1], + Candidate: common.HexToHash("0x21"), FinalState: common.HexToHash("0x22"), FinishedAt: 101, + } + standing, err := tournamentStandingFromBinding(binding) + require.NoError(t, err) + require.Equal(t, TournamentStanding{ + State: model.TournamentStandingInnerWinner, AcceptsJoins: flags[0], HasCandidate: flags[1], + Candidate: common.HexToHash("0x21"), FinalState: common.HexToHash("0x22"), FinishedAt: 101, + }, standing) + } +} + +func TestCommitmentStandingFromBinding_MapsAllFields(t *testing.T) { + binding := itournament.ITournamentCommitmentStandingView{ + Joined: true, + FinalState: common.HexToHash("0x31"), + Claimer: common.HexToAddress("0x32"), + } + + standing := commitmentStandingFromBinding(binding) + + require.Equal(t, binding.Joined, standing.Joined) + require.Equal(t, common.Hash(binding.FinalState), standing.FinalState) + require.Equal(t, binding.Claimer, standing.Claimer) +} + +func TestValidateTournamentDescriptor_LevelCountAndKind(t *testing.T) { + valid := func(level uint64, kind model.TournamentKind) TournamentDescriptor { + return TournamentDescriptor{ + BaseCycle: big.NewInt(0), + Level: level, + Kind: kind, + } + } + tests := []struct { + name string + descriptor TournamentDescriptor + expectedLevel TournamentLevel + levelCount uint64 + wantError string + }{ + {name: "root of three levels", descriptor: valid(0, model.TournamentKindNonLeaf), levelCount: 3}, + {name: "dynamic last level", descriptor: valid(3, model.TournamentKindLeaf), expectedLevel: 3, levelCount: 4}, + {name: "zero level count", descriptor: valid(0, model.TournamentKindLeaf), wantError: "level count is zero"}, + { + name: "nil base cycle", + descriptor: TournamentDescriptor{Level: 0, Kind: model.TournamentKindNonLeaf}, + levelCount: 2, + wantError: "invalid base cycle", + }, + { + name: "negative base cycle", + descriptor: TournamentDescriptor{BaseCycle: big.NewInt(-1), Level: 0, Kind: model.TournamentKindNonLeaf}, + levelCount: 2, + wantError: "invalid base cycle", + }, + { + name: "unexpected level", + descriptor: valid(1, model.TournamentKindNonLeaf), + expectedLevel: 2, + levelCount: 3, + wantError: "does not match expected level", + }, + { + name: "level outside count", + descriptor: valid(2, model.TournamentKindLeaf), + expectedLevel: 2, + levelCount: 2, + wantError: "outside level count", + }, + { + name: "unknown kind", + descriptor: valid(0, model.TournamentKind("UNKNOWN")), + levelCount: 2, + wantError: "unknown kind", + }, + { + name: "leaf before last level", + descriptor: valid(0, model.TournamentKindLeaf), + levelCount: 3, + wantError: "expected NON_LEAF", + }, + { + name: "non-leaf at last level", + descriptor: valid(2, model.TournamentKindNonLeaf), + expectedLevel: 2, + levelCount: 3, + wantError: "expected LEAF", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateTournamentDescriptor(test.descriptor, test.expectedLevel, test.levelCount) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantError) + }) + } +} + +func TestValidateTournamentStanding_AllStates(t *testing.T) { + candidate := common.HexToHash("0x41") + tests := []struct { + name string + descriptor TournamentDescriptor + standing TournamentStanding + terminal bool + }{ + { + name: "matches active", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{State: model.TournamentStandingMatchesActive}, + }, + { + name: "matches active and accepts joins", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingMatchesActive, AcceptsJoins: true, + }, + }, + { + name: "matches active with candidate", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingMatchesActive, HasCandidate: true, Candidate: candidate, + }, + }, + { + name: "awaiting closure", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, + }, + }, + { + name: "root winner", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingRootWinner, HasCandidate: true, Candidate: candidate, FinishedAt: 9, + }, + terminal: true, + }, + { + name: "root failed", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{State: model.TournamentStandingRootFailed, FinishedAt: 9}, + terminal: true, + }, + { + name: "inner winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{ + State: model.TournamentStandingInnerWinner, HasCandidate: true, Candidate: candidate, + FinishedAt: 9, + }, + terminal: true, + }, + { + name: "inner eliminable without winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{State: model.TournamentStandingInnerEliminableNoWinner, FinishedAt: 9}, + terminal: true, + }, + { + name: "inner eliminable with expired winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{ + State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: candidate, FinishedAt: 9, + }, + terminal: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NoError(t, validateTournamentStanding(test.standing, 10)) + require.Equal(t, test.terminal, isTerminalTournamentStanding(test.standing.State)) + if test.terminal { + require.NotZero(t, test.standing.FinishedAt) + } else { + require.Zero(t, test.standing.FinishedAt) + } + }) + } +} + +func TestValidateTournamentStanding_RejectsInvalidStoredBoundary(t *testing.T) { + for _, test := range []struct { + name string + standing TournamentStanding + wantError string + }{ + {name: unknownStandingCase, standing: TournamentStanding{State: unknownEnumValue}, wantError: unknownStandingCase}, + {name: "terminal without finished block", standing: TournamentStanding{State: model.TournamentStandingRootFailed}, + wantError: "zero finished block"}, + {name: "finished after selected head", standing: TournamentStanding{State: model.TournamentStandingRootWinner, FinishedAt: 11}, + wantError: "after observed block"}, + } { + t.Run(test.name, func(t *testing.T) { + require.ErrorContains(t, validateTournamentStanding(test.standing, 10), test.wantError) + }) + } +} + +func TestReadTournament_PinsDescriptorAndStandingReads(t *testing.T) { + const mostRecentBlock uint64 = 100 + app := &model.Application{ID: 1, Name: "app", IApplicationAddress: common.HexToAddress("0x91")} + epoch := &model.Epoch{Index: 3} + tournamentAddress := common.HexToAddress("0x92") + descriptor := TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: uint64(RootLevel), Kind: model.TournamentKindNonLeaf, + } + standing := TournamentStanding{State: model.TournamentStandingMatchesActive} + pinned := mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts != nil && opts.Context != nil && opts.BlockNumber != nil && + opts.BlockNumber.Uint64() == mostRecentBlock + }) + + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", pinned).Return(descriptor, nil).Once() + adapter.On("Standing", pinned).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, pinned, RootLevel, standing.State) + s, repo := newPRTServiceMock() + + tournament, err := s.readTournament( + context.Background(), app, epoch, RootLevel, nil, nil, tournamentAddress, adapter, 2, mostRecentBlock, + ) + require.NoError(t, err) + require.Equal(t, tournamentAddress, tournament.Address) + require.Equal(t, uint64(2), tournament.MaxLevel) + require.Equal(t, uint64(RootLevel), tournament.Level) + require.Empty(t, repo.Calls, "reading a projection must not write it") + repo.AssertExpectations(t) + adapter.AssertExpectations(t) +} + +func TestGatherTournamentData_UsesDynamicLastLevel(t *testing.T) { + for _, test := range []struct { + name string + level TournamentLevel + wantChildScan bool + }{ + {name: "last level from four-level factory", level: 3}, + {name: "level before last", level: 2, wantChildScan: true}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, app: prtRevertTestApp(), factory: &adapterFactoryMock{}} + s.adapterFactory = f.factory + epoch := checkpointEpoch(3, "0x92") + tournament := f.tournament(epoch, *epoch.TournamentAddress, uint64(test.level), 4, 100, 100, &TournamentEvents{}, nil) + if test.wantChildScan { + f.children(epoch, tournament) + } + batches, err := s.gatherTournamentData(t.Context(), f.app, epoch, test.level, nil, nil, + *epoch.TournamentAddress, 4, 100) + require.NoError(t, err) + require.Len(t, batches, 1) + require.Equal(t, uint64(test.level), batches[0].Tournament.Level) + repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + }) + } +} + +func TestValidateJoinedCommitment_FinalState(t *testing.T) { + machineHash := common.HexToHash("0xa1") + t.Run("missing local machine hash", func(t *testing.T) { + s, repo := newPRTServiceMock() + app := &model.Application{ID: 1, Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3} + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "has no machine hash") + })).Return(nil).Once() + + err := s.validateJoinedCommitment( + context.Background(), app, epoch, CommitmentStanding{Joined: true}, + ) + require.ErrorContains(t, err, "has no machine hash") + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) + repo.AssertExpectations(t) + }) + + t.Run("match", func(t *testing.T) { + s, repo := newPRTServiceMock() + app := &model.Application{ID: 1, Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + + require.NoError(t, s.validateJoinedCommitment( + context.Background(), app, epoch, CommitmentStanding{Joined: true, FinalState: machineHash}, + )) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertExpectations(t) + }) + + t.Run("latest mismatch does not change application status", func(t *testing.T) { + s, repo := newPRTServiceMock() + app := &model.Application{ID: 1, Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + onchain := common.HexToHash("0xa2") + + err := s.validateJoinedCommitment( + context.Background(), app, epoch, CommitmentStanding{Joined: true, FinalState: onchain}, + ) + require.ErrorContains(t, err, "inconsistent final state") + require.ErrorContains(t, err, machineHash.String()) + require.ErrorContains(t, err, onchain.String()) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) +} diff --git a/internal/prt/passive_observer_test.go b/internal/prt/passive_observer_test.go new file mode 100644 index 000000000..ade9f2759 --- /dev/null +++ b/internal/prt/passive_observer_test.go @@ -0,0 +1,189 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" +) + +func TestPassiveObservationIncludesLocalFailuresAndTerminalEpochs(t *testing.T) { + for _, status := range model.ApplicationStatusAllValues { + for _, epochStatus := range []model.EpochStatus{ + model.EpochStatus_Closed, model.EpochStatus_InputsProcessed, model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimStaged, model.EpochStatus_ClaimAccepted, model.EpochStatus_ClaimForeclosed, + } { + t.Run(status.String()+"/"+epochStatus.String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.app.Status = status + // These applications must remain passive even with signing enabled. + f.s.submissionEnabled = status != model.ApplicationStatus_OK + epoch := checkpointEpoch(0, "0x100") + epoch.Status = epochStatus + pendingClaim := epochStatus == model.EpochStatus_ClaimComputed || epochStatus == model.EpochStatus_ClaimStaged + if pendingClaim { + // A complete claim ensures that invalid local data cannot be + // the reason an unhealthy application avoids submission. + applyPRTStateProof(epoch, repotest.KeccakStateProof(common.HexToHash("0x1234"))) + _, err := epoch.StateProof() + require.NoError(t, err) + } + snapshot := resultTestSnapshot(epoch, epochStatus == model.EpochStatus_ClaimStaged) + if epochStatus == model.EpochStatus_ClaimStaged { + epoch.StagedAtBlock = new(uint64(95)) + snapshot.sealed.StagingBlockNumber = *epoch.StagedAtBlock + snapshot.accept.IsClaimStagingPeriodOver = true + } + if !pendingClaim { + epoch.Commitment, epoch.MachineHash, epoch.TxBufferDataBlock = nil, nil, nil + } + f.repo.On("ListApplications", mock.Anything, repository.ApplicationFilter{ + Enabled: new(true), ConsensusType: new(model.Consensus_PRT), + }, repository.Pagination{}, false).Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + if status == model.ApplicationStatus_OK { + if pendingClaim { + f.unaccepted(epoch, 90) + } + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() + } + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, status, f.app.Status) + require.Equal(t, epochStatus, epoch.Status) + require.Empty(t, f.s.pendingTransactions) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + if status != model.ApplicationStatus_OK { + // Tick must stop after observation, before any result-action + // query. An unexpected broadcast also fails the strict mocks. + f.consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.Anything) + f.consensus.AssertNotCalled(t, "CanStageTournamentResult", mock.Anything) + f.consensus.AssertNotCalled(t, "CanAcceptStagedTournamentResult", mock.Anything) + } + }) + } + } +} + +func TestHealthyReaderPublishesBeforeRecordingStagedClaim(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = false + epoch := checkpointEpoch(0, "0x100") + snapshot := resultTestSnapshot(epoch, true) + snapshot.sealed.StagingBlockNumber = 95 + snapshot.accept.IsClaimStagingPeriodOver = true + f.repo.On("ListApplications", mock.Anything, repository.ApplicationFilter{ + Enabled: new(true), ConsensusType: new(model.Consensus_PRT), + }, repository.Pagination{}, false).Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + f.unaccepted(epoch, 90) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(95)). + Run(func(mock.Arguments) { require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) }).Return(nil).Once() + + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(95)), epoch.StagedAtBlock) + require.Empty(t, f.s.pendingTransactions) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestLosingRootPublishesBeforeLocalDivergence(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + foreign := common.HexToHash("0xbad") + address := *epoch.TournamentAddress + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return((*model.Tournament)(nil), nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, StartInstant: 10, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: model.TournamentStandingRootWinner, HasCandidate: true, Candidate: foreign, FinishedAt: 90, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, model.TournamentStandingRootWinner) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + // A newly found clone includes its full history, even below the app cursor. + return opts.Start == 10 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, address) + var published *model.Tournament + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, uint64(100)). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(t, batches, 1) + published = batches[0].Tournament + require.Equal(t, foreign, *published.Snapshot.WinnerCommitment) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + }).Return(nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()). + Return(&model.Tournament{Snapshot: model.TournamentSnapshot{FinishedAtBlock: 90, WinnerCommitment: &foreign}}, nil).Once() + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Run(func(mock.Arguments) { + require.NotNil(t, published) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }).Return(nil).Once() + deferActions, err := f.s.checkEpochs(t.Context(), f.app, 100) + require.ErrorContains(t, err, "inconsistent commitment") + require.True(t, deferActions) + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) + require.Empty(t, f.s.pendingTransactions) + adapter.AssertExpectations(t) +} diff --git a/internal/prt/pending.go b/internal/prt/pending.go new file mode 100644 index 000000000..c750c90f3 --- /dev/null +++ b/internal/prt/pending.go @@ -0,0 +1,108 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +type tournamentAction string + +const ( + tournamentActionJoin tournamentAction = "join" + tournamentActionStage tournamentAction = "stage" + tournamentActionAccept tournamentAction = "accept" +) + +// Match the claimer's 64-block recovery horizon, but count latest blocks from +// the first missing lookup, not finalized blocks from submission. This releases +// stale local exclusion only; it cannot ensure delivery before a join deadline. +const maxMissingTournamentTransactionBlocks uint64 = 64 + +// pendingTournamentTransaction is a local exclusion slot, not a delivery queue. +// It is lost on restart. Known-pending transactions have no retry deadline. +type pendingTournamentTransaction struct { + Action tournamentAction + Hash common.Hash + EpochIndex uint64 + FirstMissingBlock *uint64 +} + +// waitForTournamentTransaction blocks new actions for this tick, including when +// a mined or missing transaction releases its slot. The next tick reads fresh state. +// Receipts provide diagnostics; they do not change stored claim state. +func (s *Service) waitForTournamentTransaction(ctx context.Context, app *model.Application, latestBlock uint64) (bool, error) { + tx, exists := s.pendingTransactions[app.ID] + if !exists { + return false, nil + } + _, pending, err := s.client.TransactionByHash(ctx, tx.Hash) + missing := errors.Is(err, ethereum.NotFound) + if err != nil && !missing { + return true, fmt.Errorf("checking %s transaction %s: %w", tx.Action, tx.Hash, err) + } + if !missing { + tx.FirstMissingBlock = nil + s.pendingTransactions[app.ID] = tx + } + if !missing && pending { + s.Logger.Debug("Tournament transaction is still pending", + "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash) + return true, nil + } + // A transaction lookup can be missing while its receipt is available. + // Require both lookups to report NotFound before aging the local slot. + receipt, err := s.client.TransactionReceipt(ctx, tx.Hash) + if missing && errors.Is(err, ethereum.NotFound) { + s.waitForMissingTournamentTransaction(app, tx, latestBlock) + return true, nil + } + if err != nil { + return true, fmt.Errorf("reading %s transaction receipt %s: %w", tx.Action, tx.Hash, err) + } + if receipt == nil || receipt.TxHash != tx.Hash { + return true, fmt.Errorf("%s transaction %s has a missing or mismatched receipt", tx.Action, tx.Hash) + } + if _, err := checkedUint64(receipt.BlockNumber, "tournament transaction receipt block"); err != nil { + return true, err + } + if receipt.Status != types.ReceiptStatusFailed && receipt.Status != types.ReceiptStatusSuccessful { + return true, fmt.Errorf("%s transaction %s has invalid receipt status %d", tx.Action, tx.Hash, receipt.Status) + } + delete(s.pendingTransactions, app.ID) + if receipt.Status == types.ReceiptStatusFailed { + s.Logger.Error("Tournament transaction reverted; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash) + } else { + s.Logger.Debug("Tournament transaction was mined; waiting for a fresh chain snapshot", + "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash) + } + return true, nil +} + +func (s *Service) waitForMissingTournamentTransaction( + app *model.Application, tx pendingTournamentTransaction, latestBlock uint64, +) { + if tx.FirstMissingBlock == nil || latestBlock < *tx.FirstMissingBlock { + tx.FirstMissingBlock = new(latestBlock) + s.pendingTransactions[app.ID] = tx + return + } + age := latestBlock - *tx.FirstMissingBlock + if age < maxMissingTournamentTransactionBlocks { + return + } + delete(s.pendingTransactions, app.ID) + s.Logger.Warn("Tournament transaction remains missing; releasing local slot for fresh chain reconciliation", + "application", app.Name, "epoch_index", tx.EpochIndex, "action", tx.Action, "tx", tx.Hash, + "missing_since_block", *tx.FirstMissingBlock, "latest_block", latestBlock, "age_blocks", age, + "timeout_blocks", maxMissingTournamentTransactionBlocks) +} diff --git a/internal/prt/pending_test.go b/internal/prt/pending_test.go new file mode 100644 index 000000000..56f442b14 --- /dev/null +++ b/internal/prt/pending_test.go @@ -0,0 +1,128 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestPendingTournamentTransactionRetainsOrReleasesSlot(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + for _, test := range []struct { + name string + pending bool + lookupError error + receiptError error + mutate func(*types.Receipt) *types.Receipt + wantError bool + wantRelease bool + wantRevert bool + }{ + {name: "pending", pending: true}, + {name: "missing transaction with receipt", lookupError: ethereum.NotFound, wantRelease: true}, + {name: "missing transaction with reverted receipt", lookupError: ethereum.NotFound, + mutate: func(r *types.Receipt) *types.Receipt { r.Status = types.ReceiptStatusFailed; return r }, + wantRelease: true, wantRevert: true}, + {name: "missing transaction with nil receipt", lookupError: ethereum.NotFound, + mutate: func(*types.Receipt) *types.Receipt { return nil }, wantError: true}, + {name: "missing transaction with wrong receipt hash", lookupError: ethereum.NotFound, + mutate: func(r *types.Receipt) *types.Receipt { r.TxHash = common.Hash{}; return r }, wantError: true}, + {name: "missing receipt", receiptError: ethereum.NotFound, wantError: true}, + {name: "nil receipt", mutate: func(*types.Receipt) *types.Receipt { return nil }, wantError: true}, + {name: "wrong hash", mutate: func(r *types.Receipt) *types.Receipt { r.TxHash = common.Hash{}; return r }, wantError: true}, + {name: "missing block", mutate: func(r *types.Receipt) *types.Receipt { r.BlockNumber = nil; return r }, wantError: true}, + {name: "invalid status", mutate: func(r *types.Receipt) *types.Receipt { r.Status = 2; return r }, wantError: true}, + {name: "mined success", wantRelease: true}, + {name: "mined revert", mutate: func(r *types.Receipt) *types.Receipt { r.Status = 0; return r }, + wantRelease: true, wantRevert: true}, + } { + t.Run(string(action)+"/"+test.name, func(t *testing.T) { + app := prtRevertTestApp() + s, repo := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + tx := pendingTournamentTransaction{Action: action, Hash: common.HexToHash("0xbeef"), EpochIndex: 3} + s.pendingTransactions[app.ID] = tx + s.pendingTransactions[app.ID+1] = tx + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, tx.Hash). + Return((*types.Transaction)(nil), test.pending, test.lookupError).Once() + if !test.pending { + receipt := &types.Receipt{TxHash: tx.Hash, BlockNumber: big.NewInt(21), Status: types.ReceiptStatusSuccessful} + if test.mutate != nil { + receipt = test.mutate(receipt) + } + client.On("TransactionReceipt", mock.Anything, tx.Hash).Return(receipt, test.receiptError).Once() + } + s.client = client + // A receipt newer than the observed head does not need finality to + // release the local slot. It cannot mark an epoch or app as terminal. + joinEpoch, _, err := s.progressTournamentResult(t.Context(), app, 20, 20) + require.Equal(t, test.wantError, err != nil, "%v", err) + require.Nil(t, joinEpoch) + if test.wantRelease { + require.NotContains(t, s.pendingTransactions, app.ID) + } else { + require.Equal(t, tx, s.pendingTransactions[app.ID]) + } + require.Equal(t, tx, s.pendingTransactions[app.ID+1], "another application's slot must not change") + require.Equal(t, ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + if test.wantRevert { + require.Contains(t, output.String(), "level=ERROR") + require.Contains(t, output.String(), "action="+string(action)) + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), tx.Hash.Hex()) + } else { + require.NotContains(t, output.String(), "level=ERROR") + } + client.AssertExpectations(t) + }) + } + } +} + +func TestPendingJoinReadsCurrentEpochOnlyAfterMining(t *testing.T) { + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage = CanStageTournamentResult{EpochNumber: epoch.Index} + s, repo, consensus := resultTestService(t, epoch, snapshot, true) + app := prtRevertTestApp() + tx := types.NewTx(&types.LegacyTx{}) + s.pendingTransactions[app.ID] = pendingTournamentTransaction{ + Action: tournamentActionJoin, Hash: tx.Hash(), EpochIndex: epoch.Index - 1, + } + client := ðClientMock{} + client.On("TransactionByHash", mock.Anything, tx.Hash()).Return(tx, true, nil).Once() + client.On("TransactionByHash", mock.Anything, tx.Hash()).Return(tx, false, nil).Once() + client.On("TransactionReceipt", mock.Anything, tx.Hash()).Return(&types.Receipt{ + TxHash: tx.Hash(), BlockNumber: big.NewInt(20), Status: types.ReceiptStatusSuccessful, + }, nil).Once() + s.client = client + for range 2 { + joinEpoch, _, err := s.progressTournamentResult(t.Context(), app, 20, 20) + require.NoError(t, err) + require.Nil(t, joinEpoch) + require.Empty(t, repo.Calls) + require.Empty(t, consensus.Calls) + } + // The chain advanced while the old join was pending. The next snapshot, + // not the old transaction's epoch, supplies the new join decision. + joinEpoch, _, err := s.progressTournamentResult(t.Context(), app, 20, 20) + require.NoError(t, err) + require.Same(t, epoch, joinEpoch) + client.AssertExpectations(t) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) +} diff --git a/internal/prt/prt.go b/internal/prt/prt.go index 88c375154..687518574 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -15,6 +15,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/merkle" @@ -36,29 +37,34 @@ type prtRepository interface { ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool) ([]*Epoch, uint64, error) GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) - UpdateEpochStatus(ctx context.Context, nameOrAddress string, e *Epoch) error + UpdateEpochReconciledStaged(ctx context.Context, applicationID int64, index uint64, stagedAtBlock uint64) error + UpdateEpochWithAcceptedClaim(ctx context.Context, applicationID int64, index uint64, txHash *common.Hash) error - CreateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) - UpdateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error ListTournaments(ctx context.Context, nameOrAddress string, f repository.TournamentFilter, p repository.Pagination, descending bool) ([]*Tournament, uint64, error) - StoreTournamentEvents(ctx context.Context, appID int64, commitments []*Commitment, matches []*Match, - matchAdvanced []*MatchAdvanced, matchDeleted []*Match, lastBlock uint64) error + StoreTournamentEvents(ctx context.Context, appID int64, batches []*repository.TournamentEventBatch, lastBlock uint64) error GetCommitment(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, commitmentHex string) (*Commitment, error) + ListCommitments(ctx context.Context, nameOrAddress string, filter repository.CommitmentFilter, + pagination repository.Pagination, descending bool) ([]*Commitment, uint64, error) + ListMatches(ctx context.Context, nameOrAddress string, filter repository.MatchFilter, + pagination repository.Pagination, descending bool) ([]*Match, uint64, error) + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) } // EthClientInterface defines the methods we need from ethclient.Client type EthClientInterface interface { + SuggestGasPrice(ctx context.Context) (*big.Int, error) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) ChainID(ctx context.Context) (*big.Int, error) BlockNumber(ctx context.Context) (uint64, error) + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) TransactionByHash(ctx context.Context, hash common.Hash) (*types.Transaction, bool, error) } @@ -81,23 +87,48 @@ func (f *DefaultAdapterFactory) CreateDaveConsensusAdapter(addr common.Address) return NewDaveConsensusAdapter(addr, f.client) } -func getAllRunningApplications(ctx context.Context, r prtRepository) ([]*Application, uint64, error) { - return r.ListApplications(ctx, prtTickApplicationsFilter(), repository.Pagination{}, false) -} - -func prtTickApplicationsFilter() repository.ApplicationFilter { - return repository.ApplicationFilter{ +// getObservableApplications includes unhealthy apps so tournament and dispute +// observation can continue. Later health gates stop local claim reconciliation +// and transaction submission; they must not remove these apps from observation. +func getObservableApplications(ctx context.Context, r prtRepository) ([]*Application, uint64, error) { + filter := repository.ApplicationFilter{ Enabled: new(true), - Statuses: []ApplicationStatus{ApplicationStatus_OK}, ConsensusType: new(Consensus_PRT), } + return r.ListApplications(ctx, filter, repository.Pagination{}, false) } -func getAllClaimComputedEpochs(ctx context.Context, r prtRepository, nameOrAddress string) ([]*Epoch, uint64, error) { - f := repository.EpochFilter{Status: []EpochStatus{EpochStatus_ClaimComputed}} +func getTournamentObservationEpochs(ctx context.Context, r prtRepository, nameOrAddress string) ([]*Epoch, uint64, error) { + f := repository.EpochFilter{HasTournament: new(true)} return r.ListEpochs(ctx, nameOrAddress, f, repository.Pagination{}, false) } +// getDefaultBlockNumber selects the block used for stored chain state. Live +// transaction checks use a separate latest block and cannot advance this view. +func (s *Service) getDefaultBlockNumber(ctx context.Context) (uint64, error) { + var tag rpc.BlockNumber + switch s.defaultBlock { + case DefaultBlock_Pending: + tag = rpc.PendingBlockNumber + case DefaultBlock_Latest: + tag = rpc.LatestBlockNumber + case DefaultBlock_Finalized: + tag = rpc.FinalizedBlockNumber + case DefaultBlock_Safe: + tag = rpc.SafeBlockNumber + default: + return 0, fmt.Errorf("default block %v not supported", s.defaultBlock) + } + header, err := s.client.HeaderByNumber(ctx, big.NewInt(tag.Int64())) + if err != nil { + return 0, fmt.Errorf("fetching %s block header: %w", tag, err) + } + if header == nil { + return 0, fmt.Errorf("returned %s block header is nil", tag) + } + return checkedUint64(header.Number, "configured block number") +} + func getAllSubTournaments( ctx context.Context, r prtRepository, @@ -124,115 +155,7 @@ func (s *Service) setApplicationFailed(ctx context.Context, app *Application, re return appstatus.SetFailedf(ctx, s.Logger, s.repository, app, reasonFmt, args...) } -func (s *Service) saveTournamentEvents(ctx context.Context, app *Application, epoch *Epoch, - tournamentAddress common.Address, events *TournamentEvents, lastBlock uint64) error { - commitments := make([]*Commitment, 0, len(events.CommitmentJoined)) - for _, ev := range events.CommitmentJoined { - c := Commitment{ - ApplicationID: app.ID, - EpochIndex: epoch.Index, - TournamentAddress: tournamentAddress, - Commitment: ev.Commitment, - FinalStateHash: ev.FinalStateHash, - SubmitterAddress: ev.Submitter, - BlockNumber: ev.Raw.BlockNumber, - TxHash: ev.Raw.TxHash, - } - s.Logger.Info("Found CommitmentJoined event", - "application", app.Name, - "epoch_index", epoch.Index, - "tournament", tournamentAddress.Hex(), - "commitment", c.Commitment.String()) - commitments = append(commitments, &c) - } - - matches := make([]*Match, 0, len(events.MatchCreated)) - for _, ev := range events.MatchCreated { - m := Match{ - ApplicationID: app.ID, - EpochIndex: epoch.Index, - TournamentAddress: tournamentAddress, - IDHash: ev.MatchIdHash, - CommitmentOne: ev.One, - CommitmentTwo: ev.Two, - LeftOfTwo: ev.LeftOfTwo, - BlockNumber: ev.Raw.BlockNumber, - TxHash: ev.Raw.TxHash, - Winner: WinnerCommitment_NONE, - DeletionReason: MatchDeletionReason_NOT_DELETED, - DeletionBlockNumber: 0, - DeletionTxHash: common.Hash{}, - } - s.Logger.Info("Found MatchCreated event", - "application", app.Name, - "epoch_index", epoch.Index, - "tournament", tournamentAddress.Hex(), - "id_hash", m.IDHash.String(), - "one", m.CommitmentOne.String(), - "two", m.CommitmentTwo.String(), - "leftOfTwo", m.LeftOfTwo.String()) - matches = append(matches, &m) - } - - matchAdvanced := make([]*MatchAdvanced, 0, len(events.MatchAdvanced)) - for _, ev := range events.MatchAdvanced { - m := &MatchAdvanced{ - ApplicationID: app.ID, - EpochIndex: epoch.Index, - TournamentAddress: tournamentAddress, - IDHash: ev.MatchIdHash, - OtherParent: ev.OtherParent, - LeftNode: ev.LeftNode, - BlockNumber: ev.Raw.BlockNumber, - TxHash: ev.Raw.TxHash, - } - s.Logger.Info("Found MatchAdvanced event", - "application", app.Name, - "epoch_index", epoch.Index, - "tournament", tournamentAddress.Hex(), - "id_hash", m.IDHash.String(), - "other_parent", m.OtherParent.String(), - "left_node", m.LeftNode.String()) - matchAdvanced = append(matchAdvanced, m) - } - - matchDeleted := make([]*Match, 0, len(events.MatchDeleted)) - for _, ev := range events.MatchDeleted { - m := Match{ - ApplicationID: app.ID, - EpochIndex: epoch.Index, - TournamentAddress: tournamentAddress, - IDHash: ev.MatchIdHash, - CommitmentOne: ev.One, - CommitmentTwo: ev.Two, - Winner: WinnerCommitmentFromUint8(ev.WinnerCommitment), - DeletionReason: MatchDeletionReasonFromUint8(ev.Reason), - DeletionBlockNumber: ev.Raw.BlockNumber, - DeletionTxHash: ev.Raw.TxHash, - } - s.Logger.Info("Found MatchDeleted event", - "application", app.Name, - "epoch_index", epoch.Index, - "tournament", tournamentAddress.Hex(), - "id_hash", ((common.Hash)(ev.MatchIdHash)).String(), - "one", ((common.Hash)(ev.One)).String(), - "two", ((common.Hash)(ev.Two)).String(), - "winner", m.Winner.String(), - "reason", m.DeletionReason.String(), - ) - matchDeleted = append(matchDeleted, &m) - } - - err := s.repository.StoreTournamentEvents(ctx, app.ID, commitments, matches, matchAdvanced, matchDeleted, lastBlock) - if err != nil { - s.Logger.Error("failed to save tournament events", "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err - } - return nil -} - -func (s *Service) createTournament( +func (s *Service) readTournament( ctx context.Context, app *Application, epoch *Epoch, @@ -240,67 +163,27 @@ func (s *Service) createTournament( parentMatchIDHash *common.Hash, parentTournamentAddress *common.Address, tournamentAddress common.Address, + adapter TournamentAdapter, + levelCount uint64, + mostRecentBlock uint64, ) (*Tournament, error) { - adapter, err := s.adapterFactory.CreateTournamentAdapter(tournamentAddress) + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + descriptor, err := adapter.Descriptor(callOpts) if err != nil { - s.Logger.Error("failed to create tournament adapter", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament descriptor", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) return nil, err } - - constants, err := adapter.Constants(nil) - if err != nil { - s.Logger.Error("failed to fetch tournament constants", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return nil, err + if err := validateTournamentDescriptor(descriptor, level, levelCount); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) } - - var winnerCommitmentPtr *common.Hash - var finalStatePtr *common.Hash - finishedAtBlock := uint64(0) - if epoch.ClaimTransactionHash != nil { - finished, timeFinished, err := adapter.TimeFinished(nil) - if err != nil { - s.Logger.Error("failed to fetch tournament finished at time", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return nil, err - } - if !finished { - err := fmt.Errorf("epoch %d: tournament %s should be finished but is not", - epoch.Index, tournamentAddress.String()) - s.Logger.Error("tournament should be finished", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return nil, err - } - finishedAtBlock = timeFinished - - _, winnerCommitment, finalState, err := adapter.Result(nil) - if err != nil { - s.Logger.Error("failed to fetch tournament result", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return nil, err - } - - // root tournament with no winner. - if level == RootLevel && winnerCommitment == [32]byte{} { - return nil, s.setApplicationDiverged(ctx, app, - "Epoch %d root tournament %s has finished without winners.", - epoch.Index, tournamentAddress.String()) - } - - if level == RootLevel && *epoch.Commitment != winnerCommitment { - return nil, s.setApplicationDiverged(ctx, app, - "Epoch %d has inconsistent commitment between off-chain (%s) and on-chain (%s).", - epoch.Index, epoch.Commitment.String(), hexutil.Encode(winnerCommitment[:])) - } - winnerCommitmentPtr = new(common.Hash) - *winnerCommitmentPtr = winnerCommitment - - finalStatePtr = new(common.Hash) - *finalStatePtr = finalState - } else { - s.Logger.Info("Found open tournament", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + if descriptor.StartInstant > mostRecentBlock { + return nil, fmt.Errorf("tournament %s starts at block %d after observed block %d", + tournamentAddress, descriptor.StartInstant, mostRecentBlock) + } + baseCycle, err := Uint256FromBig(descriptor.BaseCycle) + if err != nil { + return nil, fmt.Errorf("tournament %s base cycle: %w", tournamentAddress, err) } t := &Tournament{ @@ -309,26 +192,94 @@ func (s *Service) createTournament( Address: tournamentAddress, ParentMatchIDHash: parentMatchIDHash, ParentTournamentAddress: parentTournamentAddress, - MaxLevel: constants.MaxLevel, - Level: constants.Level, - Log2Step: constants.Log2step, - Height: constants.Height, - WinnerCommitment: winnerCommitmentPtr, - FinalStateHash: finalStatePtr, - FinishedAtBlock: finishedAtBlock, + MaxLevel: levelCount, + Level: descriptor.Level, + Log2Step: descriptor.Log2Stride, + Height: descriptor.Height, + InitialHash: descriptor.InitialHash, + BaseCycle: baseCycle, + Kind: descriptor.Kind, + StartInstant: descriptor.StartInstant, + Allowance: descriptor.Allowance, + } + if err := s.updateTournamentStanding(ctx, app, epoch, level, adapter, t, mostRecentBlock); err != nil { + return nil, err } - - err = s.repository.CreateTournament(ctx, app.IApplicationAddress.Hex(), t) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to create tournament in database", err, - "level", level, "application", app.Name, + if t.Snapshot.FinishedAtBlock == 0 { + s.Logger.Info("Found open tournament", "level", level, "application", app.Name, "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) - return nil, err } return t, nil } -func (s *Service) updateTournamentIfFinished( +func (s *Service) logFailedRootTournament(app *Application, epochIndex uint64, tournament common.Address) { + s.Logger.Warn("Root tournament finished without a winner; this consensus cannot advance. "+ + "Ask the application guardian to consider foreclosure", + "application", app.Name, "epoch_index", epochIndex, "tournament", tournament) +} + +func validateTournamentDescriptor(descriptor TournamentDescriptor, expectedLevel TournamentLevel, levelCount uint64) error { + if levelCount == 0 { + return errors.New("tournament level count is zero") + } + if descriptor.BaseCycle == nil || descriptor.BaseCycle.Sign() < 0 { + return errors.New("tournament descriptor has invalid base cycle") + } + if descriptor.Level != uint64(expectedLevel) { + return fmt.Errorf("tournament descriptor level %d does not match expected level %d", + descriptor.Level, expectedLevel) + } + if descriptor.Level >= levelCount { + return fmt.Errorf("tournament descriptor level %d is outside level count %d", + descriptor.Level, levelCount) + } + if descriptor.Kind != TournamentKindLeaf && descriptor.Kind != TournamentKindNonLeaf { + return fmt.Errorf("tournament descriptor has unknown kind %s", descriptor.Kind) + } + expectedKind := TournamentKindNonLeaf + if descriptor.Level+1 == levelCount { + expectedKind = TournamentKindLeaf + } + if descriptor.Kind != expectedKind { + return fmt.Errorf("tournament descriptor level %d has kind %s, expected %s", + descriptor.Level, descriptor.Kind, expectedKind) + } + return nil +} + +func validateTournamentStanding(standing TournamentStanding, mostRecentBlock uint64) error { + switch standing.State { + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, TournamentStandingRootFailed, TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + default: + return fmt.Errorf("tournament has unknown standing %s", standing.State) + } + if isTerminalTournamentStanding(standing.State) { + if standing.FinishedAt == 0 { + return errors.New("terminal tournament has zero finished block") + } + if standing.FinishedAt > mostRecentBlock { + return fmt.Errorf("tournament finished at block %d after observed block %d", + standing.FinishedAt, mostRecentBlock) + } + } + return nil +} + +func isTerminalTournamentStanding(state TournamentStandingState) bool { + switch state { + case TournamentStandingRootWinner, TournamentStandingRootFailed, TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + return true + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure: + return false + default: + return false + } +} + +func (s *Service) refreshTournament( ctx context.Context, app *Application, epoch *Epoch, @@ -337,137 +288,302 @@ func (s *Service) updateTournamentIfFinished( t *Tournament, mostRecentBlock uint64, ) error { - callOpts := &bind.CallOpts{ - Context: ctx, - BlockNumber: new(big.Int).SetUint64(mostRecentBlock), - } + callOpts := pinnedCallOpts(ctx, mostRecentBlock) - finished, timeFinished, err := adapter.TimeFinished(callOpts) + descriptor, err := adapter.Descriptor(callOpts) if err != nil { - s.Logger.Error("failed to fetch tournament finished at time", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", t.Address.String(), "error", err) + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament descriptor", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", t.Address.String()) return err } - if !finished { - return nil + if err := validateTournamentDescriptor(descriptor, level, t.MaxLevel); err != nil { + return fmt.Errorf("tournament %s: %w", t.Address, err) + } + if t.Level != descriptor.Level || t.Log2Step != descriptor.Log2Stride || t.Height != descriptor.Height { + return fmt.Errorf("tournament %s descriptor does not match stored geometry", t.Address) } - t.FinishedAtBlock = timeFinished + if t.InitialHash != descriptor.InitialHash || t.BaseCycle.ToBig().Cmp(descriptor.BaseCycle) != 0 || + t.Kind != descriptor.Kind || t.StartInstant != descriptor.StartInstant || t.Allowance != descriptor.Allowance { + return fmt.Errorf("tournament %s descriptor does not match stored configuration", t.Address) + } + return s.updateTournamentStanding(ctx, app, epoch, level, adapter, t, mostRecentBlock) +} - _, winnerCommitment, finalState, err := adapter.Result(callOpts) +// updateTournamentStanding applies the current chain view. A prior winner can +// expire, so a finished tournament must still be refreshed. +func (s *Service) updateTournamentStanding( + ctx context.Context, + app *Application, + epoch *Epoch, + level TournamentLevel, + adapter TournamentAdapter, + t *Tournament, + mostRecentBlock uint64, +) error { + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + standing, err := adapter.Standing(callOpts) if err != nil { - s.Logger.Error("failed to fetch tournament result", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", t.Address.String(), "error", err) + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament standing", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", t.Address.String()) return err } - - // root tournament with no winner. - if level == RootLevel && winnerCommitment == [32]byte{} { - return s.setApplicationDiverged(ctx, app, - "Epoch %d root tournament %s has finished without winners.", - epoch.Index, t.Address.String()) + if err := validateTournamentStanding(standing, mostRecentBlock); err != nil { + return fmt.Errorf("tournament %s: %w", t.Address, err) } - - if level == RootLevel && *epoch.Commitment != winnerCommitment { - return s.setApplicationDiverged(ctx, app, "Epoch %d has inconsistent commitment between off-chain (%s) and on-chain (%s)", - epoch.Index, epoch.Commitment.String(), hexutil.Encode(winnerCommitment[:])) + if standing.State == TournamentStandingMatchesActive { + s.warnUnsupportedDispute(app, epoch.Index, t.Address) } - t.WinnerCommitment = new(common.Hash) - *t.WinnerCommitment = winnerCommitment - - t.FinalStateHash = new(common.Hash) - *t.FinalStateHash = finalState - - return s.repository.UpdateTournament(ctx, app.IApplicationAddress.Hex(), t) + if level == RootLevel && standing.State == TournamentStandingRootFailed { + s.logFailedRootTournament(app, epoch.Index, t.Address) + } + snapshot := TournamentSnapshot{ + AsOfBlock: mostRecentBlock, Standing: standing.State, AcceptsJoins: standing.AcceptsJoins, + FinishedAtBlock: standing.FinishedAt, + } + if standing.HasCandidate { + snapshot.Candidate = new(standing.Candidate) + } + if standing.State == TournamentStandingRootWinner || standing.State == TournamentStandingInnerWinner { + snapshot.WinnerCommitment = new(standing.Candidate) + snapshot.FinalStateHash = new(standing.FinalState) + } + if standing.State == TournamentStandingInnerWinner { + snapshot.ParentCommitment = new(standing.ParentCommitment) + snapshot.WinnerExpiresAt = standing.WinnerExpiresAt + } + if level != RootLevel { + result, err := adapter.InnerResult(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch inner tournament result", err, + "application", app.Name, "tournament", t.Address) + return err + } + snapshot.InnerResult = &TournamentInnerResult{Disposition: result.Disposition} + switch result.Disposition { + case InnerTournamentWinner: + snapshot.InnerResult.ParentCommitment = new(result.ParentCommitment) + snapshot.InnerResult.PausedAllowance = result.PausedAllowance + case InnerTournamentUnsettled, InnerTournamentEliminable: + default: + return fmt.Errorf("tournament %s has unknown inner disposition %s", t.Address, result.Disposition) + } + } + recovery, err := adapter.BondRecovery(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament bond recovery", err, + "application", app.Name, "tournament", t.Address) + return err + } + snapshot.BondRecovery.Disposition = recovery.Disposition + switch recovery.Disposition { + case BondDispositionRecoverable: + payment, err := Uint256FromBig(recovery.Payment) + if err != nil { + return fmt.Errorf("tournament %s bond payment: %w", t.Address, err) + } + snapshot.BondRecovery.Claimer = new(recovery.Claimer) + snapshot.BondRecovery.Payment = &payment + case BondDispositionTournamentRunning, BondDispositionNoWinner, BondDispositionRecovered: + default: + return fmt.Errorf("tournament %s has unknown bond disposition %s", t.Address, recovery.Disposition) + } + t.Snapshot = snapshot + return nil } -func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentBlock uint64) error { - if app.LastTournamentCheckBlock >= mostRecentBlock { - s.Logger.Debug("No new blocks since last tournament check", "application", app.Name, - "last_tournament_check_block", app.LastTournamentCheckBlock, "most_recent_block", mostRecentBlock) - return nil // nothing to do +// checkEpochs commits a complete event window before accepting any epoch. +// A failed RPC read or database write leaves the shared cursor unchanged. +// The first return value asks the caller to defer later PRT actions. +func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentBlock uint64) (bool, error) { + epochs, consensus, err := s.observeApplicationTournaments(ctx, app, mostRecentBlock) + if err != nil { + return true, err + } + if app.Status != ApplicationStatus_OK { + return true, nil } + return s.reconcileAcceptedEpochs(ctx, app, epochs, consensus, mostRecentBlock) +} - epochs, _, err := getAllClaimComputedEpochs(ctx, s.repository, app.Name) +// observeApplicationTournaments indexes chain facts without consulting local +// claim readiness or changing application health. The returned roots are also +// used by the separate local claim reconciliation after publication. +func (s *Service) observeApplicationTournaments( + ctx context.Context, app *Application, mostRecentBlock uint64, +) ([]*Epoch, DaveConsensusAdapter, error) { + epochs, _, err := getTournamentObservationEpochs(ctx, s.repository, app.Name) if err != nil { - s.Logger.Error("failed to list epochs", "application", app.Name, "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to list epochs", err, "application", app.Name) + return nil, nil, err } if len(epochs) == 0 { - s.Logger.Debug("No epochs with claim computed status", "application", app.Name) - return nil // nothing to do + s.Logger.Debug("No tournament roots to observe", "application", app.Name) + return epochs, nil, nil } consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) if err != nil { s.Logger.Error("failed to bind dave consensus contract", "application", app.Name, "consensus_address", app.IConsensusAddress.String(), "error", err) - return err + return nil, nil, err + } + windowEnd := min(mostRecentBlock, app.LastEpochCheckBlock) + if app.ForecloseBlock != 0 && app.LastEpochCheckBlock >= app.ForecloseBlock { + // Foreclosure ends root creation, not the existing tournament trees. + // Once every pre-foreclosure root is known, their events remain live. + windowEnd = mostRecentBlock } + if windowEnd > app.LastTournamentCheckBlock { + if err := s.observeTournamentWindow(ctx, app, epochs, consensus, windowEnd); err != nil { + s.recordTournamentObservationFailure(ctx, app, mostRecentBlock, windowEnd, err) + return nil, nil, err + } + s.clearTournamentObservationFailure(app.ID) + } + + return epochs, consensus, nil +} + +// observeTournamentWindow publishes projections, events, and their cursor in +// one transaction. Acceptance reconciliation and transactions are not part of +// this operation or its readiness signal. +func (s *Service) observeTournamentWindow( + ctx context.Context, app *Application, epochs []*Epoch, consensus DaveConsensusAdapter, windowEnd uint64, +) error { + var roots []*Epoch + for _, epoch := range epochs { + if epoch.TournamentAddress != nil && epoch.LastBlock <= windowEnd { + roots = append(roots, epoch) + } + } + var levelCount uint64 + if len(roots) > 0 { + var err error + levelCount, err = consensus.TournamentLevelCount(pinnedCallOpts(ctx, windowEnd)) + if err != nil { + return fmt.Errorf("fetching tournament level count: %w", err) + } + if levelCount == 0 { + return errors.New("tournament level count is zero") + } + } + var batches []*repository.TournamentEventBatch + for _, epoch := range roots { + rootBatches, err := s.gatherTournamentData(ctx, app, epoch, RootLevel, nil, nil, + *epoch.TournamentAddress, levelCount, windowEnd) + if err != nil { + return fmt.Errorf("gathering epoch %d tournament events: %w", epoch.Index, err) + } + batches = append(batches, rootBatches...) + } + if err := s.repository.StoreTournamentEvents(ctx, app.ID, batches, windowEnd); err != nil { + return fmt.Errorf("storing application tournament event window: %w", err) + } + app.LastTournamentCheckBlock = windowEnd + return nil +} + +func (s *Service) reconcileAcceptedEpochs( + ctx context.Context, app *Application, epochs []*Epoch, consensus DaveConsensusAdapter, mostRecentBlock uint64, +) (bool, error) { + if mostRecentBlock < app.LastTournamentCheckBlock { + // A stored projection from a later head cannot decide local status at + // this older configured head. Keep it and wait for the head to catch up. + return true, nil + } for _, epoch := range epochs { + if epoch.Status != EpochStatus_ClaimComputed && epoch.Status != EpochStatus_ClaimStaged { + continue + } if epoch.TournamentAddress == nil || epoch.Commitment == nil || epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { - return s.setApplicationCorrupted(ctx, app, - "epoch %d has missing required fields for ClaimComputed status", epoch.Index) + return true, s.setApplicationCorrupted(ctx, app, + "epoch %d has missing required fields for pending claim processing", epoch.Index) } - if epoch.ClaimTransactionHash == nil { // epoch not claimed on-chain yet - err = s.fetchTournamentData(ctx, app, epoch, RootLevel, nil, nil, *epoch.TournamentAddress, mostRecentBlock) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to fetch root tournament data", err, - "application", app.Name, "epoch", epoch.Index, - "tournament", epoch.TournamentAddress.String()) - return err - } - // if this epoch is not claimed on-chain yet, all other epochs with higher index should not be claimed either, so we can - // stop processing here. + // Compare only after the complete root and descendant window is durable. + // A losing local claim must not hide the on-chain winner from clients. + tournament, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()) + if err != nil { + return true, fmt.Errorf("loading epoch %d observed tournament: %w", epoch.Index, err) + } + if tournament == nil || tournament.Snapshot.FinishedAtBlock == 0 || + tournament.Snapshot.FinishedAtBlock > app.LastTournamentCheckBlock { + return false, nil + } + if tournament.Snapshot.WinnerCommitment != nil && *tournament.Snapshot.WinnerCommitment != *epoch.Commitment { + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent commitment between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.Commitment, tournament.Snapshot.WinnerCommitment) + } + if epoch.ClaimTransactionHash == nil { // no accepting EpochSealed event observed yet break } receipt, err := s.client.TransactionReceipt(ctx, *epoch.ClaimTransactionHash) if err != nil { - s.Logger.Error("failed to fetch transaction receipt for epoch", "application", app.Name, - "epoch", epoch.Index, "tx", epoch.ClaimTransactionHash, "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to fetch transaction receipt for epoch", err, "application", app.Name, + "epoch", epoch.Index, "tx", epoch.ClaimTransactionHash) + return true, err + } + if receipt == nil { + return true, fmt.Errorf("epoch %d: acceptance transaction receipt is nil", epoch.Index) + } + if receipt.TxHash != *epoch.ClaimTransactionHash { + return true, fmt.Errorf("epoch %d: acceptance receipt transaction hash %s differs from observed hash %s", + epoch.Index, receipt.TxHash, epoch.ClaimTransactionHash) + } + receiptBlock, err := checkedUint64(receipt.BlockNumber, "acceptance receipt block") + if err != nil { + return true, fmt.Errorf("epoch %d: %w", epoch.Index, err) + } + observedBlock := min(mostRecentBlock, app.LastTournamentCheckBlock) + if receiptBlock > observedBlock { + s.Logger.Debug("Acceptance transaction is newer than the published tournament window", + "application", app.Name, + "epoch", epoch.Index, + "tx", epoch.ClaimTransactionHash, + "receipt_block", receiptBlock, + "snapshot_block", observedBlock) + return true, nil } - if receipt.Status != 1 { - return fmt.Errorf("epoch %d: EpochSealed transaction hash points to failed transaction", epoch.Index) + if receipt.Status != types.ReceiptStatusSuccessful { + return true, fmt.Errorf("epoch %d: EpochSealed transaction hash points to failed transaction", epoch.Index) } var event *idaveconsensus.IDaveConsensusEpochSealed + expectedEventEpoch := new(big.Int).SetUint64(epoch.Index) + expectedEventEpoch.Add(expectedEventEpoch, common.Big1) for _, vLog := range receipt.Logs { - event, err = consensus.ParseEpochSealed(*vLog) - if err != nil { + if vLog == nil || vLog.Address != app.IConsensusAddress || + vLog.TxHash != *epoch.ClaimTransactionHash || vLog.BlockNumber != receiptBlock { + continue + } + candidate, parseErr := consensus.ParseEpochSealed(*vLog) + if parseErr != nil || candidate == nil || candidate.EpochNumber == nil || + candidate.EpochNumber.Cmp(expectedEventEpoch) != 0 { continue // Skip logs that don't match } + event = candidate break } if event == nil { - return fmt.Errorf("epoch %d: failed to find EpochSealed event in receipt logs", epoch.Index) + return true, fmt.Errorf("epoch %d: failed to find EpochSealed event in receipt logs", epoch.Index) } - if epoch.Index != event.EpochNumber.Uint64()-1 { - return s.setApplicationDiverged(ctx, app, "Epoch %d has inconsistent index between off-chain (%d) and on-chain (%d)", - epoch.Index, epoch.Index, event.EpochNumber.Uint64()-1) - } if *epoch.MachineHash != event.InitialMachineStateHash { - return s.setApplicationDiverged(ctx, app, "Epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", epoch.Index, epoch.MachineHash.String(), hexutil.Encode(event.InitialMachineStateHash[:])) } if *epoch.TxBufferDataBlock != event.OutputsMerkleRoot { - return s.setApplicationDiverged(ctx, app, "Epoch %d has inconsistent claim hash between off-chain (%s) and on-chain (%s)", + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent claim hash between off-chain (%s) and on-chain (%s)", epoch.Index, epoch.TxBufferDataBlock.String(), hexutil.Encode(event.OutputsMerkleRoot[:])) } - err = s.fetchTournamentData(ctx, app, epoch, RootLevel, nil, nil, *epoch.TournamentAddress, mostRecentBlock) - if err != nil { - s.logErrorUnlessShutdown(ctx, "failed to fetch tournament data", err, - "application", app.Name, "epoch", epoch.Index, - "tournament", epoch.TournamentAddress.String()) - return err - } - s.Logger.Info("Found finalized epoch. OutputsMerkleRoot matched. Setting claim as accepted", "application", app.Name, "epoch", epoch.Index, @@ -476,17 +592,22 @@ func (s *Service) checkEpochs(ctx context.Context, app *Application, mostRecentB "tx", epoch.ClaimTransactionHash, ) - epoch.Status = EpochStatus_ClaimAccepted - err = s.repository.UpdateEpochStatus(ctx, app.Name, epoch) + if s.submissionEnabled { + s.queueRootBondRecovery(app.ID, epoch.Index, *epoch.TournamentAddress) + } + err = s.repository.UpdateEpochWithAcceptedClaim(ctx, app.ID, epoch.Index, epoch.ClaimTransactionHash) if err != nil { - s.Logger.Error("failed to update epoch status to claim accepted", "application", app.Name, "epoch", epoch.Index, "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to update epoch status to claim accepted", err, + "application", app.Name, "epoch", epoch.Index) + return true, err } } - return nil + return false, nil } -func (s *Service) fetchTournamentData( +// gatherTournamentData reads a complete subtree without storing projections, +// events, or cursors. The returned batches have parents before their children. +func (s *Service) gatherTournamentData( ctx context.Context, app *Application, epoch *Epoch, @@ -494,73 +615,81 @@ func (s *Service) fetchTournamentData( parentMatchIDHash *common.Hash, parentTournamentAddress *common.Address, tournamentAddress common.Address, + levelCount uint64, mostRecentBlock uint64, -) error { +) ([]*repository.TournamentEventBatch, error) { s.Logger.Debug("Fetching tournament data", "level", level, "application", app.Name, "tournament", tournamentAddress.String()) - adapter, err := s.adapterFactory.CreateTournamentAdapter(tournamentAddress) + t, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), tournamentAddress.Hex()) if err != nil { - s.Logger.Error("failed to create tournament adapter", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to load tournament from database", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err } - - t, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), tournamentAddress.Hex()) + created := t == nil + if !created { + if t.MaxLevel != levelCount || t.Level != uint64(level) { + return nil, fmt.Errorf("tournament %s database geometry does not match observed hierarchy", tournamentAddress) + } + if tournamentObservationComplete(t, app.LastTournamentCheckBlock, mostRecentBlock) { + // Retirement belongs to this clone only. Its children can still + // expire or recover bonds after the parent stops changing. + return s.gatherInnerTournamentData(ctx, app, epoch, t, &TournamentEvents{}, mostRecentBlock) + } + } + adapter, err := s.adapterFactory.CreateTournamentAdapter(tournamentAddress) if err != nil { - s.Logger.Error("failed to load tournament from database", "level", level, "application", app.Name, + s.Logger.Error("failed to create tournament adapter", "level", level, "application", app.Name, "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err + return nil, err } - if t == nil { - t, err = s.createTournament(ctx, app, epoch, level, - parentMatchIDHash, parentTournamentAddress, tournamentAddress) + if created { + t, err = s.readTournament(ctx, app, epoch, level, + parentMatchIDHash, parentTournamentAddress, tournamentAddress, adapter, levelCount, mostRecentBlock) if err != nil { s.logErrorUnlessShutdown(ctx, "failed to create new tournament", err, "level", level, "application", app.Name, "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) - return err + return nil, err } - } else if t.FinishedAtBlock == 0 { - err = s.updateTournamentIfFinished(ctx, app, epoch, level, adapter, t, mostRecentBlock) + } + if !created { + // Do not mutate a repository-owned projection before the window commits. + projection := *t + t = &projection + previousFinish := t.Snapshot.FinishedAtBlock + err = s.refreshTournament(ctx, app, epoch, level, adapter, t, mostRecentBlock) if err != nil { - s.Logger.Error("failed to check if tournament was finished", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to check if tournament was finished", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err } - if t.FinishedAtBlock != 0 { + if previousFinish == 0 && t.Snapshot.FinishedAtBlock != 0 { s.Logger.Info("Found finished tournament", "level", level, "application", app.Name, "epoch", epoch.Index, "tournament_address", t.Address.String()) } } - nextSearchBlock := max(epoch.LastBlock, app.LastTournamentCheckBlock+1) - var endBlock uint64 - if t.FinishedAtBlock != 0 { - if nextSearchBlock > t.FinishedAtBlock { - s.Logger.Debug("No new blocks to search for tournament events", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), - "finished_at_block", t.FinishedAtBlock, "next_search_block", nextSearchBlock) - return nil + nextSearchBlock := max(t.StartInstant, app.LastTournamentCheckBlock+1) + if created { + // First observation must include the clone's history even if its + // creation is below this application's existing checkpoint. + nextSearchBlock = t.StartInstant + } + endBlock := mostRecentBlock + events := &TournamentEvents{} + if nextSearchBlock <= endBlock { + s.Logger.Debug("Searching for tournament events", "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), + "next_search_block", nextSearchBlock, "end_block", endBlock) + opts := &bind.FilterOpts{Context: ctx, Start: nextSearchBlock, End: &endBlock} + events, err = adapter.RetrieveAllEvents(opts) + if err != nil { + return nil, fmt.Errorf("retrieving tournament %s events: %w", tournamentAddress, err) + } + if events == nil { + return nil, fmt.Errorf("tournament %s returned nil events", tournamentAddress) } - endBlock = t.FinishedAtBlock - } else { - endBlock = mostRecentBlock - } - - s.Logger.Debug("Searching for tournament events", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), - "next_search_block", nextSearchBlock, "end_block", endBlock) - opts := &bind.FilterOpts{ - Context: ctx, - Start: nextSearchBlock, - End: &endBlock, - } - - events, err := adapter.RetrieveAllEvents(opts) - if err != nil { - s.Logger.Error("failed to retrieve all events from tournament", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err } s.Logger.Debug("Retrieved events for tournament", "level", level, "address", t.Address.String(), @@ -571,424 +700,253 @@ func (s *Service) fetchTournamentData( "matchDeleted", len(events.MatchDeleted), "newInnerTournament", len(events.NewInnerTournament)) - err = s.saveTournamentEvents(ctx, app, epoch, tournamentAddress, events, endBlock) + previousCounts := zeroStructuralEventCounts() + previousBondDisposition := BondDispositionTournamentRunning + if !created && nextSearchBlock > t.StartInstant { + previousOpts := pinnedCallOpts(ctx, nextSearchBlock-1) + previousCounts, err = adapter.StructuralEventCounts(previousOpts) + if err != nil { + return nil, fmt.Errorf("reading previous tournament event counts: %w", err) + } + previousBond, err := adapter.BondRecovery(previousOpts) + if err != nil { + return nil, fmt.Errorf("reading previous tournament bond recovery: %w", err) + } + previousBondDisposition = previousBond.Disposition + } + currentCounts, err := adapter.StructuralEventCounts(pinnedCallOpts(ctx, endBlock)) if err != nil { - s.Logger.Error("failed to save events for tournament", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", t.Address.String(), "error", err) - return err + return nil, fmt.Errorf("reading current tournament event counts: %w", err) } + if err := validateTournamentEventCounts(previousCounts, currentCounts, events); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) + } + if err := validateTournamentFinancialEvents(previousBondDisposition, t.Snapshot.BondRecovery.Disposition, events); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) + } + batch, err := s.tournamentEventBatch(ctx, app, epoch, t, adapter, events, endBlock) + if err != nil { + return nil, fmt.Errorf("projecting tournament %s events: %w", tournamentAddress, err) + } + children, err := s.gatherInnerTournamentData(ctx, app, epoch, t, events, mostRecentBlock) + if err != nil { + return nil, err + } + batches := make([]*repository.TournamentEventBatch, 0, 1+len(children)) + batches = append(batches, batch) + return append(batches, children...), nil +} + +// Finished clones reject joins and all match mutations. Once their winner can +// no longer expire and their bond cannot be recovered, their current views and +// events are immutable. Require a committed complete observation first. +func tournamentObservationComplete(t *Tournament, cursor, head uint64) bool { + snapshot := t.Snapshot + if snapshot.FinishedAtBlock == 0 || snapshot.FinishedAtBlock > snapshot.AsOfBlock || + snapshot.AsOfBlock > cursor || snapshot.AsOfBlock > head || + (t.Level != uint64(RootLevel) && t.CreationEvent == nil) { + return false + } + switch snapshot.Standing { + case TournamentStandingRootWinner, TournamentStandingRootFailed, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + return snapshot.BondRecovery.Disposition == BondDispositionNoWinner || + snapshot.BondRecovery.Disposition == BondDispositionRecovered + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, TournamentStandingInnerWinner: + return false + default: + return false + } +} + +func (s *Service) gatherInnerTournamentData( + ctx context.Context, app *Application, epoch *Epoch, t *Tournament, events *TournamentEvents, mostRecentBlock uint64, +) ([]*repository.TournamentEventBatch, error) { + var batches []*repository.TournamentEventBatch - if level == BottomLevel { - return nil // no inner tournaments + if t.Level+1 >= t.MaxLevel { + return batches, nil // no inner tournaments } + level, levelCount, tournamentAddress := TournamentLevel(t.Level), t.MaxLevel, t.Address nextLevel := level + 1 innerTournaments, _, err := getAllSubTournaments(ctx, s.repository, app.Name, epoch.Index, &tournamentAddress, level+1) if err != nil { - s.Logger.Error("failed to list inner tournaments", "level", level, "application", app.Name, - "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to list inner tournaments", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err } + seen := make(map[common.Address]*Tournament, len(innerTournaments)+len(events.NewInnerTournament)) for _, i := range innerTournaments { + if i.ParentMatchIDHash == nil || i.ParentTournamentAddress == nil || *i.ParentTournamentAddress != tournamentAddress { + return nil, fmt.Errorf("child tournament %s has an invalid parent link", i.Address) + } s.Logger.Debug("Fetching data for previous open tournament", "level", nextLevel, "parent_match_id_hash", i.ParentMatchIDHash.String(), "parent_tournament_address", i.ParentTournamentAddress.String(), "address", i.Address.String()) - if i.FinishedAtBlock != 0 { - s.Logger.Debug("Skipping finished inner tournament", "address", i.Address.String()) - continue // already finished - } - - err = s.fetchTournamentData(ctx, app, epoch, nextLevel, i.ParentMatchIDHash, &tournamentAddress, i.Address, mostRecentBlock) + childBatches, err := s.gatherTournamentData(ctx, app, epoch, nextLevel, i.ParentMatchIDHash, + &tournamentAddress, i.Address, levelCount, mostRecentBlock) if err != nil { s.logErrorUnlessShutdown(ctx, "failed to fetch tournament data", err, "level", nextLevel, "application", app.Name, "tournament", i.Address.String()) - return err + return nil, err + } + batches = append(batches, childBatches...) + storedChild := *i + seen[i.Address] = &storedChild + for _, childBatch := range childBatches { + if childBatch.Tournament.Address == i.Address { + seen[i.Address] = childBatch.Tournament + break + } } } for _, newInner := range events.NewInnerTournament { hashID := (common.Hash)(newInner.MatchIdHash) childAddress := newInner.ChildTournament + if child := seen[childAddress]; child != nil { + // A retired child already has this immutable creation tuple. Do + // not modify its repository-owned projection during validation. + projection := *child + if err := applyTournamentCreationEvent(&projection, newInner); err != nil { + return nil, err + } + child.CreationEvent = projection.CreationEvent + continue + } s.Logger.Info("NewInnerTournament event", "id_hash", hashID.String(), "tournament_address", childAddress.String()) - err = s.fetchTournamentData(ctx, app, epoch, nextLevel, &hashID, &tournamentAddress, childAddress, mostRecentBlock) + childBatches, err := s.gatherTournamentData(ctx, app, epoch, nextLevel, &hashID, + &tournamentAddress, childAddress, levelCount, mostRecentBlock) if err != nil { s.logErrorUnlessShutdown(ctx, "failed to fetch tournament data", err, "level", nextLevel, "application", app.Name, "tournament", childAddress.String()) - return err + return nil, err } - } - - return nil -} - -func (s *Service) trySettle(ctx context.Context, app *Application, mostRecentBlock uint64) error { - if _, exist := s.currentEpochIndex[app.ID]; !exist { - s.currentEpochIndex[app.ID] = 0 - } - currentEpochIndex := s.currentEpochIndex[app.ID] - - if tx, joinTxIsInFlight := s.joinInFlight[app.ID]; joinTxIsInFlight { - s.Logger.Debug("Waiting for join tournament transaction to be mined", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - return nil // wait for join to be mined before settling - } - - if tx, settleTxIsInFlight := s.settleInFlight[app.ID]; settleTxIsInFlight { - _, isPending, err := s.client.TransactionByHash(ctx, *tx) - if err != nil { - s.Logger.Error("failed to fetch last settle transaction status", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx, "error", err) - return err + if len(childBatches) == 0 || childBatches[0].Tournament.Address != childAddress { + return nil, fmt.Errorf("new child tournament %s has no initial observation", childAddress) } - if isPending { - s.Logger.Debug("Previous settle transaction is still pending", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - return nil + if err := applyTournamentCreationEvent(childBatches[0].Tournament, newInner); err != nil { + return nil, err } - s.Logger.Debug("Previous settle transaction has been mined", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - delete(s.settleInFlight, app.ID) - // Return so that the next tick's checkEpochs syncs the EpochSealed - // event before we re-check CanSettle. Without this, a stale - // mostRecentBlock snapshot could cause CanSettle to return true - // and trigger a duplicate Settle that reverts on-chain. - return nil + batches = append(batches, childBatches...) + seen[childAddress] = childBatches[0].Tournament } - consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) - if err != nil { - s.Logger.Error("failed to bind dave consensus contract", "application", app.Name, - "consensus_address", app.IConsensusAddress.String(), "error", err) - return err - } - - callOpts := &bind.CallOpts{ - Context: ctx, - BlockNumber: new(big.Int).SetUint64(mostRecentBlock), - } - - result, err := consensus.CanSettle(callOpts) - if err != nil { - s.Logger.Error("failed to call CanSettle on DaveConsensus", "application", app.Name, - "consensus", app.IConsensusAddress.String(), "error", err) - return err - } - - currentEpochIndex = result.EpochNumber.Uint64() - s.currentEpochIndex[app.ID] = currentEpochIndex - - if !result.IsFinished { - s.Logger.Debug("Epoch root tournament has not finished yet. Skipping Settle", - "application", app.Name, "epoch_index", currentEpochIndex) - return nil // nothing to do - } - - epoch, err := s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), currentEpochIndex) - if err != nil { - s.Logger.Error("failed to list epochs", "application", app.Name, "error", err) - return err - } - if epoch == nil || epoch.Status != EpochStatus_ClaimComputed { - s.Logger.Info("Application sync has not finished. Skipping Settle", "application", app.Name, - "epoch_index", currentEpochIndex) - return nil // nothing to do - } - - if epoch.TxBufferDataBlock == nil || epoch.TxBufferProof == nil { - return s.setApplicationCorrupted(ctx, app, - "epoch %d has missing required fields for settlement", epoch.Index) - } - - // Check on-chain if the epoch was already settled (e.g., after a node - // restart where settleInFlight was lost). CanSettle only checks if - // the tournament has finished, not if settlement was already performed. - alreadySettled, err := consensus.IsEpochSettled(callOpts, currentEpochIndex) - if err != nil { - s.Logger.Error("failed to check if epoch is already settled", "application", app.Name, - "epoch_index", currentEpochIndex, "error", err) - return err - } - if alreadySettled { - s.Logger.Info("Epoch already settled on-chain, waiting for event sync", - "application", app.Name, "epoch_index", currentEpochIndex) - return nil - } - - s.Logger.Info("Sending Settle transaction", "application", app.Name, "epoch_index", epoch.Index, - "outputs_merkle_root", epoch.TxBufferDataBlock.String()) + return batches, nil +} - if s.txOptsFactory == nil { - return fmt.Errorf("txOpts is required for settlement") +func applyTournamentCreationEvent(child *Tournament, event *itournament.ITournamentNewInnerTournament) error { + if child.Address != event.ChildTournament || child.ParentMatchIDHash == nil || *child.ParentMatchIDHash != event.MatchIdHash || + child.StartInstant != event.Raw.BlockNumber { + return fmt.Errorf("child tournament %s descriptor does not match its creation event", child.Address) } - txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) - defer cancel() - txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) - if err != nil { - return fmt.Errorf("creating transaction options for settlement: %w", err) + creation := &TournamentCreationEvent{ + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), } - tx, err := consensus.Settle(txOpts, result.EpochNumber, - *epoch.TxBufferDataBlock, hashSliceToByteSlice(epoch.TxBufferProof)) - if err != nil { - return s.handleSettleRevert(ctx, app, result.EpochNumber.Uint64(), err) + if child.CreationEvent != nil && *child.CreationEvent != *creation { + return fmt.Errorf("child tournament %s has conflicting creation events", child.Address) } - settleTx := tx.Hash() - s.settleInFlight[app.ID] = &settleTx - + child.CreationEvent = creation return nil } -// handleSettleRevert classifies a Settle error and performs the matching -// state change. The known DaveConsensus reverts: -// -// - IncorrectEpochNumber: carries (received, actual). received < actual -// means the epoch was already settled — after a restart when the -// IsEpochSettled pre-check used a slightly stale block number, or -// another entity settled concurrently; wait for event sync. -// received > actual means the local epoch index is ahead of the chain -// (wrong consensus address or corrupted local state) — FAILED, since -// waiting would stall silently forever. -// - TournamentNotFinishedYet: CanSettle returned true at this tick's pinned -// block, but the provider simulated the call against different state. -// Transient; retry next tick. -// - InvalidOutputsMerkleRootProofSize / InvalidOutputsMerkleRootProof: the -// locally stored outputs merkle proof does not prove the outputs root -// against the settled machine state — local data corruption; CORRUPTED. -// - ApplicationForeclosed: retry while the EVM reader records the -// foreclosure marker (settle runs the same foreclosure probe as the -// IConsensus claim methods). -// - ApplicationNotDeployed / ApplicationReverted / -// IllformedApplicationReturnData: the foreclosure probe failed — wrong -// application address, or a broken/adversarial application contract; -// FAILED, with the application's revert data preserved in the reason. -// -// JSON-RPC "nonce too low" broadcast rejections retry next tick; unknown -// errors are returned to the caller unchanged, with the decoded revert name -// in the log when one of the known ABIs declares it. -func (s *Service) handleSettleRevert(ctx context.Context, app *Application, epochNumber uint64, err error) error { - switch { - case isDaveConsensusError(err, "IncorrectEpochNumber"): - if received, actual, ok := decodeIncorrectEpochNumber(err); ok && - received.Cmp(actual) > 0 { - return s.setApplicationFailed(ctx, app, - "Settle reverted with IncorrectEpochNumber: the node tried to "+ - "settle epoch %s but the chain expects epoch %s — the local "+ - "epoch index is ahead of the DaveConsensus contract. Verify "+ - "the consensus address configuration and local state before "+ - "re-enabling.", - received, actual) - } - s.Logger.Info( - "Epoch already settled on-chain (detected via revert), "+ - "waiting for event sync", - "application", app.Name, - "epoch_index", epochNumber) - return nil - - case isDaveConsensusError(err, "TournamentNotFinishedYet"): - s.Logger.Warn( - "Settle reverted with TournamentNotFinishedYet; the provider's "+ - "state may lag this tick's CanSettle read, retrying next tick", - "application", app.Name, - "epoch_index", epochNumber) - return nil - - case isDaveConsensusError(err, "InvalidOutputsMerkleRootProofSize"): - return s.setApplicationCorrupted(ctx, app, - "Settle reverted with InvalidOutputsMerkleRootProofSize for epoch %d — "+ - "the outputs merkle proof stored locally has the wrong length for "+ - "the settled machine state.", - epochNumber) - - case isDaveConsensusError(err, "InvalidOutputsMerkleRootProof"): - return s.setApplicationCorrupted(ctx, app, - "Settle reverted with InvalidOutputsMerkleRootProof for epoch %d — "+ - "the outputs merkle proof stored locally does not prove the outputs "+ - "root against the settled machine state.", - epochNumber) - - case isDaveConsensusError(err, "ApplicationForeclosed"): - s.Logger.Warn("Settle reverted with ApplicationForeclosed; "+ - "awaiting Foreclosure observer to record the foreclosure marker", - "application", app.Name, - "epoch_index", epochNumber) - return nil - - case isDaveConsensusError(err, "ApplicationNotDeployed"): - return s.setApplicationFailed(ctx, app, - "Settle reverted with ApplicationNotDeployed for epoch %d: no "+ - "contract code exists at the application address bound to the "+ - "DaveConsensus contract. Verify the application address and "+ - "that the application contract is deployed on this chain "+ - "before re-enabling.", - epochNumber) - - case isDaveConsensusError(err, "ApplicationReverted"): - return s.setApplicationFailed(ctx, app, - "Settle reverted with ApplicationReverted for epoch %d: the "+ - "application contract reverted when the consensus contract "+ - "queried it. Verify the deployed contract and its compatibility "+ - "with the consensus contract before re-enabling.%s", - epochNumber, daveAppReturnDataSuffix(err, "ApplicationReverted")) - - case isDaveConsensusError(err, "IllformedApplicationReturnData"): - return s.setApplicationFailed(ctx, app, - "Settle reverted with IllformedApplicationReturnData for epoch %d: "+ - "the application contract returned malformed data when the "+ - "consensus contract queried it. Verify the deployed contract "+ - "and its compatibility with the consensus contract before "+ - "re-enabling.%s", - epochNumber, daveAppReturnDataSuffix(err, "IllformedApplicationReturnData")) - - case ethutil.IsNonceTooLowError(err): - // Transient broadcast race: the chain has already mined a tx with - // this EOA's nonce, so this attempt is rejected before execution. - // Most commonly hit straddling a node restart — the prior process - // broadcast Settle (or some other tx) that landed, but the - // post-restart PendingNonceAt has not yet caught up. The next tick's - // IsEpochSettled check reads chain state at a fresh block and - // short-circuits if our prior Settle actually mined; otherwise a - // new broadcast goes out with a fresh nonce. - s.Logger.Info( - "Settle broadcast rejected with 'nonce too low'; "+ - "deferring to the next tick's IsEpochSettled reconciliation", - "application", app.Name, - "epoch_index", epochNumber) - return nil - } - s.Logger.Error("failed to send Settle transaction", "application", app.Name, - "epoch_index", epochNumber, "error", err, - "decoded_revert", describeKnownRevert(err)) - return err -} - -func (s *Service) reactToTournament(ctx context.Context, app *Application, mostRecentBlock uint64) error { - currentEpochIndex, exist := s.currentEpochIndex[app.ID] - if !exist { - errMsg := "current epoch index not found for application. Should not happen" - s.Logger.Error(errMsg, "application", app.Name) - return errors.New(errMsg) - } - if tx, settleTxIsInFlight := s.settleInFlight[app.ID]; settleTxIsInFlight { - s.Logger.Debug("Waiting for settle transaction to be mined", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - return nil // wait for settle to be mined - } - - if tx, joinTxIsInFlight := s.joinInFlight[app.ID]; joinTxIsInFlight { - _, isPending, err := s.client.TransactionByHash(ctx, *tx) - if err != nil { - s.Logger.Error("failed to fetch last join tournament transaction status", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx, "error", err) - return err - } - if isPending { - s.Logger.Debug("Previous join tournament transaction is still pending", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - return nil - } - s.Logger.Debug("Previous join tournament transaction has been mined", "application", app.Name, - "epoch_index", currentEpochIndex, "tx", tx) - delete(s.joinInFlight, app.ID) - // Return so that the next tick's checkEpochs syncs the CommitmentJoined - // event before we re-check GetCommitment. Without this, a stale - // mostRecentBlock snapshot could cause GetCommitment to return nil - // and trigger a duplicate JoinTournament that reverts on-chain. - return nil - } - - epoch, err := s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), currentEpochIndex) - if err != nil { - s.Logger.Error("failed to list epochs", "application", app.Name, "error", err) - return err - } +// reactToTournament reports true only when the commitment is already joined. +// A deferred or newly broadcast join must not permit a new bond refund this tick. +func (s *Service) reactToTournament(ctx context.Context, app *Application, epoch *Epoch, mostRecentBlock uint64) (bool, error) { if epoch == nil || epoch.Status != EpochStatus_ClaimComputed { - s.Logger.Debug("Application sync has not finished. Skipping join tournament", "application", app.Name, - "epoch_index", currentEpochIndex) - return nil // nothing to do + s.Logger.Debug("Application sync has not finished. Skipping join tournament", "application", app.Name) + return false, nil } if epoch.TournamentAddress == nil || epoch.Commitment == nil || epoch.MachineHash == nil || epoch.CommitmentProof == nil { - return s.setApplicationCorrupted(ctx, app, + return false, s.setApplicationCorrupted(ctx, app, "epoch %d has missing required fields for tournament reaction", epoch.Index) } commitment, err := s.repository.GetCommitment(ctx, app.IApplicationAddress.Hex(), epoch.Index, epoch.TournamentAddress.Hex(), epoch.Commitment.String()) if err != nil { - s.Logger.Error("failed to get commitment from repository", "application", app.Name, - "epoch_index", currentEpochIndex, "tournament", epoch.TournamentAddress.Hex(), - "commitment", epoch.Commitment.Hex(), "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to get commitment from repository", err, "application", app.Name, + "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex(), + "commitment", epoch.Commitment.Hex()) + return false, err } if commitment != nil { s.Logger.Debug("Commitment already joined. Skipping JoinTournament", "application", app.Name, - "epoch_index", currentEpochIndex, "tournament", epoch.TournamentAddress.Hex(), "commitment", epoch.Commitment.Hex()) - return nil + "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex(), "commitment", epoch.Commitment.Hex()) + return true, nil } tournamentAdapter, err := s.adapterFactory.CreateTournamentAdapter(*epoch.TournamentAddress) if err != nil { s.Logger.Error("failed to create tournament adapter", "application", app.Name, "tournament", epoch.TournamentAddress.String(), "error", err) - return err + return false, err } - callOpts := &bind.CallOpts{ - Context: ctx, - BlockNumber: new(big.Int).SetUint64(mostRecentBlock), - } + callOpts := pinnedCallOpts(ctx, mostRecentBlock) // Check on-chain if the commitment was already joined (e.g., after a node - // restart where joinInFlight was lost and the DB event sync hasn't caught up). - alreadyJoined, err := tournamentAdapter.IsCommitmentJoined(callOpts, *epoch.Commitment) + // restart where the pending transaction was lost and the DB event sync hasn't caught up). + commitmentStanding, err := tournamentAdapter.CommitmentStanding(callOpts, *epoch.Commitment) if err != nil { - s.Logger.Error("failed to check commitment on-chain", "application", app.Name, - "epoch_index", currentEpochIndex, "tournament", epoch.TournamentAddress.Hex(), - "commitment", epoch.Commitment.Hex(), "error", err) - return err + s.logErrorUnlessShutdown(ctx, "failed to check commitment on-chain", err, "application", app.Name, + "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex(), + "commitment", epoch.Commitment.Hex()) + return false, err } - if alreadyJoined { + if commitmentStanding.Joined { + if err := s.validateJoinedCommitment(ctx, app, epoch, commitmentStanding); err != nil { + return false, err + } s.Logger.Info("Commitment already joined on-chain, waiting for event sync", - "application", app.Name, "epoch_index", currentEpochIndex, + "application", app.Name, "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex(), "commitment", epoch.Commitment.Hex()) - return nil + return true, nil } - bondValue, err := tournamentAdapter.BondValue(callOpts) + descriptor, err := tournamentAdapter.Descriptor(callOpts) if err != nil { - s.Logger.Error("failed to fetch tournament bond value", "application", app.Name, - "epoch_index", currentEpochIndex, "tournament", epoch.TournamentAddress.Hex(), - "error", err) - return err + return false, fmt.Errorf("reading root tournament geometry before joining: %w", err) + } + if descriptor.Level != uint64(RootLevel) || descriptor.Height != Log2EpochComputationHashLeafCount { + return false, s.setApplicationFailed(ctx, app, + "Cannot join tournament %s: root level %d and commitment height %d are required; got level %d and height %d. "+ + "Check the tournament factory and node versions before re-enabling.", + epoch.TournamentAddress, RootLevel, Log2EpochComputationHashLeafCount, descriptor.Level, descriptor.Height) } - if s.txOptsFactory == nil { - return fmt.Errorf("txOpts is required for joining tournament") + bondValue, err := tournamentAdapter.BondValue(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament bond value", err, "application", app.Name, + "epoch_index", epoch.Index, "tournament", epoch.TournamentAddress.Hex()) + return false, err } + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) defer cancel() txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) if err != nil { - return fmt.Errorf("creating transaction options for joining tournament: %w", err) + return false, fmt.Errorf("creating transaction options for joining tournament: %w", err) } txOptsWithValue := *txOpts txOptsWithValue.Value = bondValue - // FIXME move this to constants - idx := uint64(1<<48) - 1 //nolint: mnd + idx := uint64(1< actual means the local epoch index is ahead of the chain. func decodeIncorrectEpochNumber(err error) (received, actual *big.Int, ok bool) { values, ok := ethutil.UnpackRevert(err, idaveconsensus.IDaveConsensusMetaData, "IncorrectEpochNumber") @@ -1173,23 +1188,6 @@ func decodeIncorrectEpochNumber(err error) (received, actual *big.Int, ok bool) return received, actual, true } -// daveAppReturnDataSuffix formats the application-provided returndata carried -// by ApplicationReverted and IllformedApplicationReturnData reverts as a -// reason suffix. The bytes are controlled by the application contract, so -// they are hex-encoded to keep them inert in logs and in the database. -// Returns "" when the revert data cannot be decoded. -func daveAppReturnDataSuffix(err error, name string) string { - values, ok := ethutil.UnpackRevert(err, idaveconsensus.IDaveConsensusMetaData, name) - if !ok || len(values) < 2 { - return "" - } - data, ok := values[1].([]byte) - if !ok { - return "" - } - return fmt.Sprintf(" Application return data: 0x%x.", data) -} - // describeKnownRevert renders the revert carried by err against the ABIs this // service interacts with, for the unknown-error log lines. Returns "" when // nothing matches. diff --git a/internal/prt/recovery_selection_test.go b/internal/prt/recovery_selection_test.go new file mode 100644 index 000000000..629d75dce --- /dev/null +++ b/internal/prt/recovery_selection_test.go @@ -0,0 +1,249 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func queueRecoverableOlderBond(t *testing.T, f *prtBlockPolicyFixture) *tournamentAdapterMock { + t.Helper() + address := common.HexToAddress("0x900") + f.s.queueRootBondRecovery(f.app.ID, f.epoch.Index-1, address) + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(CurrentSealedEpoch{EpochNumber: f.epoch.Index}, nil).Maybe() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(tournament, nil).Maybe() + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(canonicalBondRecovery(BondDispositionRecoverable, f.s.txOptsFactory.From(), 1), nil).Maybe() + tournament.On("TryRecoveringBond", mock.Anything). + Return(types.NewTx(&types.LegacyTx{Nonce: 9}), nil).Maybe() + return tournament +} + +func TestRecoveryDefersUntilCurrentClaimIsReady(t *testing.T) { + for _, status := range []EpochStatus{EpochStatus_Open, EpochStatus_Closed, EpochStatus_InputsProcessed} { + t.Run(status.String(), func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.epoch.Status = status + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + + require.NoError(t, f.tick(100)) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + require.Nil(t, f.s.rootBondRecoveries[f.app.ID][0].TxHash) + }) + } +} + +func TestRecoveryDefersAfterMinedTournamentTransaction(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionJoin, tournamentActionStage, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.epoch.Status = EpochStatus_ClaimStaged + f.epoch.StagedAtBlock = new(uint64(10)) + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.s.pendingTransactions[f.app.ID] = pendingTournamentTransaction{ + Action: action, Hash: tx.Hash(), EpochIndex: f.epoch.Index, + } + f.client.On("TransactionByHash", mock.Anything, tx.Hash()).Return(tx, false, nil).Once() + f.client.On("TransactionReceipt", mock.Anything, tx.Hash()).Return(&types.Receipt{ + TxHash: tx.Hash(), BlockNumber: big.NewInt(99), Status: types.ReceiptStatusSuccessful, + }, nil).Once() + f.expectHead(100) + f.client.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once() + // The next tick must read fresh state and send the eligible accept. + snapshot := resultTestSnapshot(f.epoch, true) + snapshot.accept.IsClaimStagingPeriodOver = true + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + f.consensus.On("AcceptStagedTournamentResult", mock.Anything, f.epoch.Index). + Return(types.NewTx(&types.LegacyTx{Nonce: 2}), nil).Once() + + require.NoError(t, f.tick(100)) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + require.Empty(t, f.s.pendingTransactions) + require.NoError(t, f.tick(100)) + require.Equal(t, tournamentActionAccept, f.s.pendingTransactions[f.app.ID].Action) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + }) + } +} + +func TestFailedRootAllowsRecoveryWithoutPreparedClaim(t *testing.T) { + for _, name := range []string{"missing", "open", "closed", "inputs processed"} { + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + switch name { + case "missing": + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index).Unset() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index). + Return((*Epoch)(nil), nil) + case "open": + f.epoch.Status = EpochStatus_Open + case "closed": + f.epoch.Status = EpochStatus_Closed + case "inputs processed": + f.epoch.Status = EpochStatus_InputsProcessed + } + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + + require.NoError(t, f.tick(100)) + bond.AssertNumberOfCalls(t, "TryRecoveringBond", 1) + f.assertNoPermanentStatusWrite(t) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + }) + } +} + +func TestRecoveryDefersWhenJoinBroadcastIsDeferred(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + f.repo.On("GetCommitment", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index, + f.epoch.TournamentAddress.Hex(), f.epoch.Commitment.Hex()).Return((*Commitment)(nil), nil).Once() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(tournament, nil).Once() + tournament.On("CommitmentStanding", mock.Anything, [32]byte(*f.epoch.Commitment)). + Return(CommitmentStanding{}, nil).Once() + tournament.On("Descriptor", mock.Anything). + Return(TournamentDescriptor{Height: Log2EpochComputationHashLeafCount}, nil).Once() + tournament.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + tournament.On("JoinTournament", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return((*types.Transaction)(nil), errors.New("nonce too low")).Once() + + require.NoError(t, f.tick(100)) + require.Empty(t, f.s.pendingTransactions) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + tournament.AssertExpectations(t) +} + +func TestRecoveryDefersWhenResultBroadcastIsDeferred(t *testing.T) { + for _, action := range []tournamentAction{tournamentActionStage, tournamentActionAccept} { + t.Run(string(action), func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + staged := action == tournamentActionAccept + if staged { + f.epoch.Status = EpochStatus_ClaimStaged + f.epoch.StagedAtBlock = new(uint64(10)) + } + snapshot := resultTestSnapshot(f.epoch, staged) + snapshot.accept.IsClaimStagingPeriodOver = staged + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + if staged { + f.consensus.On("AcceptStagedTournamentResult", mock.Anything, f.epoch.Index). + Return((*types.Transaction)(nil), errors.New("nonce too low")).Once() + } else { + f.consensus.On("StageTournamentResult", mock.Anything, f.epoch.Index, mock.Anything). + Return((*types.Transaction)(nil), errors.New("nonce too low")).Once() + } + + require.NoError(t, f.tick(100)) + require.Empty(t, f.s.pendingTransactions) + bond.AssertNotCalled(t, "TryRecoveringBond", mock.Anything) + candidates := f.s.rootBondRecoveries[f.app.ID] + wantCandidates := 1 + if staged { + // Acceptance queues this root before broadcast. A soft error must + // retain it without allowing a refund to take the next nonce. + wantCandidates++ + } + require.Len(t, candidates, wantCandidates) + require.Equal(t, f.epoch.Index-1, candidates[0].EpochIndex) + for _, candidate := range candidates { + require.Nil(t, candidate.TxHash) + } + f.assertNoPermanentStatusWrite(t) + }) + } +} + +func TestRecoveryRunsDuringKnownSafeTournamentWait(t *testing.T) { + for _, staged := range []bool{false, true} { + name := "joined and waiting for tournament" + if staged { + name = "waiting for staging period" + } + t.Run(name, func(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, staged) + if staged { + f.epoch.Status = EpochStatus_ClaimStaged + f.epoch.StagedAtBlock = new(uint64(10)) + } else { + snapshot.stage.IsFinished = false + f.repo.On("GetCommitment", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index, + f.epoch.TournamentAddress.Hex(), f.epoch.Commitment.Hex()).Return(&Commitment{}, nil).Once() + } + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + + require.NoError(t, f.tick(100)) + bond.AssertNumberOfCalls(t, "TryRecoveringBond", 1) + }) + } +} + +func TestPendingRecoveryIsPolledAndReportsTournamentYield(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + // No result snapshot is required while the already-sent refund is pending. + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index).Unset() + f.factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Unset() + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + hash := tx.Hash() + f.s.rootBondRecoveries = map[int64][]*rootBondRecovery{f.app.ID: {{ + EpochIndex: f.epoch.Index - 1, Tournament: common.HexToAddress("0x900"), TxHash: &hash, + }}} + f.expectHead(100) + f.client.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once() + f.client.On("TransactionByHash", mock.Anything, hash).Return(tx, true, nil).Once() + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + + require.NoError(t, f.tick(100)) + require.Contains(t, output.String(), "Tournament actions wait for pending root bond recovery") + require.Contains(t, output.String(), "level=INFO") + require.Contains(t, output.String(), hash.Hex()) + f.consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.Anything) +} + +func TestRecoveryRunsWhenJoinIsAwaitingEventSync(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsFinished = false + f.expectTick(100, 100, snapshot, snapshot) + bond := queueRecoverableOlderBond(t, f) + f.repo.On("GetCommitment", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index, + f.epoch.TournamentAddress.Hex(), f.epoch.Commitment.Hex()).Return((*Commitment)(nil), nil).Once() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(tournament, nil).Once() + tournament.On("CommitmentStanding", mock.Anything, [32]byte(*f.epoch.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: *f.epoch.MachineHash}, nil).Once() + + require.NoError(t, f.tick(100)) + bond.AssertNumberOfCalls(t, "TryRecoveringBond", 1) + tournament.AssertNotCalled(t, "JoinTournament", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + tournament.AssertExpectations(t) +} diff --git a/internal/prt/result_observation_boundary_test.go b/internal/prt/result_observation_boundary_test.go new file mode 100644 index 000000000..d21077c3f --- /dev/null +++ b/internal/prt/result_observation_boundary_test.go @@ -0,0 +1,236 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestResultWritesWaitForTournamentObservation(t *testing.T) { + for _, staged := range []bool{false, true} { + name := "opponent wins" + if staged { + name = "matching result is staged" + } + t.Run(name, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastEpochCheckBlock = 99 + epoch := checkpointEpoch(0, "0x100") + winner := *epoch.Commitment + if !staged { + winner = common.HexToHash("0xbad") + } + tournament := resultBoundaryTournament(f, epoch, winner) + snapshot := resultTestSnapshot(epoch, staged) + snapshot.stage.WinnerCommitment = winner + observed := resultTestSnapshot(epoch, false) + observed.stage.IsFinished = false + observed.stage.WinnerCommitment = common.Hash{} + observed.stage.WinnerPostEpochMachineStateHash = common.Hash{} + if staged { + snapshot.sealed.StagingBlockNumber = 100 + } + var writeCoverage []uint64 + if staged { + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(100)). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, f.app.LastTournamentCheckBlock) }).Return(nil).Once() + } else { + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, f.app.LastTournamentCheckBlock) }).Return(nil).Once() + } + f.expectResultBoundaryWindow(epoch, tournament, 99) + f.expectResultBoundarySnapshot(epoch, observed, 99) + + require.NoError(t, f.s.validateApplication(t.Context(), f.app, 100)) + require.Equal(t, uint64(99), f.app.LastTournamentCheckBlock, "the older window must still publish successfully") + require.Equal(t, uint64(99), tournament.Snapshot.AsOfBlock) + require.Equal(t, model.TournamentStandingAwaitingClosure, tournament.Snapshot.Standing) + require.Empty(t, writeCoverage, "a newer configured result must not change persistent local state") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Nil(t, epoch.StagedAtBlock) + + // No-work at the same observation boundary is not evidence that the + // configured result has become durable. + f.epochs(epoch) + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), tournament.Address.Hex()). + Return(tournament, nil).Once() + f.expectResultBoundarySnapshot(epoch, observed, 99) + require.NoError(t, f.s.validateApplication(t.Context(), f.app, 100)) + require.Empty(t, writeCoverage) + + f.app.LastEpochCheckBlock = 100 + f.expectResultBoundaryWindow(epoch, tournament, 100) + if staged { + f.expectResultBoundarySnapshot(epoch, snapshot, 100) + } + err := f.s.validateApplication(t.Context(), f.app, 100) + if staged { + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(100)), epoch.StagedAtBlock) + } else { + require.ErrorContains(t, err, "Epoch 0 has inconsistent commitment") + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) + } + require.Equal(t, []uint64{100}, writeCoverage, "the complete result window must precede the local write") + require.Equal(t, uint64(100), tournament.Snapshot.AsOfBlock) + require.Equal(t, &winner, tournament.Snapshot.WinnerCommitment) + require.Empty(t, f.s.pendingTransactions, "reader mode must remain passive") + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + }) + } +} + +func resultBoundaryTournament(f *observerCheckpointFixture, epoch *model.Epoch, winner common.Hash) *model.Tournament { + return &model.Tournament{ + ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + MaxLevel: 1, Height: 1, Kind: model.TournamentKindLeaf, StartInstant: epoch.LastBlock, Allowance: 90, + Snapshot: model.TournamentSnapshot{AsOfBlock: f.app.LastTournamentCheckBlock, + Standing: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, Candidate: &winner, + BondRecovery: model.TournamentBondRecovery{Disposition: model.BondDispositionTournamentRunning}}, + } +} + +func (f *observerCheckpointFixture) expectResultBoundarySnapshot(epoch *model.Epoch, snapshot daveConsensusSnapshot, head uint64) { + f.t.Helper() + opts := mock.MatchedBy(resultCallOptsAtBlock(head)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() +} + +func (f *observerCheckpointFixture) expectResultBoundaryWindow(epoch *model.Epoch, tournament *model.Tournament, head uint64) { + f.t.Helper() + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(head)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), tournament.Address.Hex()). + Return(tournament, nil).Twice() // observation load, then local reconciliation + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", tournament.Address).Return(adapter, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{BaseCycle: new(big.Int), Height: tournament.Height, + Kind: tournament.Kind, StartInstant: tournament.StartInstant, Allowance: tournament.Allowance}, nil).Once() + standing := TournamentStanding{State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, + HasCandidate: true, Candidate: *tournament.Snapshot.Candidate} + if head >= 100 { + standing.State, standing.AcceptsJoins = model.TournamentStandingRootWinner, false + standing.FinalState, standing.FinishedAt = *epoch.MachineHash, 100 + } + adapter.On("Standing", opts).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, standing.State) + previous := f.app.LastTournamentCheckBlock + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == previous+1 && opts.End != nil && *opts.End == head + })).Return(&TournamentEvents{}, nil).Once() + counts := zeroStructuralEventCounts() + counts.CommitmentJoined.SetUint64(1) + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(previous))).Return(counts, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(counts, nil).Once() + adapter.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(previous))). + Return(canonicalBondRecovery(tournament.Snapshot.BondRecovery.Disposition, common.Address{}, 0), nil).Once() + address := tournament.Address.Hex() + commitment := &model.Commitment{ApplicationID: f.app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Commitment: standing.Candidate, FinalStateHash: *epoch.MachineHash, BlockNumber: 20} + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return([]*model.Commitment{commitment}, uint64(1), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return([]*model.Match{}, uint64(0), nil).Once() + adapter.On("CommitmentStanding", opts, [32]byte(commitment.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 80}, nil).Once() + statusBeforePublication := epoch.Status + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, mock.Anything, head). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(f.t, batches, 1) + require.Equal(f.t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(f.t, statusBeforePublication, epoch.Status) + *tournament = *batches[0].Tournament + }).Return(nil).Once() + f.t.Cleanup(func() { adapter.AssertExpectations(f.t) }) +} + +func TestObserverLagDoesNotBlockLatestStageAction(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + f.app.LastTournamentCheckBlock, f.app.LastEpochCheckBlock = 50, 99 + f.epoch.LastBlock = 10 + observer := &observerCheckpointFixture{t: t, s: f.s, repo: f.repo, factory: f.factory, + consensus: f.consensus, client: f.client, app: f.app} + tournament := resultBoundaryTournament(observer, f.epoch, *f.epoch.Commitment) + observer.expectResultBoundaryWindow(f.epoch, tournament, 99) + observed := resultTestSnapshot(f.epoch, false) + observed.stage.IsFinished = false + observed.stage.WinnerCommitment = common.Hash{} + observed.stage.WinnerPostEpochMachineStateHash = common.Hash{} + latest := resultTestSnapshot(f.epoch, false) + f.client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + f.expectSnapshot(99, observed) + f.expectSnapshot(120, latest) + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + f.consensus.On("StageTournamentResult", mock.Anything, f.epoch.Index, mock.Anything).Return(tx, nil).Once() + + require.NoError(t, f.s.validateApplication(t.Context(), f.app, 100)) + require.Equal(t, tx.Hash(), f.s.pendingTransactions[f.app.ID].Hash) + require.Equal(t, uint64(99), f.app.LastTournamentCheckBlock) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + f.assertNoStageWrite(t) + f.assertNoPermanentStatusWrite(t) +} + +func TestAcceptanceWaitsForTournamentObservation(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 99 + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + tournament := &model.Tournament{Snapshot: model.TournamentSnapshot{ + AsOfBlock: 99, FinishedAtBlock: 90, WinnerCommitment: epoch.Commitment, + }} + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Twice() + raw := types.Log{Address: app.IConsensusAddress, TxHash: *epoch.ClaimTransactionHash, BlockNumber: 100} + receipt := &types.Receipt{TxHash: raw.TxHash, BlockNumber: big.NewInt(100), + Status: types.ReceiptStatusSuccessful, Logs: []*types.Log{&raw}} + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, raw.TxHash).Return(receipt, nil).Twice() + s.client = client + consensus := &daveConsensusAdapterMock{} + consensus.On("ParseEpochSealed", raw).Return(&idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, Raw: raw, + }, nil).Once() + var writeCoverage []uint64 + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, epoch.ClaimTransactionHash). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, app.LastTournamentCheckBlock) }).Return(nil).Once() + + deferActions, err := s.reconcileAcceptedEpochs(t.Context(), app, []*model.Epoch{epoch}, consensus, 100) + require.NoError(t, err) + require.True(t, deferActions, "the acceptance receipt is newer than the published tournament window") + require.Empty(t, writeCoverage) + consensus.AssertNotCalled(t, "ParseEpochSealed", mock.Anything) + + app.LastTournamentCheckBlock = 100 + tournament.Snapshot.AsOfBlock = 100 + deferActions, err = s.reconcileAcceptedEpochs(t.Context(), app, []*model.Epoch{epoch}, consensus, 100) + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, []uint64{100}, writeCoverage) + repo.AssertExpectations(t) + client.AssertExpectations(t) + consensus.AssertExpectations(t) +} diff --git a/internal/prt/result_observation_lag_test.go b/internal/prt/result_observation_lag_test.go new file mode 100644 index 000000000..33d50cb9c --- /dev/null +++ b/internal/prt/result_observation_lag_test.go @@ -0,0 +1,60 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestResultPublicationMakesProgressWithContinuousObserverLag(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + tournament := resultBoundaryTournament(f, epoch, *epoch.Commitment) + var stageWriteCoverage []uint64 + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(100)). + Run(func(mock.Arguments) { + require.Equal(t, uint64(100), tournament.Snapshot.AsOfBlock, "publish the result window before its staged marker") + stageWriteCoverage = append(stageWriteCoverage, f.app.LastTournamentCheckBlock) + }).Return(nil).Once() + + for _, published := range []uint64{99, 100, 101} { + configured := published + 1 + f.app.LastEpochCheckBlock = published + f.expectResultBoundaryWindow(epoch, tournament, published) + staged := published >= 100 + snapshot := resultTestSnapshot(epoch, staged) + if staged { + snapshot.sealed.StagingBlockNumber = 100 + } else { + snapshot.stage.IsFinished = false + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + } + // Bind every result read to the block that was actually published. A + // read at the moving configured head cannot establish this checkpoint. + f.expectResultBoundarySnapshot(epoch, snapshot, published) + require.NoError(t, f.s.validateApplication(t.Context(), f.app, configured)) + require.Equal(t, published, f.app.LastTournamentCheckBlock) + require.Equal(t, published, tournament.Snapshot.AsOfBlock) + require.Less(t, f.app.LastTournamentCheckBlock, configured, "the observer never catches the configured head") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + if staged { + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(100)), epoch.StagedAtBlock) + require.Equal(t, []uint64{100}, stageWriteCoverage, "record once at the next tick, then retain the marker") + } else { + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Nil(t, epoch.StagedAtBlock) + require.Empty(t, stageWriteCoverage, "block 99 must not publish the stage from block 100") + } + } + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.Empty(t, f.s.pendingTransactions, "reader mode must not send transactions") +} diff --git a/internal/prt/result_projection_test.go b/internal/prt/result_projection_test.go new file mode 100644 index 000000000..5d5ca6890 --- /dev/null +++ b/internal/prt/result_projection_test.go @@ -0,0 +1,97 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestFailedRootDoesNotBlockOlderOwnedBondRecovery(t *testing.T) { + f := newPRTBlockPolicyFixture(t, true) + snapshot := resultTestSnapshot(f.epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + f.expectTick(100, 100, snapshot, snapshot) + olderTournament := common.HexToAddress("0x900") + f.s.queueRootBondRecovery(f.app.ID, f.epoch.Index-1, olderTournament) + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(snapshot.sealed, nil).Once() + tournament := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", olderTournament).Return(tournament, nil).Once() + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, f.s.txOptsFactory.From(), 1), nil).Once() + tx := types.NewTx(&types.LegacyTx{Nonce: 1}) + tournament.On("TryRecoveringBond", mock.Anything).Return(tx, nil).Once() + + require.NoError(t, f.tick(100)) + require.Equal(t, tx.Hash(), *f.s.rootBondRecoveries[f.app.ID][0].TxHash) + f.assertNoPermanentStatusWrite(t) + f.consensus.AssertNotCalled(t, "StageTournamentResult", mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "AcceptStagedTournamentResult", mock.Anything, mock.Anything) + tournament.AssertExpectations(t) +} + +func TestResultSnapshotAllowsZeroFinalState(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.MachineHash = new(common.Hash{}) + snapshot := resultTestSnapshot(epoch, false) + require.NoError(t, validateDaveConsensusSnapshot(snapshot, 20)) + require.NoError(t, matchConsensusSnapshotToEpoch(epoch, snapshot)) +} + +func TestFailedRootRepeatedObservationAndForeclosure(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + service, repo := newPRTServiceMock() + for range 2 { + require.NoError(t, service.recordTournamentResult(t.Context(), app, epoch, snapshot)) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + + app.ForecloseBlock = 100 + app.LastEpochCheckBlock = 100 + app.LastInputCheckBlock = 100 + app.LastTournamentCheckBlock = 100 + service.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + service.client = client + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(&daveConsensusAdapterMock{}, nil).Once() + service.adapterFactory = factory + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(true, nil).Once() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, + repository.Pagination{}, false).Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return( + &model.Tournament{Address: *epoch.TournamentAddress, Snapshot: model.TournamentSnapshot{ + AsOfBlock: 100, FinishedAtBlock: 90, Standing: model.TournamentStandingRootFailed, + }}, nil).Once() + repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, epoch.Index).Return(nil).Once() + + require.NoError(t, service.handleForeclosedApp(t.Context(), app, func() (uint64, error) { + return service.getDefaultBlockNumber(t.Context()) + })) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + client.AssertExpectations(t) + factory.AssertExpectations(t) +} diff --git a/internal/prt/reverts_test.go b/internal/prt/reverts_test.go index e5f49b3f1..960d0f4a2 100644 --- a/internal/prt/reverts_test.go +++ b/internal/prt/reverts_test.go @@ -15,12 +15,20 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +const ( + executionRevertedError = "execution reverted" + applicationForeclosed = "ApplicationForeclosed" +) + // daveConsensusRevertError creates a typed IDaveConsensus revert carrying only // the 4-byte selector — sufficient for the classifiers to match by name. func daveConsensusRevertError(name string) error { @@ -32,7 +40,7 @@ func daveConsensusRevertError(name string) error { if !ok { panic(fmt.Sprintf("unknown IDaveConsensus error: %s", name)) } - return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", abiErr.ID[:4])} + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", abiErr.ID[:4])} } // daveRevertWithArgs creates a typed IDaveConsensus revert carrying the given @@ -51,7 +59,7 @@ func daveRevertWithArgs(name string, args ...any) error { panic(err) } payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) - return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", payload)} + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", payload)} } // tournamentRevertError creates a typed ITournament revert carrying only the @@ -65,7 +73,7 @@ func tournamentRevertError(name string) error { if !ok { panic(fmt.Sprintf("unknown ITournament error: %s", name)) } - return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", abiErr.ID[:4])} + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", abiErr.ID[:4])} } func prtRevertTestApp() *model.Application { @@ -73,6 +81,7 @@ func prtRevertTestApp() *model.Application { ID: 7, Name: "prt-app", IApplicationAddress: common.BigToAddress(common.Big1), + IConsensusAddress: common.HexToAddress("0x100"), ConsensusType: model.Consensus_PRT, Status: model.ApplicationStatus_OK, Enabled: true, @@ -82,10 +91,12 @@ func prtRevertTestApp() *model.Application { func prtRevertTestEpoch() *model.Epoch { tournament := common.BigToAddress(common.Big2) commitment := common.HexToHash("0xabcd") + machineHash := common.HexToHash("0x1234") return &model.Epoch{ Index: 3, TournamentAddress: &tournament, Commitment: &commitment, + MachineHash: &machineHash, } } @@ -104,142 +115,161 @@ func reasonContains(substrings ...string) func(*string) bool { } } -// TestHandleSettleRevert covers the Settle revert classification: already -// settled and transient conditions retry silently, bad local proofs mark the -// app CORRUPTED, a consensus/application binding mismatch marks it FAILED, -// and unknown errors propagate unchanged. -func TestHandleSettleRevert(t *testing.T) { - const epochNumber = uint64(3) +func TestHandleStageTournamentResultRevert(t *testing.T) { + epoch := prtRevertTestEpoch() - t.Run("IncorrectEpochNumber_undecodable_waitsForEventSync", func(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - daveConsensusRevertError("IncorrectEpochNumber")) - assert.NoError(t, err) - }) + for _, name := range []string{ + "TournamentResultAlreadyStaged", + "TournamentNotFinishedYet", + applicationForeclosed, + } { + t.Run(name+"_waitsForSynchronization", func(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + err := s.handleStageTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, daveConsensusRevertError(name)) + assert.NoError(t, err) + }) + } + + for _, name := range []string{ + "InvalidSiblingsArrayLength", + "InvalidMachineMerkleProof", + "InvalidPostEpochMachineIflagsYRegister", + "InvalidPostEpochMachineHtifTohostRegister", + } { + t.Run(name+"_setsFailed", func(t *testing.T) { + s, r := newPRTServiceMock() + app := prtRevertTestApp() + testEpoch := epoch + if name == "InvalidMachineMerkleProof" { + testEpoch = resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(testEpoch, false) + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + consensus := &daveConsensusAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(20)) + consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + s.client = client + s.defaultBlock = model.DefaultBlock_Finalized + s.adapterFactory = factory + defer client.AssertExpectations(t) + defer consensus.AssertExpectations(t) + defer factory.AssertExpectations(t) + } + guidance := "Check proof serialization" + if name == "InvalidPostEpochMachineIflagsYRegister" || name == "InvalidPostEpochMachineHtifTohostRegister" { + guidance = "The proven post-epoch machine state cannot finalize" + } + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(reasonContains("StageTournamentResult", name, "epoch 3", guidance))). + Return(nil).Once() + assert.NoError(t, s.handleStageTournamentResultRevert( + context.Background(), app, testEpoch, daveConsensusRevertError(name))) + r.AssertExpectations(t) + }) + } - t.Run("IncorrectEpochNumber_behind_waitsForEventSync", func(t *testing.T) { + t.Run("TournamentFailedNoWinner_waitsForConfirmation", func(t *testing.T) { s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - // received 3 < actual 5: the chain settled past us — already settled. - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(3), big.NewInt(5))) - assert.NoError(t, err) + app := prtRevertTestApp() + assert.NoError(t, s.handleStageTournamentResultRevert( + context.Background(), app, epoch, tournamentRevertError("TournamentFailedNoWinner"))) + assert.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) }) - t.Run("IncorrectEpochNumber_ahead_setsFailed", func(t *testing.T) { + t.Run("IncorrectEpochNumber_behind_waitsForSynchronization", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) - app := prtRevertTestApp() - // received 7 > actual 5: local epoch index is ahead of the chain — - // waiting for event sync would stall silently forever. - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, - mock.MatchedBy(reasonContains( - "IncorrectEpochNumber", "epoch 7", "epoch 5", "ahead", "before re-enabling"))). - Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(7), big.NewInt(5))) - assert.NoError(t, err) + assert.NoError(t, s.handleStageTournamentResultRevert(context.Background(), prtRevertTestApp(), epoch, + daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(3), big.NewInt(5)))) }) - t.Run("TournamentNotFinishedYet_retries", func(t *testing.T) { + t.Run("IncorrectEpochNumber_ahead_retriesWithoutStatusChange", func(t *testing.T) { s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - daveConsensusRevertError("TournamentNotFinishedYet")) - assert.NoError(t, err, "a CanSettle/simulation race must retry, not surface an error") + app := prtRevertTestApp() + boom := daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(7), big.NewInt(5)) + assert.ErrorIs(t, s.handleStageTournamentResultRevert(context.Background(), app, epoch, boom), boom) + assert.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) }) - for _, revertName := range []string{ - "InvalidOutputsMerkleRootProofSize", - "InvalidOutputsMerkleRootProof", - } { - t.Run(revertName+"_setsCorrupted", func(t *testing.T) { + for _, name := range []string{"ApplicationReverted", "IllformedApplicationReturnData"} { + t.Run(name+"_setsFailedWithReturnData", func(t *testing.T) { s, r := newPRTServiceMock() - defer r.AssertExpectations(t) app := prtRevertTestApp() - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, - mock.MatchedBy(reasonContains("Settle reverted with "+revertName, "epoch 3"))). + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(reasonContains("StageTournamentResult", name, "0xdead"))). Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveConsensusRevertError(revertName)) - assert.Error(t, err, "CORRUPTED is terminal; the handler must return the reason error") + assert.NoError(t, s.handleStageTournamentResultRevert(context.Background(), app, epoch, + daveRevertWithArgs(name, common.BigToAddress(common.Big1), []byte{0xde, 0xad}))) + assert.Equal(t, model.ApplicationStatus_Failed, app.Status) + r.AssertExpectations(t) }) } - t.Run("ApplicationForeclosed_retries", func(t *testing.T) { + t.Run("NonceTooLow_waitsForSynchronization", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - daveConsensusRevertError("ApplicationForeclosed")) - assert.NoError(t, err, "must retry while the EVM reader records the foreclosure marker") + assert.NoError(t, s.handleStageTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, errors.New("nonce too low"))) }) - t.Run("ApplicationNotDeployed_setsFailed", func(t *testing.T) { + t.Run("unknown_propagates", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) - app := prtRevertTestApp() - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, - mock.MatchedBy(reasonContains( - "Settle reverted with ApplicationNotDeployed", "epoch 3", "before re-enabling"))). - Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveConsensusRevertError("ApplicationNotDeployed")) - // SetFailedf returns nil on success; the FAILED write itself is - // asserted by the mock expectation above. - assert.NoError(t, err) + boom := errors.New("boom") + assert.ErrorIs(t, s.handleStageTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, boom), boom) }) +} - t.Run("ApplicationReverted_setsFailedWithReturnData", func(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - app := prtRevertTestApp() - r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, - mock.MatchedBy(reasonContains( - "Settle reverted with ApplicationReverted", "epoch 3", - "Application return data: 0xdead", "before re-enabling"))). - Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveRevertWithArgs("ApplicationReverted", - common.BigToAddress(common.Big1), []byte{0xde, 0xad})) - assert.NoError(t, err) - }) +func TestHandleAcceptTournamentResultRevert(t *testing.T) { + epoch := prtRevertTestEpoch() + for _, name := range []string{ + "TournamentResultNotStaged", + "ClaimStagingPeriodNotOverYet", + applicationForeclosed, + } { + t.Run(name+"_waitsForSynchronization", func(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + assert.NoError(t, s.handleAcceptTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, daveConsensusRevertError(name))) + }) + } - t.Run("IllformedApplicationReturnData_setsFailed", func(t *testing.T) { + t.Run("ApplicationNotDeployed_setsFailed", func(t *testing.T) { s, r := newPRTServiceMock() - defer r.AssertExpectations(t) app := prtRevertTestApp() r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, - mock.MatchedBy(reasonContains( - "Settle reverted with IllformedApplicationReturnData", "epoch 3", - "before re-enabling"))). + mock.MatchedBy(reasonContains("AcceptStagedTournamentResult", "ApplicationNotDeployed"))). Return(nil).Once() - err := s.handleSettleRevert(context.Background(), app, epochNumber, - daveConsensusRevertError("IllformedApplicationReturnData")) - assert.NoError(t, err) - }) - - t.Run("NonceTooLow_retries", func(t *testing.T) { - s, r := newPRTServiceMock() - defer r.AssertExpectations(t) - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, - errors.New("nonce too low")) - assert.NoError(t, err) + assert.NoError(t, s.handleAcceptTournamentResultRevert( + context.Background(), app, epoch, daveConsensusRevertError("ApplicationNotDeployed"))) + r.AssertExpectations(t) }) t.Run("unknown_propagates", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) - boom := errors.New("boom") - err := s.handleSettleRevert(context.Background(), prtRevertTestApp(), epochNumber, boom) - assert.Equal(t, boom, err) + boom := errors.New("factory failed") + assert.ErrorIs(t, s.handleAcceptTournamentResultRevert( + context.Background(), prtRevertTestApp(), epoch, boom), boom) }) } // TestHandleJoinTournamentRevert covers the JoinTournament revert // classification: an already-joined commitment retries silently (whether -// detected via ClockAlreadyInitialized or via the IsCommitmentJoined re-check +// detected via ClockAlreadyInitialized or via the CommitmentStanding re-check // behind a window revert), a genuinely missed join window marks the app // FAILED, bad local commitment proofs mark it CORRUPTED, and unknown errors // propagate. @@ -253,7 +283,7 @@ func TestHandleJoinTournamentRevert(t *testing.T) { }) for _, revertName := range []string{"TournamentIsClosed", "TournamentIsFinished"} { - t.Run(revertName+"_notJoined_setsFailed", func(t *testing.T) { + t.Run(revertName+"_confirmedNotJoined_setsFailed", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) app := prtRevertTestApp() @@ -266,11 +296,51 @@ func TestHandleJoinTournamentRevert(t *testing.T) { "before re-enabling"))). Return(nil).Once() adapter := &tournamentAdapterMock{} - adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(false, nil).Once() + adapter.On("CommitmentStanding", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts != nil && opts.BlockNumber == nil + }), [32]byte(*epoch.Commitment)). + Return(CommitmentStanding{}, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(20)) + adapter.On("Standing", opts). + Return(TournamentStanding{State: model.TournamentStandingRootFailed, FinishedAt: 19}, nil).Once() + adapter.On("CommitmentStanding", opts, [32]byte(*epoch.Commitment)). + Return(CommitmentStanding{}, nil).Once() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + s.client = client + s.defaultBlock = model.DefaultBlock_Finalized err := s.handleJoinTournamentRevert(context.Background(), app, epoch, adapter, tournamentRevertError(revertName)) assert.NoError(t, err) + assert.Equal(t, model.ApplicationStatus_Failed, app.Status) + adapter.AssertExpectations(t) + client.AssertExpectations(t) + }) + + t.Run(revertName+"_configuredWindowOpen_waitsForConfirmation", func(t *testing.T) { + s, r := newPRTServiceMock() + app := prtRevertTestApp() + epoch := prtRevertTestEpoch() + adapter := &tournamentAdapterMock{} + adapter.On("CommitmentStanding", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts != nil && opts.BlockNumber == nil + }), [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, nil).Once() + adapter.On("Standing", mock.MatchedBy(resultCallOptsAtBlock(20))). + Return(TournamentStanding{State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true}, nil).Once() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(20)}, nil).Once() + s.client = client + s.defaultBlock = model.DefaultBlock_Finalized + + assert.NoError(t, s.handleJoinTournamentRevert(context.Background(), app, epoch, + adapter, tournamentRevertError(revertName))) + assert.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) adapter.AssertExpectations(t) + client.AssertExpectations(t) }) } @@ -283,7 +353,8 @@ func TestHandleJoinTournamentRevert(t *testing.T) { defer r.AssertExpectations(t) epoch := prtRevertTestEpoch() adapter := &tournamentAdapterMock{} - adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(true, nil).Once() + adapter.On("CommitmentStanding", mock.Anything, [32]byte(*epoch.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: *epoch.MachineHash}, nil).Once() err := s.handleJoinTournamentRevert(context.Background(), prtRevertTestApp(), epoch, adapter, tournamentRevertError("TournamentIsClosed")) assert.NoError(t, err, "an already-joined commitment must not mark the app FAILED") @@ -295,7 +366,8 @@ func TestHandleJoinTournamentRevert(t *testing.T) { defer r.AssertExpectations(t) epoch := prtRevertTestEpoch() adapter := &tournamentAdapterMock{} - adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(false, errors.New("rpc down")).Once() + adapter.On("CommitmentStanding", mock.Anything, [32]byte(*epoch.Commitment)). + Return(CommitmentStanding{}, errors.New("rpc down")).Once() boom := tournamentRevertError("TournamentIsClosed") err := s.handleJoinTournamentRevert(context.Background(), prtRevertTestApp(), epoch, adapter, boom) diff --git a/internal/prt/service.go b/internal/prt/service.go index f6bd1502c..1ddb0f3cb 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -9,50 +9,63 @@ import ( "fmt" "log/slog" "math/big" + "sync" "time" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/internal/errutil" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/cartesi/rollups-node/pkg/service" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" ) type CreateInfo struct { - Config config.PrtConfig - Logger *slog.Logger - Repository repository.Repository + Config config.PrtConfig + Logger *slog.Logger + Repository repository.Repository + EthClient EthClientInterface + AdapterFactory AdapterFactory } type Service struct { service.TickServiceTemplate - repository prtRepository - client EthClientInterface - adapterFactory AdapterFactory - submissionEnabled bool - submissionTimeout time.Duration - filter ethutil.Filter - txOptsFactory ethutil.TransactOptsFactory - currentEpochIndex map[int64]uint64 // application.ID -> epochIndex - settleInFlight map[int64]*common.Hash // application.ID -> txHash - joinInFlight map[int64]*common.Hash // application.ID -> txHash + repository prtRepository + client EthClientInterface + adapterFactory AdapterFactory + submissionEnabled bool + defaultBlock DefaultBlock + submissionTimeout time.Duration + filter ethutil.Filter + txOptsFactory ethutil.TransactOptsFactory // Set by Create whenever submission is enabled. + pendingTransactions map[int64]pendingTournamentTransaction // application.ID -> pending action + disputeWarnings map[common.Address]struct{} + zeroStagingWarnings map[int64]struct{} + rootBondRecoveries map[int64][]*rootBondRecovery + observationHealthMu sync.RWMutex // Ready runs concurrently with the observation loop. + observationFailures map[int64]tournamentObservationFailure + + discoveredForeclosedRootBonds map[int64]common.Address // Current root only; cleared on restart with the recovery queue. } const PrtConfigKey = "prt" -type PersistentConfig struct { - DefaultBlock DefaultBlock - ClaimSubmissionEnabled bool - ChainID uint64 -} +type PersistentConfig = config.PersistentSubmitterConfig func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { var err error + if c == nil { + return nil, errors.New("invalid CreateInfo is nil") + } if err = ctx.Err(); err != nil { return nil, err // This returns context.Canceled or context.DeadlineExceeded. } + if c.Repository == nil { + return nil, fmt.Errorf("repository on prt service Create is nil") + } s := &Service{} tickCfg := &service.TickServiceConfigs{ @@ -69,65 +82,75 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro return nil, err } - authOpt, err := config.HTTPAuthorizationOption() - if err != nil { - return nil, err - } - - ethClient, err := ethutil.NewEthClient(ctx, c.Config.BlockchainHttpEndpoint.Raw(), s.Logger, - ethutil.RetryConfig{ - MaxRetries: c.Config.BlockchainHttpMaxRetries, - RetryMinWait: c.Config.BlockchainHttpRetryMinWait, - RetryMaxWait: c.Config.BlockchainHttpRetryMaxWait, - RequestTimeout: c.Config.BlockchainHttpRequestTimeout, - }, authOpt) - if err != nil { - return nil, err + ethClient := c.EthClient + if ethClient == nil { + authOpt, err := config.HTTPAuthorizationOption() + if err != nil { + return nil, err + } + ethClient, err = ethutil.NewEthClient(ctx, c.Config.BlockchainHttpEndpoint.Raw(), s.Logger, + ethutil.RetryConfig{ + MaxRetries: c.Config.BlockchainHttpMaxRetries, + RetryMinWait: c.Config.BlockchainHttpRetryMinWait, + RetryMaxWait: c.Config.BlockchainHttpRetryMaxWait, + RequestTimeout: c.Config.BlockchainHttpRequestTimeout, + }, authOpt) + if err != nil { + return nil, err + } } chainID, err := ethClient.ChainID(ctx) if err != nil { return nil, err } - if chainID.Uint64() != c.Config.BlockchainId { - return nil, fmt.Errorf("EthClient chainId mismatch: network %d != provided %d", - chainID.Uint64(), c.Config.BlockchainId) + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err } s.repository = c.Repository - if s.repository == nil { - return nil, fmt.Errorf("repository on prt service Create is nil") - } nodeConfig, err := s.setupPersistentConfig(ctx, &c.Config) if err != nil { return nil, err } - if chainID.Uint64() != nodeConfig.ChainID { - return nil, fmt.Errorf("NodeConfig chainId mismatch: network %d != config %d", - chainID.Uint64(), nodeConfig.ChainID) - } s.client = ethClient s.submissionEnabled = nodeConfig.ClaimSubmissionEnabled + s.defaultBlock = nodeConfig.DefaultBlock s.filter = ethutil.Filter{ MinChunkSize: ethutil.DefaultMinChunkSize, MaxChunkSize: new(big.Int).SetUint64(c.Config.BlockchainMaxBlockRange), Logger: s.Logger, } - s.adapterFactory = NewDefaultAdapterFactory(ethClient, s.filter) - s.currentEpochIndex = map[int64]uint64{} - s.settleInFlight = map[int64]*common.Hash{} - s.joinInFlight = map[int64]*common.Hash{} + if c.AdapterFactory != nil { + s.adapterFactory = c.AdapterFactory + } else { + concreteClient, ok := ethClient.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("EthClient must be *ethclient.Client when AdapterFactory is not provided") + } + s.adapterFactory = NewDefaultAdapterFactory(concreteClient, s.filter) + } + + s.pendingTransactions = map[int64]pendingTournamentTransaction{} + s.disputeWarnings = map[common.Address]struct{}{} + s.zeroStagingWarnings = map[int64]struct{}{} + s.rootBondRecoveries = map[int64][]*rootBondRecovery{} + s.observationFailures = map[int64]tournamentObservationFailure{} if s.submissionEnabled { s.submissionTimeout = c.Config.BlockchainHttpRequestTimeout if s.submissionTimeout == 0 { return nil, fmt.Errorf("BlockchainHttpRequestTimeout must be different from zero") } - s.txOptsFactory, err = auth.GetTransactOptsFactory(ctx, chainID) + s.txOptsFactory, err = auth.GetPrtTransactOptsFactory(ctx, chainID) if err != nil { - return nil, err + return nil, fmt.Errorf("PRT submission is enabled and requires signer configuration "+ + "(CARTESI_PRT_AUTH_*); the standalone node always starts PRT: %w", err) + } + if c.Config.BlockchainLegacyEnabled { + s.txOptsFactory = ethutil.WithLegacyFees(s.txOptsFactory, ethClient) } s.Logger.Info("PRT submitter identity", "address", s.txOptsFactory.From()) } @@ -141,98 +164,152 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro // reported as an operational failure. DeadlineExceeded and cancellations while // the service is running remain errors. func (s *Service) logErrorUnlessShutdown(ctx context.Context, message string, err error, args ...any) { - if errors.Is(ctx.Err(), context.Canceled) && errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { return } args = append(args, "error", err) s.Logger.Error(message, args...) } -// Tick executes the Validator main logic of producing claims and/or proofs -// for processed epochs of all running applications. +// Tick observes PRT tournaments and maintains their pending actions. func (s *Service) Tick(ctx context.Context) (bool, error) { // Check for shutdown before starting work, consistent with the advancer. - if ctx.Err() != nil { - return false, nil + if err := ctx.Err(); err != nil { + if errors.Is(err, context.Canceled) { + return false, nil + } + return false, err } - apps, _, err := getAllRunningApplications(ctx, s.repository) + apps, _, err := getObservableApplications(ctx, s.repository) if err != nil { // Only suppress context errors during shutdown; surface real DB errors. - if errors.Is(err, context.Canceled) { - s.Logger.Warn("Tick interrupted by shutdown", "error", err) + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "error", err) return false, nil } - return false, fmt.Errorf("failed to get running applications. %w", err) + return false, fmt.Errorf("failed to get observable applications: %w", err) } + s.pruneTournamentObservationFailures(apps) + // Resolve observation policy once, but only when a path needs it. In + // particular, queued foreclosed bond recovery does not require finality. + observationBlock := sync.OnceValues(func() (uint64, error) { + return s.getDefaultBlockNumber(ctx) + }) // validate each application errs := []error{} for idx := range apps { - if ctx.Err() != nil { + if err := ctx.Err(); err != nil { + if !errors.Is(err, context.Canceled) { + errs = append(errs, err) + } return false, errors.Join(errs...) } app := apps[idx] - // Foreclosed apps: run the drain path (reconcile accepted epochs, - // foreclose the rest) instead of normal tournament work. EVM reader is - // the sole writer of ForecloseBlock; the app keeps health status OK and - // remains enabled for L1 observation. + // Foreclosed apps still observe tournaments. Only healthy apps also + // drain local claims and recover their root tournament bonds. + // EVM reader is the sole writer of ForecloseBlock. if app.ForecloseBlock != 0 { - if ferr := s.handleForeclosedApp(ctx, app); ferr != nil { - if errors.Is(ferr, context.Canceled) { + if ferr := s.handleForeclosedApp(ctx, app, observationBlock); ferr != nil { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(ferr) { continue } - errs = append(errs, ferr) + errs = append(errs, fmt.Errorf("draining foreclosed PRT application %s: %w", app.IApplicationAddress, ferr)) } continue } - if err := s.validateApplication(ctx, app); err != nil { + confirmedBlock, err := observationBlock() + if err != nil { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + return false, errors.Join(errs...) + } + errs = append(errs, fmt.Errorf("fetching configured block for application %s: %w", app.IApplicationAddress, err)) + continue + } + if err := s.validateApplication(ctx, app, confirmedBlock); err != nil { // During shutdown, in-flight L1 requests see context cancellation. // Suppress these to avoid spurious ERR log entries. - if errors.Is(err, context.Canceled) { - s.Logger.Warn("Tick interrupted by shutdown", + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "application", app.IApplicationAddress, "error", err) continue } - errs = append(errs, err) + errs = append(errs, fmt.Errorf("validating PRT application %s: %w", app.IApplicationAddress, err)) } } return false, errors.Join(errs...) } -// handleForeclosedApp drains a foreclosed DaveConsensus application's epochs to -// a terminal state. Foreclosure is a lifecycle fact (foreclose_block); the -// application keeps health status OK and stays enabled for L1 observation. +// handleForeclosedApp observes a foreclosed application's tournaments and, +// while local processing is healthy, drains its epochs to a terminal state. // // Once the app has ingested its pre-foreclosure sealed epochs and the advancer // has processed their inputs, each pre-foreclosure epoch is reconciled read-only -// against the chain: an epoch whose root tournament settled with our commitment +// against the chain: an epoch whose accepted root result has our commitment // becomes CLAIM_ACCEPTED, and a mismatch marks the app DIVERGED (reproducing the // on-chain divergence). Every remaining epoch can no longer be accepted once the -// app is foreclosed, so it is terminalized to CLAIM_FORECLOSED. No Settle/Join -// transactions are sent. A freshly bootstrapped node therefore reaches the same -// epoch states a node that ran in real time would have. -func (s *Service) handleForeclosedApp(ctx context.Context, app *Application) error { +// app is foreclosed, so it is terminalized to CLAIM_FORECLOSED. Join, stage, +// and accept transactions are not sent. Root bond recovery is independent of +// consensus foreclosure and local claim reconciliation. A freshly bootstrapped +// node therefore reaches the same epoch states as a node that ran in real time. +func (s *Service) handleForeclosedApp(ctx context.Context, app *Application, observationBlock func() (uint64, error)) error { if app.ForecloseBlock == 0 { return nil } + if err := ctx.Err(); err != nil { + return err + } + mostRecentBlock, observationErr := observationBlock() + var epochs []*Epoch + var consensus DaveConsensusAdapter + if observationErr == nil { + epochs, consensus, observationErr = s.observeApplicationTournaments(ctx, app, mostRecentBlock) + } + if app.Status != ApplicationStatus_OK { + return observationErr + } + if err := ctx.Err(); err != nil { + return errors.Join(observationErr, err) + } + var discoveryErr error + if observationErr == nil { + discoveryErr = s.discoverForeclosedRootBond(ctx, app, epochs, consensus, mostRecentBlock) + } + if err := ctx.Err(); err != nil { + return errors.Join(observationErr, discoveryErr, err) + } + // Queued payment maintenance does not depend on a finalized-head read. + // A transaction wait or recovery failure must not prevent the database-only + // claim drain. Join the errors after both independent operations complete. + recoveryErr := s.recoverForeclosedRootBonds(ctx, app) + if observationErr != nil || ctx.Err() != nil { + return errors.Join(observationErr, discoveryErr, recoveryErr, ctx.Err()) + } + return errors.Join(discoveryErr, recoveryErr, + s.drainForeclosedClaims(ctx, app, epochs, consensus, mostRecentBlock)) +} + +func (s *Service) drainForeclosedClaims( + ctx context.Context, app *Application, epochs []*Epoch, consensus DaveConsensusAdapter, mostRecentBlock uint64, +) error { // Bootstrap-readiness guard. The drain gate below answers "given the // rows currently in the local input table, is there any pre-foreclosure // input still status=NONE?". For a freshly registered PRT app against // an already-foreclosed contract, evmreader's checkForForeclosure writes // foreclose_block before checkForEpochsAndInputs has had a chance to // ingest the historical sealed epochs (and their inputs) — so the gate - // would see an empty table and return false. PRT's input ingestion is - // driven by EpochSealed scans, so the relevant scanner cursor is - // last_epoch_check_block (not last_input_check_block, which the Dave - // path never writes) — ForeclosureScanCaughtUp branches on consensus - // type to consult it. + // would see an empty table and return false. Dave writes both scanner + // cursors. ForeclosureScanCaughtUp requires last_epoch_check_block AND + // last_input_check_block to reach foreclose_block: sealed-epoch coverage + // alone does not prove that open-epoch inputs have been ingested. if !app.ForeclosureScanCaughtUp() { s.Logger.Info( - "Foreclosed PRT application still ingesting pre-foreclosure sealed epochs", + "Foreclosed PRT application still ingesting pre-foreclosure sealed epochs and inputs", "application", app.Name, "address", app.IApplicationAddress, "last_epoch_check_block", app.LastEpochCheckBlock, + "last_input_check_block", app.LastInputCheckBlock, "foreclose_block", app.ForecloseBlock, ) return nil @@ -263,35 +340,59 @@ func (s *Service) handleForeclosedApp(ctx context.Context, app *Application) err return nil } - // Read-only reconciliation: accept epochs whose root tournament settled with + // Read-only reconciliation: accept epochs whose root result has // our commitment, and surface any divergence. This sends no transactions. - mostRecentBlock, err := s.client.BlockNumber(ctx) + deferActions, err := s.reconcileAcceptedEpochs(ctx, app, epochs, consensus, mostRecentBlock) if err != nil { - return fmt.Errorf("fetching latest block for foreclosed app %s: %w", - app.IApplicationAddress, err) - } - if err := s.checkEpochs(ctx, app, mostRecentBlock); err != nil { // A divergence detected here marks the app DIVERGED and returns the // reason; propagate it like the normal validation path does. return err } + if deferActions { + return nil + } - // Claim-computed epochs without an on-chain claim transaction can never be - // accepted now that the app is foreclosed: terminalize them to CLAIM_FORECLOSED. - return s.forecloseComputedEpochs(ctx, app) + // Pending claim epochs without an acceptance transaction can never be + // accepted now that the app is foreclosed. Make them CLAIM_FORECLOSED. + return s.foreclosePendingClaimEpochs(ctx, app) } -// forecloseComputedEpochs transitions every unaccepted CLAIM_COMPUTED epoch of a -// foreclosed application to CLAIM_FORECLOSED. Epochs that already have a -// ClaimTransactionHash have an on-chain EpochSealed event to reconcile; leave -// them CLAIM_COMPUTED so the next checkEpochs pass can accept or reject them. -func (s *Service) forecloseComputedEpochs(ctx context.Context, app *Application) error { - epochs, _, err := getAllClaimComputedEpochs(ctx, s.repository, app.Name) +func (s *Service) recoverForeclosedRootBonds(ctx context.Context, app *Application) error { + if !s.submissionEnabled || len(s.rootBondRecoveries[app.ID]) == 0 { + return nil + } + mostRecentBlock, err := s.client.BlockNumber(ctx) if err != nil { - return fmt.Errorf("listing computed epochs for foreclosed app %s: %w", + return fmt.Errorf("fetching latest block for foreclosed app root bond recovery %s: %w", + app.IApplicationAddress, err) + } + blocked, err := s.waitForTournamentTransaction(ctx, app, mostRecentBlock) + if err != nil { + return fmt.Errorf("checking tournament transaction for foreclosed app %s: %w", app.IApplicationAddress, err) + } + if blocked { + return nil + } + if err := s.recoverRootBonds(ctx, app, mostRecentBlock); err != nil { + return fmt.Errorf("recovering queued root bonds for foreclosed app %s: %w", app.IApplicationAddress, err) + } + return nil +} + +// foreclosePendingClaimEpochs terminalizes remaining pre-foreclosure epochs. +// The caller must first confirm ingestion and input processing are complete. +// Epochs with an acceptance transaction stay pending for claim reconciliation. +func (s *Service) foreclosePendingClaimEpochs(ctx context.Context, app *Application) error { + filter := repository.EpochFilter{Status: NonTerminalEpochStatuses()} + epochs, _, err := s.repository.ListEpochs(ctx, app.Name, filter, repository.Pagination{}, false) + if err != nil { + return fmt.Errorf("listing pending claim epochs for foreclosed app %s: %w", app.IApplicationAddress, err) } for _, epoch := range epochs { + if epoch.FirstBlock > app.ForecloseBlock { + continue + } if epoch.ClaimTransactionHash != nil { s.Logger.Debug("Skipping foreclose terminalization for epoch with on-chain claim transaction", "application", app.Name, @@ -319,24 +420,27 @@ func (s *Service) setupPersistentConfig( ctx context.Context, c *config.PrtConfig, ) (*PersistentConfig, error) { + requested := PersistentConfig{ + DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId, + ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, + } + if err := requested.Validate(); err != nil { + return nil, fmt.Errorf("invalid prt config: %w", err) + } config, err := repository.LoadNodeConfig[PersistentConfig](ctx, s.repository, PrtConfigKey) if config == nil && errors.Is(err, repository.ErrNotFound) { nc := NodeConfig[PersistentConfig]{ - Key: PrtConfigKey, - Value: PersistentConfig{ - DefaultBlock: c.BlockchainDefaultBlock, - ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, - ChainID: c.BlockchainId, - }, + Key: PrtConfigKey, + Value: requested, } s.Logger.Info("Initializing PRT persistent config", "config", nc.Value) - err = repository.SaveNodeConfig(ctx, s.repository, &nc) - if err != nil { - return nil, err + config, err = repository.InitializeNodeConfig(ctx, s.repository, &nc) + } + if err == nil { + if err := config.Value.CheckRequested(requested); err != nil { + return nil, fmt.Errorf("prt persistent config: %w", err) } - return &nc.Value, nil - } else if err == nil { - s.Logger.Info("PRT service was already configured. Using previous persistent config", "config", config.Value) + s.Logger.Info("PRT persistent config matches requested config", "config", config.Value) return &config.Value, nil } diff --git a/internal/prt/service_config_test.go b/internal/prt/service_config_test.go new file mode 100644 index 000000000..ea15498a3 --- /dev/null +++ b/internal/prt/service_config_test.go @@ -0,0 +1,103 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "encoding/json" + "fmt" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSetupPersistentConfigChecksInitializedValue(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, concurrent := range []bool{false, true} { + t.Run(fmt.Sprintf("enabled=%t/concurrent=%t", enabled, concurrent), func(t *testing.T) { + s, repo := newPRTServiceMock() + requested := PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: enabled, + } + raw, err := json.Marshal(requested) + require.NoError(t, err) + stored := requested + if concurrent { + stored.ClaimSubmissionEnabled = !enabled + } + storedRaw, err := json.Marshal(stored) + require.NoError(t, err) + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey). + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound).Once() + repo.On("InitializeNodeConfigRaw", mock.Anything, PrtConfigKey, raw).Return(nil).Once() + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey). + Return(storedRaw, time.Time{}, time.Time{}, nil).Once() + got, err := s.setupPersistentConfig(t.Context(), &config.PrtConfig{ + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: enabled, + }) + if concurrent { + require.Nil(t, got) + require.ErrorContains(t, err, "claim submission mode mismatch") + } else { + require.NoError(t, err) + require.Equal(t, requested, *got) + } + repo.AssertNumberOfCalls(t, "SaveNodeConfigRaw", 0) + repo.AssertExpectations(t) + }) + } + } +} + +func TestCreateRejectsConfigBeforeSignerResolution(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "invalid") + t.Cleanup(func() { viper.Set(config.PRT_AUTH_KIND, nil) }) + for _, test := range []struct { + name string + raw string + requestedEnabled bool + want string + }{ + {"chain mismatch", `{"ChainID":2,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, true, + "chain ID mismatch: database=2, configured=42"}, + {"policy mismatch", `{"ChainID":42,"DefaultBlock":"LATEST","ClaimSubmissionEnabled":true}`, true, + "observation policy mismatch: database=LATEST, configured=FINALIZED"}, + {"enable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":false}`, true, + "claim submission mode mismatch: database=false, configured=true"}, + {"disable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true}`, false, + "claim submission mode mismatch: database=true, configured=false"}, + {"missing mode", `{"ChainID":42,"DefaultBlock":"FINALIZED"}`, true, "non-null ClaimSubmissionEnabled"}, + {"null mode", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":null}`, true, + "non-null ClaimSubmissionEnabled"}, + {"null config", `null`, true, "non-null DefaultBlock and ChainID"}, + {"invalid policy", `{"ChainID":42,"DefaultBlock":"invalid","ClaimSubmissionEnabled":true}`, true, "invalid DefaultBlock"}, + } { + t.Run(test.name, func(t *testing.T) { + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return([]byte(test.raw), nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + info := &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: test.requestedEnabled, BlockchainHttpRequestTimeout: time.Second, + }, + EthClient: client, Repository: repo, + } + svc, err := Create(t.Context(), info) + require.Nil(t, svc) + require.ErrorContains(t, err, test.want) + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/startup_signer_test.go b/internal/prt/startup_signer_test.go new file mode 100644 index 000000000..9cfd03a3e --- /dev/null +++ b/internal/prt/startup_signer_test.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "encoding/json" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestCreateExplainsRequiredPRTSigner(t *testing.T) { + for _, enabled := range []bool{true, false} { + name := "submission disabled" + if enabled { + name = "submission enabled" + } + t.Run(name, func(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "mnemonic_file") + viper.Set(config.PRT_AUTH_MNEMONIC, "") + viper.Set(config.PRT_AUTH_MNEMONIC_FILE, filepath.Join(t.TempDir(), "missing-mnemonic")) + t.Cleanup(func() { + viper.Set(config.PRT_AUTH_KIND, nil) + viper.Set(config.PRT_AUTH_MNEMONIC, nil) + viper.Set(config.PRT_AUTH_MNEMONIC_FILE, nil) + }) + raw, err := json.Marshal(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: enabled, + }) + require.NoError(t, err) + repo := &prtBlockPolicyCreateRepository{} + repo.On("LoadNodeConfigRaw", mock.Anything, PrtConfigKey).Return(raw, nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: enabled, BlockchainHttpRequestTimeout: time.Second, + }, + Repository: repo, EthClient: client, AdapterFactory: &adapterFactoryMock{}, + }) + if enabled { + require.Nil(t, svc) + require.ErrorIs(t, err, os.ErrNotExist, "preserve the underlying credential error") + require.ErrorContains(t, err, "PRT submission is enabled and requires signer configuration (CARTESI_PRT_AUTH_*)") + require.ErrorContains(t, err, "the standalone node always starts PRT") + } else { + require.NoError(t, err, "reader mode must not require signer credentials") + require.Nil(t, svc.(*Service).txOptsFactory) + } + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/state_proof_test.go b/internal/prt/state_proof_test.go new file mode 100644 index 000000000..22c20d1c6 --- /dev/null +++ b/internal/prt/state_proof_test.go @@ -0,0 +1,39 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestStageTournamentResultEncodesModelProof(t *testing.T) { + proof := repotest.KeccakStateProof(common.HexToHash("0xab")) + contract, err := idaveconsensus.NewIDaveConsensus(common.HexToAddress("0x1234"), nil) + require.NoError(t, err) + adapter := &DaveConsensusAdapterImpl{consensus: contract} + opts := &bind.TransactOpts{ + Context: t.Context(), Nonce: big.NewInt(0), GasPrice: big.NewInt(1), GasLimit: 1_000_000, + NoSend: true, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + tx, err := adapter.StageTournamentResult(opts, 3, proof) + require.NoError(t, err) + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + want, err := contractABI.Pack("stageTournamentResult", big.NewInt(3), idaveconsensus.MachineValidityProof{ + IflagsYProof: idaveconsensus.LeafProof{DataBlock: proof.IflagsYDataBlock, Siblings: proof.IflagsYProof}, + HtifTohostProof: idaveconsensus.LeafProof{DataBlock: proof.HtifTohostDataBlock, Siblings: proof.HtifTohostProof}, + TxBufferProof: idaveconsensus.LeafProof{DataBlock: proof.TxBufferDataBlock, Siblings: proof.TxBufferProof}, + }) + require.NoError(t, err) + require.Equal(t, want, tx.Data()) +} diff --git a/internal/prt/supervisor_readiness_test.go b/internal/prt/supervisor_readiness_test.go new file mode 100644 index 000000000..2bd73a8a2 --- /dev/null +++ b/internal/prt/supervisor_readiness_test.go @@ -0,0 +1,63 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSupervisorReportsPRTObservationReadiness(t *testing.T) { + s, repo := newPRTServiceMock() + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: config.ServicePrt, Logger: s.Logger}, PollInterval: time.Hour, + }, s)) + started := make(chan struct{}) + // Stop the first tick before it changes observer eligibility. This test + // isolates the supervisor's use of the PRT-specific readiness method. + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Run(func(mock.Arguments) { close(started) }). + Return([]*model.Application{}, uint64(0), errors.New("application list unavailable")).Once() + supervisor, err := service.NewSupervisor(t.Context(), &service.SupervisorConfigs{ + BaseConfigs: service.BaseConfigs{Name: "node", Logger: s.Logger}, + Factories: []service.FactoryFunction{ + func(context.Context, service.Supervisor) (service.SupervisedService, error) { return s, nil }, + }, + }) + require.NoError(t, err) + finished := make(chan error, 1) + go func() { finished <- supervisor.Serve() }() + t.Cleanup(func() { + supervisor.Stop() + select { + case err := <-finished: + require.NoError(t, err) + case <-time.After(time.Second): + t.Error("supervisor did not stop") + } + }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("PRT service did not start") + } + require.Empty(t, supervisor.NotReady()) + app := prtRevertTestApp() + for head := uint64(100); head < 100+tournamentObservationFailureThreshold; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, errors.New("observation unavailable")) + } + require.Equal(t, []string{config.ServicePrt}, supervisor.NotReady()) + s.clearTournamentObservationFailure(app.ID) + require.Empty(t, supervisor.NotReady()) + repo.AssertExpectations(t) +} diff --git a/internal/prt/tick_policy_test.go b/internal/prt/tick_policy_test.go new file mode 100644 index 000000000..1cf3221c0 --- /dev/null +++ b/internal/prt/tick_policy_test.go @@ -0,0 +1,313 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestPRTTickSharesObservationHeadAndKeepsActionHeadsFresh(t *testing.T) { + s, repo := newPRTServiceMock() + s.defaultBlock = model.DefaultBlock_Finalized + s.submissionEnabled = true + client := ðClientMock{} + factory := &adapterFactoryMock{} + s.client, s.adapterFactory = client, factory + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + apps := []*model.Application{prtRevertTestApp(), prtRevertTestApp()} + apps[1].ID++ + apps[1].Name = "delayed-app" + apps[1].IApplicationAddress = common.HexToAddress("0x900") + apps[1].IConsensusAddress = common.HexToAddress("0x901") + apps[1].ClaimStagingPeriod = 300 + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return(apps, uint64(2), nil).Twice() + for i, app := range apps { + consensus := &daveConsensusAdapterMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Twice() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), nil).Twice() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + // No tournament window is published while the EVM reader catches up. + // Only the independent latest action snapshot can be read at this point. + repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index). + Return((*model.Epoch)(nil), nil).Twice() + for tick := range 2 { + opts := mock.MatchedBy(resultCallOptsAtBlock(120 + uint64(tick*2+i))) + consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + } + t.Cleanup(func() { consensus.AssertExpectations(t) }) + } + for tick := range 2 { + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(int64(100 + tick))}, nil).Once() + for appIndex := range apps { + client.On("BlockNumber", mock.Anything).Return(uint64(120+tick*2+appIndex), nil).Once() + } + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + } + require.Equal(t, 1, strings.Count(output.String(), "Application has no claim staging delay")) + require.Contains(t, output.String(), "without sentries") + require.NotContains(t, output.String(), "application=delayed-app") + require.Empty(t, s.pendingTransactions) + client.AssertExpectations(t) + factory.AssertExpectations(t) + repo.AssertExpectations(t) +} + +func TestPRTTickWithoutApplicationsDoesNotReadHead(t *testing.T) { + s, repo := newPRTServiceMock() + s.client = ðClientMock{} + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), nil).Once() + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.Empty(t, s.client.(*ethClientMock).Calls) + repo.AssertExpectations(t) +} + +func TestPRTTickSuppressesHeadReadCancellationDuringShutdown(t *testing.T) { + s, repo := newPRTServiceMock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + s.client = client + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{prtRevertTestApp()}, uint64(1), nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Run(func(mock.Arguments) { cancel() }).Return((*types.Header)(nil), context.Canceled).Once() + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + require.NoError(t, err) + client.AssertExpectations(t) + repo.AssertExpectations(t) +} + +func TestPRTTickPreservesDependencyCancellationAndDeadlines(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, branch := range []string{"application list", "configured head", "active application", "foreclosed application"} { + t.Run(branch, func(t *testing.T) { + for _, tc := range []struct { + name string + shutdown bool + cause error + quiet bool + }{ + {name: "shutdown cancellation", shutdown: true, cause: context.Canceled, quiet: true}, + {name: "wrapped shutdown", shutdown: true, cause: fmt.Errorf("read: %w", context.Canceled), quiet: true}, + {name: "joined shutdown", shutdown: true, + cause: errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), quiet: true}, + {name: "dependency canceled while runtime active", cause: context.Canceled}, + {name: "database failure during shutdown", shutdown: true, cause: dbErr}, + {name: "mixed database failure during shutdown", shutdown: true, cause: errors.Join(context.Canceled, dbErr)}, + {name: "nested database failure during shutdown", shutdown: true, + cause: fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr))}, + { + name: "joined deadline during shutdown", + shutdown: true, + cause: errors.Join(context.Canceled, context.DeadlineExceeded), + }, + } { + t.Run(tc.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + s.client = client + app := prtRevertTestApp() + if branch == "foreclosed application" { + app.ForecloseBlock = 100 + } + listCall := repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false).Once() + var failureCall *mock.Call + if branch == "application list" { + failureCall = listCall.Return([]*model.Application(nil), uint64(0), tc.cause) + } else { + listCall.Return([]*model.Application{app}, uint64(1), nil) + headCall := client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Once() + if branch == "configured head" { + failureCall = headCall.Return((*types.Header)(nil), tc.cause) + } else { + headCall.Return(&types.Header{Number: big.NewInt(100)}, nil) + failureCall = repo.On("ListEpochs", mock.Anything, app.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch(nil), uint64(0), tc.cause).Once() + } + } + failureCall.Run(func(mock.Arguments) { + if tc.shutdown { + cancel() + } + }) + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if tc.quiet { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.cause, "preserve every cause") + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + client.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } + }) + } +} + +func TestPRTTickRetainsEarlierApplicationErrorDuringShutdown(t *testing.T) { + for _, foreclosed := range []bool{false, true} { + name := "active application" + if foreclosed { + name = "foreclosed second application" + } + t.Run(name, func(t *testing.T) { + s, repo := newPRTServiceMock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + s.client = client + first, second, unvisited := prtRevertTestApp(), prtRevertTestApp(), prtRevertTestApp() + second.ID++ + second.Name = "shutdown-app" + second.IApplicationAddress = common.HexToAddress("0x900") + unvisited.ID += 2 + unvisited.Name = "unvisited-app" + unvisited.IApplicationAddress = common.HexToAddress("0x901") + if foreclosed { + second.ForecloseBlock = 100 + } + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{first, second, unvisited}, uint64(3), nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + firstError := errors.New("first application epoch read failed") + repo.On("ListEpochs", mock.Anything, first.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch(nil), uint64(0), firstError).Once() + repo.On("ListEpochs", mock.Anything, second.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Run(func(mock.Arguments) { cancel() }). + Return([]*model.Epoch(nil), uint64(0), context.Canceled).Once() + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + require.ErrorIs(t, err, firstError) + require.NotErrorIs(t, err, context.Canceled) + require.Contains(t, err.Error(), first.IApplicationAddress.Hex()) + repo.AssertNotCalled(t, "ListEpochs", mock.Anything, unvisited.Name, mock.Anything, mock.Anything, mock.Anything) + client.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestPRTTickConfiguredHeadFailureDoesNotBlockForeclosedBondRecovery(t *testing.T) { + live := prtRevertTestApp() + foreclosed := prtForeclosedApp(live.ID+1, 100) + foreclosed.LastEpochCheckBlock = 99 // Recovery precedes the incomplete-ingestion gate. + foreclosed.IConsensusAddress = common.HexToAddress("0x800") + owned := common.HexToAddress("0x600") + factory := &adapterFactoryMock{} + s := newRootBondTestService(owned, factory) + s.defaultBlock = model.DefaultBlock_Finalized + s.submissionEnabled = true + repo := s.repository.(*prtRepositoryMock) + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{live, foreclosed}, uint64(2), nil).Once() + tournamentAddress := common.HexToAddress("0x300") + s.queueRootBondRecovery(foreclosed.ID, 3, tournamentAddress) + client := ðClientMock{} + headError := errors.New("finalized head unavailable") + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return((*types.Header)(nil), headError).Once() + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + s.client = client + consensus := &daveConsensusAdapterMock{} + consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(120))). + Return(CurrentSealedEpoch{EpochNumber: 4}, nil).Once() + factory.On("CreateDaveConsensusAdapter", foreclosed.IConsensusAddress).Return(consensus, nil).Once() + tournament := &tournamentAdapterMock{} + tournament.On("BondRecovery", mock.MatchedBy(resultCallOptsAtBlock(120))). + Return(canonicalBondRecovery(model.BondDispositionRecoverable, owned, 1), nil).Once() + recoveryTx := types.NewTx(&types.LegacyTx{}) + tournament.On("TryRecoveringBond", mock.Anything).Return(recoveryTx, nil).Once() + factory.On("CreateTournamentAdapter", tournamentAddress).Return(tournament, nil).Once() + + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.ErrorIs(t, err, headError) + require.Equal(t, 2, strings.Count(err.Error(), headError.Error()), + "each application reports its failed passive observation") + require.Equal(t, recoveryTx.Hash(), *s.rootBondRecoveries[foreclosed.ID][0].TxHash) + client.AssertExpectations(t) + consensus.AssertExpectations(t) + tournament.AssertExpectations(t) + factory.AssertExpectations(t) + repo.AssertExpectations(t) +} + +func TestPRTTickPublishesObservationBeforePendingBondMaintenance(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = true + epoch := checkpointEpoch(3, "0x100") + epoch.Status = model.EpochStatus_ClaimAccepted + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{f.app}, uint64(1), nil).Once() + f.client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: big.NewInt(100)}, nil).Once() + f.client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + published := false + f.repo.On("StoreTournamentEvents", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Address == *epoch.TournamentAddress + }), uint64(100)).Run(func(mock.Arguments) { published = true }).Return(nil).Once() + transaction := types.NewTx(&types.LegacyTx{Nonce: 1}) + hash := transaction.Hash() + f.s.rootBondRecoveries[f.app.ID] = []*rootBondRecovery{{ + EpochIndex: epoch.Index, Tournament: *epoch.TournamentAddress, TxHash: &hash, + }} + f.client.On("TransactionByHash", mock.Anything, hash). + Run(func(mock.Arguments) { + require.True(t, published, "a pending refund must not stop passive state publication") + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }).Return(transaction, true, nil).Once() + + reschedule, err := f.s.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.True(t, published) + require.Equal(t, hash, *f.s.rootBondRecoveries[f.app.ID][0].TxHash) + require.Empty(t, f.s.pendingTransactions, "the observer must not submit tournament actions") +} diff --git a/internal/prt/tournament_views_test.go b/internal/prt/tournament_views_test.go new file mode 100644 index 000000000..9d0a93c86 --- /dev/null +++ b/internal/prt/tournament_views_test.go @@ -0,0 +1,385 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type tournamentViewRPC struct{ mock.Mock } + +func (m *tournamentViewRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +type tournamentViewFixture struct { + t *testing.T + adapter TournamentAdapter + backend *tournamentViewRPC + abi *abi.ABI + address common.Address + opts *bind.CallOpts +} + +func newTournamentViewFixture(t *testing.T) *tournamentViewFixture { + t.Helper() + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + backend := &tournamentViewRPC{} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + address := common.HexToAddress("0x1234") + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) + require.NoError(t, err) + t.Cleanup(func() { backend.AssertExpectations(t) }) + return &tournamentViewFixture{t: t, adapter: adapter, backend: backend, abi: contractABI, + address: address, opts: &bind.CallOpts{Context: t.Context(), BlockNumber: big.NewInt(200)}} +} + +func (f *tournamentViewFixture) expect(name string, inputs []any, outputs ...any) { + f.t.Helper() + encoded, err := f.abi.Methods[name].Outputs.Pack(outputs...) + require.NoError(f.t, err) + f.expectBytes(name, inputs, encoded, nil) +} + +func (f *tournamentViewFixture) expectBytes(name string, inputs []any, output []byte, callErr error) { + f.t.Helper() + input, err := f.abi.Pack(name, inputs...) + require.NoError(f.t, err) + f.backend.On("Call", f.address, input, rpc.BlockNumber(f.opts.BlockNumber.Int64())).Return(output, callErr).Once() +} + +func TestTournamentAdapterDecodesCompleteViews(t *testing.T) { + f := newTournamentViewFixture(t) + one, two, three := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + large := new(big.Int).Lsh(big.NewInt(1), 200) + f.expect("tournamentDescriptor", nil, itournament.ITournamentTournamentDescriptor{ + InitialHash: one, BaseCycle: large, Log2Stride: 3, Height: 4, Level: 5, Kind: 1, StartInstant: 6, Allowance: 7, + }) + descriptor, err := f.adapter.Descriptor(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentDescriptor{InitialHash: one, BaseCycle: large, Log2Stride: 3, Height: 4, Level: 5, + Kind: model.TournamentKindNonLeaf, StartInstant: 6, Allowance: 7}, descriptor) + + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{ + Standing: 4, HasCandidate: true, Candidate: one, FinalState: two, ParentCommitment: three, FinishedAt: 8, WinnerExpiresAt: 9, + }) + standing, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentStanding{State: model.TournamentStandingInnerWinner, HasCandidate: true, + Candidate: one, FinalState: two, ParentCommitment: three, FinishedAt: 8, WinnerExpiresAt: 9}, standing) + + f.expect("commitmentStanding", []any{one}, itournament.ITournamentCommitmentStandingView{ + Joined: true, FinalState: two, Claimer: f.address, ClockRunning: true, ClockDeadline: 11, ClockAllowance: 12, + }) + commitment, err := f.adapter.CommitmentStanding(f.opts, one) + require.NoError(t, err) + require.Equal(t, CommitmentStanding{Joined: true, FinalState: two, Claimer: f.address, + ClockRunning: true, ClockDeadline: 11, ClockAllowance: 12}, commitment) + + f.expect("innerResult", nil, itournament.ITournamentInnerResultView{Disposition: 1, ParentCommitment: three, PausedAllowance: 13}) + inner, err := f.adapter.InnerResult(f.opts) + require.NoError(t, err) + require.Equal(t, InnerResult{Disposition: model.InnerTournamentWinner, ParentCommitment: three, PausedAllowance: 13}, inner) + f.expect("bondRecovery", nil, uint8(2), f.address, large) + bond, err := f.adapter.BondRecovery(f.opts) + require.NoError(t, err) + require.Equal(t, BondRecovery{Disposition: model.BondDispositionRecoverable, Claimer: f.address, Payment: large}, bond) + f.expect("bondValue", nil, large) + value, err := f.adapter.BondValue(f.opts) + require.NoError(t, err) + require.Equal(t, large, value) +} + +func TestTournamentAdapterReadsSixDistinctCounters(t *testing.T) { + f := newTournamentViewFixture(t) + names := []string{"getCommitmentJoinedCount", "getMatchCreatedCount", "getMatchAdvancedCount", + "getLeafMatchSealedCount", "getMatchDeletedCount", "getNewInnerTournamentCount"} + values := make([]*big.Int, len(names)) + for index, name := range names { + values[index] = new(big.Int).Lsh(big.NewInt(1), 200+uint(index)) + f.expect(name, nil, values[index]) + } + counts, err := f.adapter.StructuralEventCounts(f.opts) + require.NoError(t, err) + require.Equal(t, StructuralEventCounts{CommitmentJoined: values[0], MatchCreated: values[1], MatchAdvanced: values[2], + LeafMatchSealed: values[3], MatchDeleted: values[4], NewInnerTournament: values[5]}, counts) +} + +func TestTournamentAdapterDecodesStandingOrdinalsAndTimeOnlyChanges(t *testing.T) { + states := []struct { + ordinal uint8 + state model.TournamentStandingState + }{ + {0, model.TournamentStandingMatchesActive}, {1, model.TournamentStandingAwaitingClosure}, + {2, model.TournamentStandingRootWinner}, {3, model.TournamentStandingRootFailed}, {4, model.TournamentStandingInnerWinner}, + {5, model.TournamentStandingInnerEliminableNoWinner}, {6, model.TournamentStandingInnerEliminableWinnerExpired}, + } + for _, test := range states { + t.Run(string(test.state), func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{Standing: test.ordinal}) + standing, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, test.state, standing.State) + }) + } + f := newTournamentViewFixture(t) + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{AcceptsJoins: true}) + open, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.True(t, open.AcceptsJoins) + require.False(t, open.HasCandidate) + one := common.HexToHash("0x11") + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{ + Standing: 6, HasCandidate: true, Candidate: one, FinishedAt: 100, + }) + expired, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentStanding{State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: one, FinishedAt: 100}, expired) +} + +func TestTournamentAdapterDecodesInactiveViews(t *testing.T) { + f := newTournamentViewFixture(t) + one := common.HexToHash("0x11") + for _, binding := range []itournament.ITournamentCommitmentStandingView{ + {}, {Joined: true, FinalState: one, ClockAllowance: 12}, + } { + f.expect("commitmentStanding", []any{one}, binding) + standing, err := f.adapter.CommitmentStanding(f.opts, one) + require.NoError(t, err) + require.Equal(t, binding.Joined, standing.Joined) + require.False(t, standing.ClockRunning) + require.Zero(t, standing.ClockDeadline) + require.Equal(t, binding.ClockAllowance, standing.ClockAllowance) + require.Zero(t, standing.Claimer, "bond recovery can clear the claimer without clearing the join record") + } + for _, test := range []struct { + raw uint8 + want model.InnerTournamentDisposition + }{{0, model.InnerTournamentUnsettled}, {2, model.InnerTournamentEliminable}} { + f.expect("innerResult", nil, itournament.ITournamentInnerResultView{Disposition: test.raw}) + inner, err := f.adapter.InnerResult(f.opts) + require.NoError(t, err) + require.Equal(t, InnerResult{Disposition: test.want}, inner) + } + for _, test := range []struct { + raw uint8 + want model.BondDisposition + }{{0, model.BondDispositionTournamentRunning}, {1, model.BondDispositionNoWinner}, {3, model.BondDispositionRecovered}} { + f.expect("bondRecovery", nil, test.raw, common.Address{}, big.NewInt(0)) + bond, err := f.adapter.BondRecovery(f.opts) + require.NoError(t, err) + require.Equal(t, BondRecovery{Disposition: test.want, Payment: big.NewInt(0)}, bond) + } +} + +func TestTournamentAdapterDecodesEveryMatchPhase(t *testing.T) { + one, two, three := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + position := new(big.Int).Lsh(big.NewInt(1), 100) + cycle := new(big.Int).Lsh(big.NewInt(1), 200) + for _, test := range []struct { + name string + phase uint8 + outcome uint8 + charge uint64 + method string + payload any + want ObservedMatchSnapshot + }{ + {name: "absent", want: ObservedMatchSnapshot{Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone}}, + {name: "bisecting", phase: 1, outcome: 2, charge: 7, method: "bisectingMatch", + payload: itournament.ITournamentBisectingMatchView{RevealingParent: one, WaitingLeft: two, WaitingRight: three, + SegmentStartPosition: position, SegmentStartCycle: cycle, CurrentHeight: 4, Responder: 0}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseBisecting, TimeoutOutcome: model.MatchTimeoutTwoWins, DeferredCharge: 7, + Bisecting: &BisectingMatch{RevealingParent: one, WaitingLeft: two, WaitingRight: three, + SegmentStartPosition: position, SegmentStartCycle: cycle, CurrentHeight: 4, Responder: model.CommitmentSideOne}}}, + {name: "ready to seal", phase: 2, outcome: 1, charge: 8, method: "readyToSealMatch", + payload: itournament.ITournamentReadyToSealMatchView{RevealingParent: three, WaitingLeft: two, WaitingRight: one, + SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: 1}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseReadyToSeal, TimeoutOutcome: model.MatchTimeoutOneWins, DeferredCharge: 8, + ReadyToSeal: &ReadyToSealMatch{RevealingParent: three, WaitingLeft: two, WaitingRight: one, + SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: model.CommitmentSideTwo}}}, + {name: "sealed", phase: 3, outcome: 3, method: "sealedMatch", + payload: itournament.ITournamentSealedMatchView{AgreeState: one, DivergencePosition: position, + DivergenceCycle: cycle, FinalStateOne: two, FinalStateTwo: three}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseSealed, TimeoutOutcome: model.MatchTimeoutEliminateBoth, + Sealed: &SealedMatch{AgreeState: one, DivergencePosition: position, DivergenceCycle: cycle, + FinalStateOne: two, FinalStateTwo: three}}}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, test.outcome, test.charge) + if test.method != "" { + f.expect(test.method, []any{crypto.Keccak256Hash(one[:], two[:])}, test.phase, test.payload) + } + snapshot, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.NoError(t, err) + require.Equal(t, test.want, snapshot) + }) + } +} + +func TestTournamentAdapterRejectsUnknownViewEnums(t *testing.T) { + for _, test := range []struct { + name string + method string + output []any + read func(TournamentAdapter, *bind.CallOpts) error + }{ + {"kind", "tournamentDescriptor", []any{itournament.ITournamentTournamentDescriptor{Kind: 2, BaseCycle: big.NewInt(0)}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.Descriptor(o); return err }}, + {"standing", "tournamentStanding", []any{itournament.ITournamentTournamentStandingView{Standing: 7}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.Standing(o); return err }}, + {"inner result", "innerResult", []any{itournament.ITournamentInnerResultView{Disposition: 3}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.InnerResult(o); return err }}, + {"bond", "bondRecovery", []any{uint8(4), common.Address{}, big.NewInt(0)}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.BondRecovery(o); return err }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect(test.method, nil, test.output...) + require.ErrorContains(t, test.read(f.adapter, f.opts), "unknown") + }) + } +} + +func TestTournamentAdapterRejectsInvalidMatchViews(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + name string + phase uint8 + outcome uint8 + charge uint64 + projectionPhase uint8 + responder uint8 + readProjection bool + }{ + {name: "unknown phase", phase: 4}, + {name: "unknown timeout", phase: 1, outcome: 4}, + {name: "absent timeout", outcome: 1}, + {name: "inactive charge", phase: 1, charge: 1}, + {name: "different pinned phase", phase: 1, projectionPhase: 2, readProjection: true}, + {name: "unknown responder", phase: 1, projectionPhase: 1, responder: 2, readProjection: true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, test.outcome, test.charge) + if test.readProjection { + f.expect("bisectingMatch", []any{crypto.Keccak256Hash(one[:], two[:])}, test.projectionPhase, + itournament.ITournamentBisectingMatchView{SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0), + CurrentHeight: 2, Responder: test.responder}) + } + snapshot, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.Error(t, err) + require.Equal(t, ObservedMatchSnapshot{}, snapshot) + }) + } +} + +func TestTournamentReadsRejectUnpinnedSnapshots(t *testing.T) { + for _, opts := range []*bind.CallOpts{nil, {}, {BlockNumber: big.NewInt(-1)}, {BlockNumber: big.NewInt(10), Pending: true}} { + f := newTournamentViewFixture(t) + _, err := f.adapter.StructuralEventCounts(opts) + require.ErrorContains(t, err, "pinned block") + _, err = f.adapter.MatchSnapshot(opts, [32]byte{}, [32]byte{}) + require.ErrorContains(t, err, "pinned block") + } +} + +func TestTournamentAdapterChecksEachPinnedProjectionPhase(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + phase uint8 + method string + payload any + }{ + {1, "bisectingMatch", itournament.ITournamentBisectingMatchView{ + SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, + {2, "readyToSealMatch", itournament.ITournamentReadyToSealMatchView{ + SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, + {3, "sealedMatch", itournament.ITournamentSealedMatchView{ + DivergencePosition: big.NewInt(0), DivergenceCycle: big.NewInt(0)}}, + } { + t.Run(test.method, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, uint8(0), uint64(0)) + f.expect(test.method, []any{crypto.Keccak256Hash(one[:], two[:])}, uint8(0), test.payload) + _, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.ErrorContains(t, err, "phase changed") + }) + } +} + +func TestTournamentReadsPropagateRPCAndScalarErrors(t *testing.T) { + t.Run("counter read error", func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("getCommitmentJoinedCount", nil, big.NewInt(1)) + f.expectBytes("getMatchCreatedCount", nil, nil, errors.New("provider unavailable")) + counts, err := f.adapter.StructuralEventCounts(f.opts) + require.ErrorContains(t, err, "provider unavailable") + require.Equal(t, StructuralEventCounts{}, counts) + }) + t.Run("uint64 overflow", func(t *testing.T) { + f := newTournamentViewFixture(t) + encoded, err := f.abi.Methods["innerResult"].Outputs.Pack(itournament.ITournamentInnerResultView{Disposition: 1}) + require.NoError(t, err) + const allowanceWordStart = 64 + encoded[allowanceWordStart] = 1 + f.expectBytes("innerResult", nil, encoded, nil) + _, err = f.adapter.InnerResult(f.opts) + require.Error(t, err) + }) +} + +func TestTournamentUint256BoundsAndOwnership(t *testing.T) { + maximum := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) + for _, value := range []*big.Int{nil, big.NewInt(-1), new(big.Int).Add(maximum, big.NewInt(1))} { + _, err := tournamentUint256("test", value) + require.Error(t, err) + } + copiedAmount, err := tournamentUint256("test", maximum) + require.NoError(t, err) + require.Equal(t, maximum, copiedAmount) + copiedAmount.SetInt64(0) + require.NotZero(t, maximum.Sign(), "the DTO owns its integer") +} diff --git a/internal/prt/typed_errors_test.go b/internal/prt/typed_errors_test.go deleted file mode 100644 index 2c08187d7..000000000 --- a/internal/prt/typed_errors_test.go +++ /dev/null @@ -1,118 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package prt - -import ( - "testing" - - "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" - "github.com/cartesi/rollups-node/pkg/contracts/itournament" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestPRTTypedErrorNamesExistInABI walks every typed-error name the PRT -// package references and asserts it exists in the appropriate ABI metadata. -// Catches silent regressions when contracts rename errors (e.g. v3 renamed -// ClockNotTimedOut → NeitherClockHasTimedOut and BothClocksHaveNotTimedOut -// → AtLeastOneClockHasNotTimedOut — neither old name appears in PRT today, -// but the same shape of rename can recur). -// -// Maintenance: add an entry here every time PRT starts referencing a new -// typed error by name (via ethutil.IsCustomError or a hardcoded selector). -// Mirror entries are kept across both ABIs where appropriate. -func TestPRTTypedErrorNamesExistInABI(t *testing.T) { - tournamentABI, err := itournament.ITournamentMetaData.GetAbi() - require.NoError(t, err) - daveABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() - require.NoError(t, err) - - cases := []struct { - name string - abi map[string]struct { - present bool - } - // where: brief locator pointing at the source reference, for - // failure messages. - where string - }{ - // itournament_adapter.go: Result() tolerates ArbitrationResult - // reverting with TournamentFailedNoWinner. - {name: "TournamentFailedNoWinner", where: "itournament_adapter.go (Result)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - - // prt.go: handleJoinTournamentRevert classifies JoinTournament reverts. - {name: "ClockAlreadyInitialized", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - {name: "TournamentIsClosed", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - {name: "TournamentIsFinished", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - {name: "CommitmentStateMismatch", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - {name: "CommitmentProofWrongSize", where: "prt.go (handleJoinTournamentRevert)", - abi: map[string]struct{ present bool }{"itournament": {true}}}, - - // prt.go: handleSettleRevert classifies Settle reverts. - {name: "IncorrectEpochNumber", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "TournamentNotFinishedYet", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "InvalidOutputsMerkleRootProofSize", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "InvalidOutputsMerkleRootProof", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "ApplicationForeclosed", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "ApplicationNotDeployed", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "ApplicationReverted", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - {name: "IllformedApplicationReturnData", where: "prt.go (handleSettleRevert)", - abi: map[string]struct{ present bool }{"idaveconsensus": {true}}}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - for which := range tc.abi { - var ok bool - switch which { - case "itournament": - _, ok = tournamentABI.Errors[tc.name] - case "idaveconsensus": - _, ok = daveABI.Errors[tc.name] - default: - t.Fatalf("unknown ABI bucket %q for %s", which, tc.name) - } - assert.True(t, ok, - "%s missing from %s ABI (referenced by %s) — check whether the contract renamed it", - tc.name, which, tc.where) - } - }) - } -} - -// TestPRTHasNoReferencesToRenamedErrors locks against accidental reintroduction -// of v2 error names that v3 renamed. If a future maintainer copies a code -// fragment from a v2 branch that references one of these, the existence check -// in TestPRTTypedErrorNamesExistInABI would still catch it — but this test -// fails earlier with a more direct message. -func TestPRTHasNoReferencesToRenamedErrors(t *testing.T) { - tournamentABI, err := itournament.ITournamentMetaData.GetAbi() - require.NoError(t, err) - - v3Renames := map[string]string{ - "ClockNotTimedOut": "NeitherClockHasTimedOut", - "BothClocksHaveNotTimedOut": "AtLeastOneClockHasNotTimedOut", - } - for oldName, newName := range v3Renames { - _, oldExists := tournamentABI.Errors[oldName] - assert.False(t, oldExists, - "v2 error %q unexpectedly present in v3 ITournament ABI", oldName) - _, newExists := tournamentABI.Errors[newName] - assert.True(t, newExists, - "v3 renamed error %q missing from ITournament ABI (was %q in v2)", - newName, oldName) - } -} diff --git a/internal/prt/types.go b/internal/prt/types.go index 490cb64dd..932ca1d0d 100644 --- a/internal/prt/types.go +++ b/internal/prt/types.go @@ -6,6 +6,8 @@ package prt import ( "math/big" + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -14,44 +16,165 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/itournament" ) -type TournamentConstants struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 +// TournamentDescriptor is the immutable tournament configuration exposed by +// the Dave tournament contract. +type TournamentDescriptor struct { + InitialHash common.Hash + BaseCycle *big.Int + Log2Stride uint64 + Height uint64 + Level uint64 + Kind model.TournamentKind + StartInstant uint64 + Allowance uint64 +} + +// TournamentStanding is the current tournament result and timing projection. +type TournamentStanding struct { + State model.TournamentStandingState + AcceptsJoins bool + HasCandidate bool + Candidate common.Hash + FinalState common.Hash + ParentCommitment common.Hash + FinishedAt uint64 + WinnerExpiresAt uint64 +} + +// CommitmentStanding is the current on-chain record for one commitment. +type CommitmentStanding struct { + Joined bool + FinalState common.Hash + Claimer common.Address + ClockRunning bool + ClockDeadline uint64 + ClockAllowance uint64 +} + +// BisectingMatch is the current divergence frontier before the final opening. +type BisectingMatch struct { + RevealingParent common.Hash + WaitingLeft common.Hash + WaitingRight common.Hash + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + CurrentHeight uint64 + Responder model.CommitmentSide +} + +// ReadyToSealMatch is the final divergence frontier, before sealing. +type ReadyToSealMatch struct { + RevealingParent common.Hash + WaitingLeft common.Hash + WaitingRight common.Hash + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + Responder model.CommitmentSide +} + +// SealedMatch stores states in commitment-side order, not reveal order. +type SealedMatch struct { + AgreeState common.Hash + DivergencePosition *big.Int + DivergenceCycle *big.Int + FinalStateOne common.Hash + FinalStateTwo common.Hash +} + +// ObservedMatchSnapshot contains only the payload selected by Phase. Deleted matches +// have phase UNINITIALIZED and no payload; their events remain historical data. +type ObservedMatchSnapshot struct { + Phase model.MatchPhase + TimeoutOutcome model.MatchTimeoutOutcome + DeferredCharge uint64 + Bisecting *BisectingMatch + ReadyToSeal *ReadyToSealMatch + Sealed *SealedMatch +} + +// InnerResult includes the carryover allowance at the observation block. +type InnerResult struct { + Disposition model.InnerTournamentDisposition + ParentCommitment common.Hash + PausedAllowance uint64 +} + +// StructuralEventCounts contains six independent on-chain counters. Financial +// events have no dedicated counter in this contract version. +type StructuralEventCounts struct { + CommitmentJoined *big.Int + MatchCreated *big.Int + MatchAdvanced *big.Int + LeafMatchSealed *big.Int + MatchDeleted *big.Int + NewInnerTournament *big.Int +} + +// BondRecovery is the current recovery state of a tournament bond. +type BondRecovery struct { + Disposition model.BondDisposition + Claimer common.Address + Payment *big.Int } // TournamentAdapter provides read and write access to tournament contracts. type TournamentAdapter interface { - RetrieveCommitmentJoinedEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentCommitmentJoined, error) - RetrieveMatchAdvancedEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentMatchAdvanced, error) - RetrieveMatchCreatedEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentMatchCreated, error) - RetrieveMatchDeletedEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentMatchDeleted, error) - RetrieveNewInnerTournamentEvents(opts *bind.FilterOpts) ([]*itournament.ITournamentNewInnerTournament, error) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) - Result(opts *bind.CallOpts) (bool, [32]byte, [32]byte, error) - Constants(opts *bind.CallOpts) (TournamentConstants, error) - TimeFinished(opts *bind.CallOpts) (bool, uint64, error) + Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) + Standing(opts *bind.CallOpts) (TournamentStanding, error) + CommitmentStanding(opts *bind.CallOpts, commitmentRoot [32]byte) (CommitmentStanding, error) + MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) + InnerResult(opts *bind.CallOpts) (InnerResult, error) + StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) BondValue(opts *bind.CallOpts) (*big.Int, error) - IsCommitmentJoined(opts *bind.CallOpts, commitmentRoot [32]byte) (bool, error) + BondRecovery(opts *bind.CallOpts) (BondRecovery, error) JoinTournament(opts *bind.TransactOpts, finalState [32]byte, proof [][32]byte, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) + TryRecoveringBond(opts *bind.TransactOpts) (*types.Transaction, error) } // DaveConsensusAdapter wraps access to the IDaveConsensus contract. type DaveConsensusAdapter interface { ParseEpochSealed(log types.Log) (*idaveconsensus.IDaveConsensusEpochSealed, error) - CanSettle(opts *bind.CallOpts) (CanSettleResult, error) - IsEpochSettled(opts *bind.CallOpts, epochNumber uint64) (bool, error) - Settle(opts *bind.TransactOpts, epochNumber *big.Int, - outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) + TournamentLevelCount(opts *bind.CallOpts) (uint64, error) + GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) + CanStageTournamentResult(opts *bind.CallOpts) (CanStageTournamentResult, error) + CanAcceptStagedTournamentResult(opts *bind.CallOpts) (CanAcceptStagedTournamentResult, error) + StageTournamentResult(opts *bind.TransactOpts, epochNumber uint64, + proof model.StateProof) (*types.Transaction, error) + AcceptStagedTournamentResult(opts *bind.TransactOpts, epochNumber uint64) (*types.Transaction, error) +} + +// CurrentSealedEpoch is the complete state of the current sealed epoch. +type CurrentSealedEpoch struct { + EpochNumber uint64 + InputIndexLowerBound uint64 + InputIndexUpperBound uint64 + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber uint64 + StagedPostEpochMachineStateHash common.Hash + StagedPostEpochOutputsMerkleRoot common.Hash +} + +// CanStageTournamentResult is the stage readiness view for the current sealed epoch. +type CanStageTournamentResult struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber uint64 + WinnerCommitment common.Hash + WinnerPostEpochMachineStateHash common.Hash } -// CanSettleResult holds the result of a CanSettle call. -type CanSettleResult struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte +// CanAcceptStagedTournamentResult is the accept readiness view for the current sealed epoch. +type CanAcceptStagedTournamentResult struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber uint64 + StagedPostEpochMachineStateHash common.Hash + StagedPostEpochOutputsMerkleRoot common.Hash } // AdapterFactory creates contract adapters from on-chain addresses. @@ -67,25 +190,22 @@ type TournamentEvents struct { MatchCreated []*itournament.ITournamentMatchCreated MatchDeleted []*itournament.ITournamentMatchDeleted NewInnerTournament []*itournament.ITournamentNewInnerTournament + LeafMatchSealed []*itournament.ITournamentLeafMatchSealed + PartialBondRefund []*itournament.ITournamentPartialBondRefund + BondRecovered []*itournament.ITournamentBondRecovered } type TournamentLevel uint64 const ( RootLevel TournamentLevel = iota - MiddleLevel - BottomLevel ) func (l TournamentLevel) String() string { switch l { case RootLevel: return "root" - case MiddleLevel: - return "middle" - case BottomLevel: - return "bottom" default: - return "unknown" + return "inner" } } diff --git a/internal/prt/validation_test.go b/internal/prt/validation_test.go index afa495e28..32bf0d1e9 100644 --- a/internal/prt/validation_test.go +++ b/internal/prt/validation_test.go @@ -7,7 +7,6 @@ import ( "bytes" "context" "errors" - "io" "log/slog" "math/big" "strings" @@ -67,59 +66,50 @@ func TestLogErrorUnlessShutdown(t *testing.T) { } } -func TestTrySettleOperationDeadlineDoesNotCancelServiceContext(t *testing.T) { - s, app := newValidationService(t) - ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) - defer cancel() - - err := s.trySettle(ctx, app, 1) - - require.ErrorIs(t, err, context.DeadlineExceeded) - require.NoError(t, t.Context().Err()) -} - -func TestTrySettleShutdownCancelsOperationContext(t *testing.T) { - s, app := newValidationService(t) - parentCtx, parentCancel := context.WithCancel(context.Background()) - ctx, cancel := context.WithTimeout(parentCtx, time.Second) - defer cancel() - - time.AfterFunc(50*time.Millisecond, parentCancel) - start := time.Now() - err := s.trySettle(ctx, app, 1) - - require.ErrorIs(t, err, context.Canceled) - require.ErrorIs(t, ctx.Err(), context.Canceled) - require.Less(t, time.Since(start), 500*time.Millisecond) -} - -func TestReactToTournamentOperationDeadlineDoesNotCancelServiceContext(t *testing.T) { - s, app := newValidationService(t) - ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) - defer cancel() - - err := s.reactToTournament(ctx, app, 1) - - require.ErrorIs(t, err, context.DeadlineExceeded) - require.NoError(t, t.Context().Err()) -} - -func TestReactToTournamentShutdownCancelsOperationContext(t *testing.T) { - s, app := newValidationService(t) - parentCtx, parentCancel := context.WithCancel(context.Background()) - ctx, cancel := context.WithTimeout(parentCtx, time.Second) - defer cancel() - - time.AfterFunc(50*time.Millisecond, parentCancel) - start := time.Now() - err := s.reactToTournament(ctx, app, 1) +func TestPRTOperationContextCancellation(t *testing.T) { + for _, test := range []struct { + name string + staged bool + shutdown bool + }{ + {name: "AcceptResult/OperationDeadline", staged: true}, + {name: "AcceptResult/Shutdown", staged: true, shutdown: true}, + {name: "ReactToTournament/OperationDeadline"}, + {name: "ReactToTournament/Shutdown", shutdown: true}, + } { + t.Run(test.name, func(t *testing.T) { + s, app, epoch := newValidationService(t, test.staged) + parentCtx, parentCancel := context.WithCancel(t.Context()) + defer parentCancel() + timeout := 50 * time.Millisecond + if test.shutdown { + timeout = time.Second + timer := time.AfterFunc(50*time.Millisecond, parentCancel) + defer timer.Stop() + } + ctx, cancel := context.WithTimeout(parentCtx, timeout) + defer cancel() - require.ErrorIs(t, err, context.Canceled) - require.ErrorIs(t, ctx.Err(), context.Canceled) - require.Less(t, time.Since(start), 500*time.Millisecond) + start := time.Now() + var err error + if test.staged { + _, _, err = s.progressTournamentResult(ctx, app, 1, 1) + } else { + _, err = s.reactToTournament(ctx, app, epoch, 1) + } + if test.shutdown { + require.ErrorIs(t, err, context.Canceled) + require.ErrorIs(t, ctx.Err(), context.Canceled) + require.Less(t, time.Since(start), 500*time.Millisecond) + } else { + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NoError(t, parentCtx.Err()) + } + }) + } } -func newValidationService(t *testing.T) (*Service, *model.Application) { +func newValidationService(t *testing.T, staged bool) (*Service, *model.Application, *model.Epoch) { t.Helper() app := repotest.NewApplicationBuilder(). @@ -127,8 +117,12 @@ func newValidationService(t *testing.T) (*Service, *model.Application) { Build() app.IConsensusAddress = common.HexToAddress("0x3") + status := model.EpochStatus_ClaimComputed + if staged { + status = model.EpochStatus_ClaimStaged + } epoch := repotest.NewEpochBuilder(app.ID). - WithStatus(model.EpochStatus_ClaimComputed). + WithStatus(status). WithMachineHash(common.HexToHash("0x6")). WithTxBufferDataBlock(common.HexToHash("0x8")). Build() @@ -137,9 +131,12 @@ func newValidationService(t *testing.T) (*Service, *model.Application) { epoch.TournamentAddress = &tournamentAddress epoch.Commitment = &commitment epoch.CommitmentProof = []common.Hash{common.HexToHash("0x7")} - epoch.TxBufferProof = []common.Hash{} + if staged { + epoch.StagedAtBlock = new(uint64) + *epoch.StagedAtBlock = 1 + } - repo := &prtRepositoryMock{} + s, repo := newPRTServiceMock() repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), uint64(0)). Return(epoch, nil) repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), uint64(0), @@ -148,19 +145,41 @@ func newValidationService(t *testing.T) (*Service, *model.Application) { Maybe() consensusAdapter := &daveConsensusAdapterMock{} - consensusAdapter.On("CanSettle", mock.Anything). - Return(CanSettleResult{IsFinished: true, EpochNumber: big.NewInt(0)}, nil) - consensusAdapter.On("IsEpochSettled", mock.Anything, uint64(0)). - Return(false, nil) - consensusAdapter.On("Settle", mock.Anything, big.NewInt(0), [32]byte(common.HexToHash("0x8")), [][32]byte{}). - Return((*types.Transaction)(nil), func(opts *bind.TransactOpts, _ *big.Int, _ [32]byte, _ [][32]byte) error { + consensusAdapter.On("GetCurrentSealedEpoch", mock.Anything).Return(CurrentSealedEpoch{ + EpochNumber: 0, + Tournament: tournamentAddress, + IsTournamentResultStaged: true, + StagingBlockNumber: 1, + StagedPostEpochMachineStateHash: *epoch.MachineHash, + StagedPostEpochOutputsMerkleRoot: *epoch.TxBufferDataBlock, + }, nil) + consensusAdapter.On("CanStageTournamentResult", mock.Anything).Return(CanStageTournamentResult{ + IsFinished: true, + IsTournamentResultStaged: true, + EpochNumber: 0, + WinnerCommitment: commitment, + WinnerPostEpochMachineStateHash: *epoch.MachineHash, + }, nil) + consensusAdapter.On("CanAcceptStagedTournamentResult", mock.Anything).Return(CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: true, + IsClaimStagingPeriodOver: true, + EpochNumber: 0, + StagedPostEpochMachineStateHash: *epoch.MachineHash, + StagedPostEpochOutputsMerkleRoot: *epoch.TxBufferDataBlock, + }, nil) + consensusAdapter.On("AcceptStagedTournamentResult", mock.Anything, uint64(0)). + Return((*types.Transaction)(nil), func(opts *bind.TransactOpts, _ uint64) error { <-opts.Context.Done() return opts.Context.Err() }) tournamentAdapter := &tournamentAdapterMock{} - tournamentAdapter.On("IsCommitmentJoined", mock.Anything, [32]byte(commitment)). - Return(false, nil) + if !staged { + tournamentAdapter.On("Descriptor", mock.Anything). + Return(TournamentDescriptor{Height: model.Log2EpochComputationHashLeafCount}, nil).Once() + } + tournamentAdapter.On("CommitmentStanding", mock.Anything, [32]byte(commitment)). + Return(CommitmentStanding{}, nil) tournamentAdapter.On("BondValue", mock.Anything). Return(big.NewInt(0), nil) tournamentAdapter.On("JoinTournament", @@ -182,23 +201,11 @@ func newValidationService(t *testing.T) (*Service, *model.Application) { adapterFactory.On("CreateTournamentAdapter", tournamentAddress). Return(tournamentAdapter, nil) - s := &Service{ - TickServiceTemplate: service.TickServiceTemplate{ - BaseTemplate: service.BaseTemplate{ - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - }, - }, - repository: repo, - adapterFactory: adapterFactory, - submissionEnabled: true, - submissionTimeout: time.Second, - txOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{ - From: common.HexToAddress("0x9"), - }), - currentEpochIndex: map[int64]uint64{}, - settleInFlight: map[int64]*common.Hash{}, - joinInFlight: map[int64]*common.Hash{}, - } - s.currentEpochIndex[app.ID] = 0 - return s, app + s.adapterFactory = adapterFactory + s.submissionEnabled = true + s.submissionTimeout = time.Second + s.txOptsFactory = ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{ + From: common.HexToAddress("0x9"), + }) + return s, app, epoch } diff --git a/internal/prt/warnings.go b/internal/prt/warnings.go new file mode 100644 index 000000000..0afb70414 --- /dev/null +++ b/internal/prt/warnings.go @@ -0,0 +1,31 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +func (s *Service) warnUnsupportedDispute(app *Application, epochIndex uint64, tournament common.Address) { + if _, warned := s.disputeWarnings[tournament]; warned { + return + } + s.disputeWarnings[tournament] = struct{}{} + s.Logger.Warn("This node release sends no dispute moves; the commitment can lose by timeout", + "application", app.Name, "epoch_index", epochIndex, "tournament", tournament) +} + +func (s *Service) warnZeroStagingPeriod(app *Application) { + if app.ClaimStagingPeriod != 0 { + return + } + if _, warned := s.zeroStagingWarnings[app.ID]; warned { + return + } + s.zeroStagingWarnings[app.ID] = struct{}{} + s.Logger.Warn("Application has no claim staging delay; without sentries, a staged result can be accepted immediately", + "application", app.Name, "address", app.IApplicationAddress, + "claim_staging_period", app.ClaimStagingPeriod) +} diff --git a/internal/prt/warnings_test.go b/internal/prt/warnings_test.go new file mode 100644 index 000000000..d221ae3f9 --- /dev/null +++ b/internal/prt/warnings_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "log/slog" + "math/big" + "strings" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentObservationWarnsOnlyForActiveDisputes(t *testing.T) { + for _, test := range []struct { + name string + state TournamentStandingState + warn bool + }{ + {name: "active matches", state: TournamentStandingMatchesActive, warn: true}, + {name: "uncontested wait", state: TournamentStandingAwaitingClosure}, + } { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(test.name+"/"+operation, func(t *testing.T) { + s, repo := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := prtRevertTestApp() + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + tournament := &Tournament{Address: *epoch.TournamentAddress, MaxLevel: 2, Height: 48, Kind: TournamentKindNonLeaf} + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Height: 48, Kind: TournamentKindNonLeaf, + }, nil).Twice() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{State: test.state}, nil).Twice() + for range 2 { + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, test.state) + if update { + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, tournament, 20)) + } else { + _, err := s.readTournament(t.Context(), app, epoch, RootLevel, nil, nil, tournament.Address, adapter, 2, 20) + require.NoError(t, err) + } + } + wantWarnings := 0 + if test.warn { + wantWarnings = 1 + require.Contains(t, output.String(), "sends no dispute moves") + require.Contains(t, output.String(), "commitment can lose by timeout") + require.Contains(t, output.String(), "application="+app.Name) + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), tournament.Address.Hex()) + } + require.Equal(t, wantWarnings, strings.Count(output.String(), "level=WARN")) + require.Equal(t, ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } + } +} + +func TestStoredInnerTournamentWarningsFollowCurrentStanding(t *testing.T) { + for _, test := range []struct { + name string + state TournamentStandingState + finish uint64 + wantWarnings int + }{ + {name: "active after restart", state: TournamentStandingMatchesActive, wantWarnings: 2}, + {name: "finished history", state: TournamentStandingInnerEliminableWinnerExpired, finish: 10}, + } { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + inner := &Tournament{Address: *epoch.TournamentAddress, Level: 1, MaxLevel: 2, Kind: TournamentKindLeaf} + var output bytes.Buffer + for range 2 { + s, repo := newPRTServiceMock() + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + adapter := &tournamentAdapterMock{} + for range 2 { + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: 1, Kind: TournamentKindLeaf, + }, nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{ + State: test.state, HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: test.finish, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, 1, test.state) + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, 1, adapter, inner, 20)) + } + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + } + require.Equal(t, test.wantWarnings, strings.Count(output.String(), "sends no dispute moves")) + require.Equal(t, ApplicationStatus_OK, app.Status) + }) + } +} diff --git a/internal/repository/node_config_test.go b/internal/repository/node_config_test.go new file mode 100644 index 000000000..4b3ba2c3b --- /dev/null +++ b/internal/repository/node_config_test.go @@ -0,0 +1,59 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type nodeConfigReadStub struct { + NodeConfigRepository + load func(context.Context, string) ([]byte, time.Time, time.Time, error) +} + +func (r nodeConfigReadStub) LoadNodeConfigRaw(ctx context.Context, key string) ([]byte, time.Time, time.Time, error) { + return r.load(ctx, key) +} + +func TestLoadNodeConfigResult(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + raw []byte + want string + }{ + {name: "nil payload", want: "has no JSON value"}, + {name: "empty payload", raw: []byte{}, want: "unmarshal node_config value failed"}, + {name: "invalid JSON", raw: []byte(`{`), want: "unmarshal node_config value failed"}, + {name: "valid payload", raw: []byte(`{"ChainID":42}`)}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + createdAt := time.Unix(1, 0) + updatedAt := createdAt.Add(time.Second) + repo := nodeConfigReadStub{load: func(ctx context.Context, key string) ([]byte, time.Time, time.Time, error) { + require.Equal(t, t.Context(), ctx) + require.Equal(t, "test-config", key) + return test.raw, createdAt, updatedAt, nil + }} + type testConfig struct{ ChainID uint64 } + got, err := LoadNodeConfig[testConfig](t.Context(), repo, "test-config") + if test.want != "" { + require.ErrorContains(t, err, test.want) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got, "a successful load must return a record") + require.Equal(t, "test-config", got.Key) + require.Equal(t, testConfig{ChainID: 42}, got.Value) + require.Equal(t, createdAt, got.CreatedAt) + require.Equal(t, updatedAt, got.UpdatedAt) + }) + } +} diff --git a/internal/repository/postgres/application.go b/internal/repository/postgres/application.go index da6d39bad..373299030 100644 --- a/internal/repository/postgres/application.go +++ b/internal/repository/postgres/application.go @@ -8,6 +8,7 @@ import ( "database/sql" "errors" "fmt" + "math" "github.com/ethereum/go-ethereum/common" "github.com/go-jet/jet/v2/postgres" @@ -40,7 +41,6 @@ func (r *PostgresRepository) CreateApplication( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.Enabled, table.Application.Status, @@ -73,7 +73,6 @@ func (r *PostgresRepository) CreateApplication( app.WithdrawalConfig.Log2MaxNumOfAccounts, app.WithdrawalConfig.AccountsDriveStartIndex, app.WithdrawalConfig.WithdrawalOutputBuilder, - app.DataAvailability, app.ConsensusType, app.Enabled, app.Status, @@ -187,7 +186,6 @@ func (r *PostgresRepository) GetApplication( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.Enabled, table.Application.Status, @@ -252,7 +250,6 @@ func (r *PostgresRepository) GetApplication( &app.WithdrawalConfig.Log2MaxNumOfAccounts, &app.WithdrawalConfig.AccountsDriveStartIndex, &app.WithdrawalConfig.WithdrawalOutputBuilder, - &app.DataAvailability, &app.ConsensusType, &app.Enabled, &app.Status, @@ -349,7 +346,6 @@ func (r *PostgresRepository) UpdateApplication( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.IinputboxBlock, ). @@ -367,7 +363,6 @@ func (r *PostgresRepository) UpdateApplication( app.WithdrawalConfig.Log2MaxNumOfAccounts, app.WithdrawalConfig.AccountsDriveStartIndex, app.WithdrawalConfig.WithdrawalOutputBuilder, - app.DataAvailability, app.ConsensusType, app.IInputBoxBlock, ). @@ -651,23 +646,15 @@ func getColumnForEvent(event model.MonitoredEvent) (postgres.ColumnFloat, error) return table.Application.LastInputCheckBlock, nil case model.MonitoredEvent_OutputExecuted: return table.Application.LastOutputCheckBlock, nil - case model.MonitoredEvent_CommitmentJoined: - fallthrough - case model.MonitoredEvent_MatchAdvanced: - fallthrough - case model.MonitoredEvent_MatchCreated: - fallthrough - case model.MonitoredEvent_MatchDeleted: - fallthrough - case model.MonitoredEvent_NewInnerTournament: + case model.MonitoredEvent_CommitmentJoined, model.MonitoredEvent_MatchAdvanced, + model.MonitoredEvent_MatchCreated, model.MonitoredEvent_MatchDeleted, model.MonitoredEvent_NewInnerTournament, + model.MonitoredEvent_LeafMatchSealed, model.MonitoredEvent_PartialBondRefund, model.MonitoredEvent_BondRecovered: return table.Application.LastTournamentCheckBlock, nil - case model.MonitoredEvent_ClaimSubmitted: - fallthrough - case model.MonitoredEvent_ClaimAccepted: - fallthrough - default: - return nil, fmt.Errorf("invalid monitored event type: %v", event) + case model.MonitoredEvent_ClaimSubmitted, model.MonitoredEvent_ClaimAccepted, + model.MonitoredEvent_Foreclosure, model.MonitoredEvent_Withdrawal, model.MonitoredEvent_AccountsDriveMerkleRootProved: + // These events use their own claim reconciliation or atomic marker writes. } + return nil, fmt.Errorf("invalid monitored event type: %v", event) } func (r *PostgresRepository) GetEventLastCheckBlock( @@ -823,6 +810,9 @@ func (r *PostgresRepository) ListApplications( p repository.Pagination, descending bool, ) ([]*model.Application, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } fromClause := table.Application.INNER_JOIN( table.ExecutionParameters, @@ -843,11 +833,6 @@ func (r *PostgresRepository) ListApplications( } conditions = append(conditions, table.Application.Status.IN(statuses...)) } - if f.DataAvailability != nil { - conditions = append(conditions, - SubstrBytea(table.Application.DataAvailability, 1, 4).EQ(postgres.Bytea(f.DataAvailability[:])), //nolint:mnd - ) - } if f.ConsensusType != nil { conditions = append(conditions, table.Application.ConsensusType.EQ(postgres.NewEnumValue(f.ConsensusType.String()))) } @@ -900,7 +885,6 @@ func (r *PostgresRepository) ListApplications( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.Enabled, table.Application.Status, @@ -981,7 +965,6 @@ func (r *PostgresRepository) ListApplications( &app.WithdrawalConfig.Log2MaxNumOfAccounts, &app.WithdrawalConfig.AccountsDriveStartIndex, &app.WithdrawalConfig.WithdrawalOutputBuilder, - &app.DataAvailability, &app.ConsensusType, &app.Enabled, &app.Status, diff --git a/internal/repository/postgres/bond_event.go b/internal/repository/postgres/bond_event.go new file mode 100644 index 000000000..44cbf4956 --- /dev/null +++ b/internal/repository/postgres/bond_event.go @@ -0,0 +1,91 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func (r *PostgresRepository) GetBondEvent( + ctx context.Context, nameOrAddress string, txHash common.Hash, logIndex uint64, +) (*BondEvent, error) { + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt). + FROM(table.BondEvents.INNER_JOIN(table.Application, table.BondEvents.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.BondEvents.TxHash.EQ(postgres.Bytea(txHash.Bytes())).AND(table.BondEvents.LogIndex.EQ(uint64Expr(logIndex))), + )) + sqlStr, args := sel.Sql() + value, err := scanBondEvent(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return value, err +} + +func (r *PostgresRepository) ListBondEvents( + ctx context.Context, nameOrAddress string, f repository.BondEventFilter, p repository.Pagination, descending bool, +) ([]*BondEvent, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.BondEvents.INNER_JOIN(table.Application, table.BondEvents.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} + if f.EpochIndex != nil { + conditions = append(conditions, table.BondEvents.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + } + if f.TournamentAddress != nil { + conditions = append(conditions, table.BondEvents.TournamentAddress.EQ(postgres.Bytea(f.TournamentAddress.Bytes()))) + } + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + countStmt := table.BondEvents.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil || total == 0 { + return nil, total, err + } + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) + if descending { + sel = sel.ORDER_BY(table.BondEvents.BlockNumber.DESC(), table.BondEvents.LogIndex.DESC(), table.BondEvents.TxHash.DESC()) + } else { + sel = sel.ORDER_BY(table.BondEvents.BlockNumber.ASC(), table.BondEvents.LogIndex.ASC(), table.BondEvents.TxHash.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var values []*BondEvent + for rows.Next() { + value, err := scanBondEvent(rows) + if err != nil { + return nil, 0, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return values, total, nil +} diff --git a/internal/repository/postgres/bulk.go b/internal/repository/postgres/bulk.go index ea207894c..6a15fdbbe 100644 --- a/internal/repository/postgres/bulk.go +++ b/internal/repository/postgres/bulk.go @@ -682,7 +682,13 @@ func updateEpochClaim( return fmt.Errorf("SetEpochClaimAndInsertProofsTransaction failed: %w", err) } if cmd.RowsAffected() != 1 { - return fmt.Errorf("failed to update application %d epoch %d: no rows affected", e.ApplicationID, e.Index) + statusQuery := table.Epoch.SELECT(table.Epoch.Status).WHERE(postgres.AND( + table.Epoch.ApplicationID.EQ(postgres.Int64(e.ApplicationID)), + table.Epoch.Index.EQ(uint64Expr(e.Index)), + )) + statusSQL, statusArgs := statusQuery.Sql() + return fmt.Errorf("failed to update application %d epoch %d: %w", e.ApplicationID, e.Index, + classifyEpochPublicationMiss(tx.QueryRow(ctx, statusSQL, statusArgs...))) } return nil } @@ -752,159 +758,6 @@ func (r *PostgresRepository) StoreClaimAndProofs(ctx context.Context, epoch *mod return tx.Commit(ctx) } -func insertCommitments(ctx context.Context, tx pgx.Tx, appID int64, commitments []*model.Commitment) error { - if len(commitments) < 1 { - return nil - } - - stmt := table.Commitments.INSERT( - table.Commitments.ApplicationID, - table.Commitments.EpochIndex, - table.Commitments.TournamentAddress, - table.Commitments.Commitment, - table.Commitments.FinalStateHash, - table.Commitments.SubmitterAddress, - table.Commitments.BlockNumber, - table.Commitments.TxHash, - ) - for _, c := range commitments { - stmt = stmt.VALUES( - appID, - c.EpochIndex, - c.TournamentAddress, - c.Commitment[:], - c.FinalStateHash[:], - c.SubmitterAddress[:], - c.BlockNumber, - c.TxHash[:], - ) - } - - sqlStr, args := stmt.Sql() - _, err := tx.Exec(ctx, sqlStr, args...) - return err -} - -func insertMatches(ctx context.Context, tx pgx.Tx, appID int64, matches []*model.Match) error { - if len(matches) < 1 { - return nil - } - - stmt := table.Matches.INSERT( - table.Matches.ApplicationID, - table.Matches.EpochIndex, - table.Matches.TournamentAddress, - table.Matches.IDHash, - table.Matches.CommitmentOne, - table.Matches.CommitmentTwo, - table.Matches.LeftOfTwo, - table.Matches.BlockNumber, - table.Matches.TxHash, - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - ) - for _, m := range matches { - stmt = stmt.VALUES( - appID, - m.EpochIndex, - m.TournamentAddress[:], - m.IDHash[:], - m.CommitmentOne[:], - m.CommitmentTwo[:], - m.LeftOfTwo, - m.BlockNumber, - m.TxHash, - m.Winner, - m.DeletionReason, - m.DeletionBlockNumber, - m.DeletionTxHash, - ) - } - - sqlStr, args := stmt.Sql() - _, err := tx.Exec(ctx, sqlStr, args...) - return err -} - -func insertMatchAdvanced(ctx context.Context, tx pgx.Tx, appID int64, matchAdvanced []*model.MatchAdvanced) error { - if len(matchAdvanced) < 1 { - return nil - } - - stmt := table.MatchAdvances.INSERT( - table.MatchAdvances.ApplicationID, - table.MatchAdvances.EpochIndex, - table.MatchAdvances.TournamentAddress, - table.MatchAdvances.IDHash, - table.MatchAdvances.OtherParent, - table.MatchAdvances.LeftNode, - table.MatchAdvances.BlockNumber, - table.MatchAdvances.TxHash, - ) - for _, ma := range matchAdvanced { - stmt = stmt.VALUES( - appID, - ma.EpochIndex, - ma.TournamentAddress, - ma.IDHash, - ma.OtherParent, - ma.LeftNode, - ma.BlockNumber, - ma.TxHash, - ) - } - - sqlStr, args := stmt.Sql() - _, err := tx.Exec(ctx, sqlStr, args...) - return err -} - -func updateMatches(ctx context.Context, tx pgx.Tx, appID int64, matches []*model.Match) error { - if len(matches) == 0 { - return nil - } - - batch := &pgx.Batch{} - for _, m := range matches { - updStmt := table.Matches.UPDATE( - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - ).SET( - m.Winner, - m.DeletionReason, - m.DeletionBlockNumber, - m.DeletionTxHash, - ).WHERE( - table.Matches.ApplicationID.EQ(postgres.Int64(appID)). - AND(table.Matches.EpochIndex.EQ(uint64Expr(m.EpochIndex))). - AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(m.TournamentAddress.Bytes()))). - AND(table.Matches.IDHash.EQ(postgres.Bytea(m.IDHash.Bytes()))), - ) - sqlStr, args := updStmt.Sql() - batch.Queue(sqlStr, args...) - } - - br := tx.SendBatch(ctx, batch) - - for _, m := range matches { - cmd, err := br.Exec() - if err != nil { - br.Close() - return err - } - if cmd.RowsAffected() == 0 { - br.Close() - return fmt.Errorf("no match found for update: app %d, epoch %d, tournament %s, idHash %s", - m.ApplicationID, m.EpochIndex, m.TournamentAddress.Hex(), m.IDHash.Hex()) - } - } - return br.Close() -} - func updateLastProcessedBlock(ctx context.Context, tx pgx.Tx, appID int64, lastProcessedBlock uint64) error { lastBlock := uint64Expr(lastProcessedBlock) appUpdateStmt := table.Application. @@ -923,46 +776,3 @@ func updateLastProcessedBlock(ctx context.Context, tx pgx.Tx, appID int64, lastP _, err := tx.Exec(ctx, sqlStr, args...) return err } - -func (r *PostgresRepository) StoreTournamentEvents( - ctx context.Context, - appID int64, - commitments []*model.Commitment, - matches []*model.Match, - matchAdvanced []*model.MatchAdvanced, - matchDeleted []*model.Match, - lastProcessedBlock uint64, -) error { - tx, err := r.db.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) //nolint:errcheck - - err = insertCommitments(ctx, tx, appID, commitments) - if err != nil { - return err - } - - err = insertMatches(ctx, tx, appID, matches) - if err != nil { - return err - } - - err = insertMatchAdvanced(ctx, tx, appID, matchAdvanced) - if err != nil { - return err - } - - err = updateMatches(ctx, tx, appID, matchDeleted) - if err != nil { - return err - } - - err = updateLastProcessedBlock(ctx, tx, appID, lastProcessedBlock) - if err != nil { - return err - } - - return tx.Commit(ctx) -} diff --git a/internal/repository/postgres/claimer.go b/internal/repository/postgres/claimer.go index faebc34f1..5da7cb917 100644 --- a/internal/repository/postgres/claimer.go +++ b/internal/repository/postgres/claimer.go @@ -53,6 +53,10 @@ func (r *PostgresRepository) selectOldestClaimPerApp( table.Epoch.MachineHash, table.Epoch.TxBufferDataBlock, table.Epoch.TxBufferProof, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, table.Epoch.ClaimTransactionHash, table.Epoch.Status, table.Epoch.StagedAtBlock, @@ -74,7 +78,6 @@ func (r *PostgresRepository) selectOldestClaimPerApp( table.Application.WithdrawalLog2MaxNumOfAccounts, table.Application.WithdrawalAccountsDriveStartIndex, table.Application.WithdrawalOutputBuilder, - table.Application.DataAvailability, table.Application.ConsensusType, table.Application.Enabled, table.Application.Status, @@ -127,6 +130,10 @@ func (r *PostgresRepository) selectOldestClaimPerApp( &epoch.MachineHash, &epoch.TxBufferDataBlock, &epoch.TxBufferProof, + &epoch.IflagsYDataBlock, + &epoch.IflagsYProof, + &epoch.HtifTohostDataBlock, + &epoch.HtifTohostProof, &epoch.ClaimTransactionHash, &epoch.Status, &epoch.StagedAtBlock, @@ -148,7 +155,6 @@ func (r *PostgresRepository) selectOldestClaimPerApp( &application.WithdrawalConfig.Log2MaxNumOfAccounts, &application.WithdrawalConfig.AccountsDriveStartIndex, &application.WithdrawalConfig.WithdrawalOutputBuilder, - &application.DataAvailability, &application.ConsensusType, &application.Enabled, &application.Status, @@ -191,10 +197,10 @@ func (r *PostgresRepository) selectNewestClaimBarrierPerApp( statusExprs = append(statusExprs, postgres.NewEnumValue(status.String())) } - // NOTE(mpolitzer): DISTINCT ON is a postgres extension. To implement - // this in SQLite there is an alternative using GROUP BY and HAVING - // clauses instead. - stmt := table.Epoch.SELECT( + // Select one barrier per eligible application through the epoch primary + // key. Sorting every historical accepted claim makes this query grow with + // the application's full history instead of the number of applications. + barrier := postgres.LATERAL(table.Epoch.SELECT( table.Epoch.ApplicationID, table.Epoch.Index, table.Epoch.FirstBlock, @@ -202,31 +208,25 @@ func (r *PostgresRepository) selectNewestClaimBarrierPerApp( table.Epoch.MachineHash, table.Epoch.TxBufferDataBlock, table.Epoch.TxBufferProof, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, table.Epoch.ClaimTransactionHash, table.Epoch.Status, table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt, - ). - DISTINCT(table.Epoch.ApplicationID). - FROM( - table.Epoch. - INNER_JOIN( - table.Application, - table.Epoch.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - table.Epoch.Status.IN(statusExprs...). - AND(table.Application.Enabled.EQ(postgres.Bool(true))). - AND(claimableOrForeclosedApplication()). - AND(table.Application.ConsensusType.NOT_EQ(enum.Consensus.Prt)), - ). - ORDER_BY( - table.Epoch.ApplicationID, - table.Epoch.Index.DESC(), - ) + ).WHERE( + table.Epoch.ApplicationID.EQ(table.Application.ID). + AND(table.Epoch.Status.IN(statusExprs...)), + ).ORDER_BY(table.Epoch.Index.DESC()).LIMIT(1)).AS("barrier") + stmt := postgres.SELECT(barrier.AllColumns()). + FROM(table.Application.CROSS_JOIN(barrier)). + WHERE(table.Application.Enabled.EQ(postgres.Bool(true)). + AND(claimableOrForeclosedApplication()). + AND(table.Application.ConsensusType.NOT_EQ(enum.Consensus.Prt))) sqlStr, args := stmt.Sql() rows, err := tx.Query(ctx, sqlStr, args...) @@ -246,6 +246,10 @@ func (r *PostgresRepository) selectNewestClaimBarrierPerApp( &epoch.MachineHash, &epoch.TxBufferDataBlock, &epoch.TxBufferProof, + &epoch.IflagsYDataBlock, + &epoch.IflagsYProof, + &epoch.HtifTohostDataBlock, + &epoch.HtifTohostProof, &epoch.ClaimTransactionHash, &epoch.Status, &epoch.StagedAtBlock, @@ -374,10 +378,10 @@ func (r *PostgresRepository) UpdateEpochWithSubmittedClaim( // source state may be CLAIM_SUBMITTED, CLAIM_STAGED, or CLAIM_COMPUTED — the // trigger enforces validity per the v3 state machine: // -// - CLAIM_STAGED → CLAIM_ACCEPTED is the normal v3 path (after the staging -// period elapses and acceptClaim is called). -// - CLAIM_COMPUTED → CLAIM_ACCEPTED is the deep reader-mode catch-up path -// (also PRT's terminal transition; the trigger forbids PRT from STAGED). +// - CLAIM_STAGED → CLAIM_ACCEPTED is the normal v3 path after the staging +// period elapses and the consensus accepts the staged result. +// - CLAIM_COMPUTED → CLAIM_ACCEPTED is the deep reader-mode catch-up path, +// including a PRT node that first observes an already accepted result. // - CLAIM_SUBMITTED → CLAIM_ACCEPTED is permitted by the trigger but not // reached by the v3 happy path. Kept for resilience. // @@ -455,14 +459,7 @@ func (r *PostgresRepository) UpdateEpochWithForeclosedClaim( WHERE( table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). AND(table.Epoch.Index.EQ(uint64Expr(index))). - AND(table.Epoch.Status.IN( - postgres.NewEnumValue(model.EpochStatus_Open.String()), - postgres.NewEnumValue(model.EpochStatus_Closed.String()), - postgres.NewEnumValue(model.EpochStatus_InputsProcessed.String()), - postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()), - postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()), - postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), - )). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions()...)). AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). AND(table.Application.ForecloseBlock.GT(uint64Expr(0))), ) @@ -698,8 +695,9 @@ func (r *PostgresRepository) UpdateEpochThroughStaging( // UpdateEpochReconciledStaged transitions an epoch from CLAIM_COMPUTED to // CLAIM_STAGED without setting a claim_transaction_hash. Used by the -// pre-submit reconciliation path when getClaim() reveals the chain has -// already staged our claim (e.g., across a restart or in reader mode). +// chain reconciliation path when the chain has already staged the local +// result. Authority/Quorum and PRT can use this path after a restart or in +// reader mode. func (r *PostgresRepository) UpdateEpochReconciledStaged( ctx context.Context, applicationID int64, diff --git a/internal/repository/postgres/commitment.go b/internal/repository/postgres/commitment.go index 58a0ba9a7..7c6fb9537 100644 --- a/internal/repository/postgres/commitment.go +++ b/internal/repository/postgres/commitment.go @@ -5,231 +5,105 @@ package postgres import ( "context" - "database/sql" "errors" + "fmt" + "math" - "github.com/ethereum/go-ethereum/common" - "github.com/go-jet/jet/v2/postgres" - - "github.com/cartesi/rollups-node/internal/model" + . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" ) -// ------------------------ CommitmentRepository Methods ------------------------ // - -func (r *PostgresRepository) CreateCommitment( - ctx context.Context, - nameOrAddress string, - c *model.Commitment, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - selectQuery := table.Application.SELECT( - table.Application.ID, - uint64Expr(c.EpochIndex), - postgres.Bytea(c.TournamentAddress.Bytes()), - postgres.Bytea(c.Commitment.Bytes()), - postgres.Bytea(c.FinalStateHash.Bytes()), - postgres.Bytea(c.SubmitterAddress.Bytes()), - uint64Expr(c.BlockNumber), - postgres.Bytea(c.TxHash.Bytes()), - ).WHERE( - whereClause, - ) - - insertStmt := table.Commitments.INSERT( - table.Commitments.ApplicationID, - table.Commitments.EpochIndex, - table.Commitments.TournamentAddress, - table.Commitments.Commitment, - table.Commitments.FinalStateHash, - table.Commitments.SubmitterAddress, - table.Commitments.BlockNumber, - table.Commitments.TxHash, - ).QUERY( - selectQuery, - ) - - sqlStr, args := insertStmt.Sql() +func (r *PostgresRepository) CreateCommitment(ctx context.Context, nameOrAddress string, value *Commitment) error { + if value == nil { + return fmt.Errorf("cannot create a nil commitment") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := commitmentValues(value) + values[0] = applicationID + stmt := table.Commitments.INSERT(commitmentColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() _, err := r.db.Exec(ctx, sqlStr, args...) - return err } func (r *PostgresRepository) GetCommitment( - ctx context.Context, - nameOrAddress string, - epochIndex uint64, - tournamentAddress string, - commitmentHex string, -) (*model.Commitment, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - tournamentAddr := common.HexToAddress(tournamentAddress) - commitment := common.HexToHash(commitmentHex) - - sel := table.Commitments. - SELECT( - table.Commitments.ApplicationID, - table.Commitments.EpochIndex, - table.Commitments.TournamentAddress, - table.Commitments.Commitment, - table.Commitments.FinalStateHash, - table.Commitments.SubmitterAddress, - table.Commitments.BlockNumber, - table.Commitments.TxHash, - table.Commitments.CreatedAt, - table.Commitments.UpdatedAt, - ). - FROM( - table.Commitments. - INNER_JOIN(table.Application, - table.Commitments.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - whereClause. - AND(table.Commitments.EpochIndex.EQ(uint64Expr(epochIndex))). - AND(table.Commitments.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))). - AND(table.Commitments.Commitment.EQ(postgres.Bytea(commitment.Bytes()))), - ) - + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, commitmentHex string, +) (*Commitment, error) { + sel := table.Commitments.SELECT(commitmentColumns, table.Commitments.CreatedAt, table.Commitments.UpdatedAt). + FROM(table.Commitments.INNER_JOIN(table.Application, table.Commitments.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Commitments.EpochIndex.EQ(uint64Expr(epochIndex)). + AND(table.Commitments.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes()))). + AND(table.Commitments.Commitment.EQ(postgres.Bytea(common.HexToHash(commitmentHex).Bytes()))), + )) sqlStr, args := sel.Sql() - row := r.db.QueryRow(ctx, sqlStr, args...) - - var c model.Commitment - err := row.Scan( - &c.ApplicationID, - &c.EpochIndex, - &c.TournamentAddress, - &c.Commitment, - &c.FinalStateHash, - &c.SubmitterAddress, - &c.BlockNumber, - &c.TxHash, - &c.CreatedAt, - &c.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { + value, err := scanCommitment(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - if err != nil { - return nil, err - } - return &c, nil + return value, err } func (r *PostgresRepository) ListCommitments( - ctx context.Context, - nameOrAddress string, - f repository.CommitmentFilter, - p repository.Pagination, - descending bool, -) ([]*model.Commitment, uint64, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - fromClause := table.Commitments. - INNER_JOIN(table.Application, - table.Commitments.ApplicationID.EQ(table.Application.ID), - ) - - conditions := []postgres.BoolExpression{whereClause} + ctx context.Context, nameOrAddress string, f repository.CommitmentFilter, p repository.Pagination, descending bool, +) ([]*Commitment, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Commitments.INNER_JOIN(table.Application, table.Commitments.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} if f.EpochIndex != nil { conditions = append(conditions, table.Commitments.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) } if f.TournamentAddress != nil { - tournamentAddr := common.HexToAddress(*f.TournamentAddress) - conditions = append(conditions, table.Commitments.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))) + conditions = append(conditions, + table.Commitments.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(*f.TournamentAddress).Bytes()))) } - tx, err := beginReadTx(ctx, r.db) if err != nil { return nil, 0, err } defer tx.Rollback(ctx) //nolint:errcheck - - countStmt := table.Commitments.SELECT(postgres.COUNT(postgres.STAR)). - FROM(fromClause).WHERE(postgres.AND(conditions...)) + countStmt := table.Commitments.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) total, err := countFromTx(ctx, tx, countStmt) - if err != nil { - return nil, 0, err - } - if total == 0 { - return nil, 0, nil + if err != nil || total == 0 { + return nil, total, err } - - sel := table.Commitments. - SELECT( - table.Commitments.ApplicationID, - table.Commitments.EpochIndex, - table.Commitments.TournamentAddress, - table.Commitments.Commitment, - table.Commitments.FinalStateHash, - table.Commitments.SubmitterAddress, - table.Commitments.BlockNumber, - table.Commitments.TxHash, - table.Commitments.CreatedAt, - table.Commitments.UpdatedAt, - ). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + sel := table.Commitments.SELECT(commitmentColumns, table.Commitments.CreatedAt, table.Commitments.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) if descending { sel = sel.ORDER_BY( - table.Commitments.ApplicationID.DESC(), - table.Commitments.EpochIndex.DESC(), - table.Commitments.TournamentAddress.DESC(), - table.Commitments.Commitment.DESC()) + table.Commitments.EpochIndex.DESC(), table.Commitments.TournamentAddress.DESC(), table.Commitments.Commitment.DESC()) } else { sel = sel.ORDER_BY( - table.Commitments.ApplicationID.ASC(), - table.Commitments.EpochIndex.ASC(), - table.Commitments.TournamentAddress.ASC(), - table.Commitments.Commitment.ASC()) + table.Commitments.EpochIndex.ASC(), table.Commitments.TournamentAddress.ASC(), table.Commitments.Commitment.ASC()) } - - // Apply pagination if p.Limit > 0 { sel = sel.LIMIT(int64(p.Limit)) } if p.Offset > 0 { sel = sel.OFFSET(int64(p.Offset)) } - sqlStr, args := sel.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { return nil, 0, err } defer rows.Close() - - var commitments []*model.Commitment + var values []*Commitment for rows.Next() { - var c model.Commitment - err := rows.Scan( - &c.ApplicationID, - &c.EpochIndex, - &c.TournamentAddress, - &c.Commitment, - &c.FinalStateHash, - &c.SubmitterAddress, - &c.BlockNumber, - &c.TxHash, - &c.CreatedAt, - &c.UpdatedAt, - ) + value, err := scanCommitment(rows) if err != nil { return nil, 0, err } - commitments = append(commitments, &c) + values = append(values, value) } if err := rows.Err(); err != nil { return nil, 0, err } - - return commitments, total, nil + return values, total, nil } diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go b/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go new file mode 100644 index 000000000..809e4b5d4 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var BondDisposition = &struct { + TournamentRunning postgres.StringExpression + NoWinner postgres.StringExpression + Recoverable postgres.StringExpression + Recovered postgres.StringExpression +}{ + TournamentRunning: postgres.NewEnumValue("TOURNAMENT_RUNNING"), + NoWinner: postgres.NewEnumValue("NO_WINNER"), + Recoverable: postgres.NewEnumValue("RECOVERABLE"), + Recovered: postgres.NewEnumValue("RECOVERED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go b/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go new file mode 100644 index 000000000..aed36dcd2 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var BondEventType = &struct { + PartialBondRefund postgres.StringExpression + BondRecovered postgres.StringExpression +}{ + PartialBondRefund: postgres.NewEnumValue("PARTIAL_BOND_REFUND"), + BondRecovered: postgres.NewEnumValue("BOND_RECOVERED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go b/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go new file mode 100644 index 000000000..2732af031 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var CommitmentSide = &struct { + One postgres.StringExpression + Two postgres.StringExpression +}{ + One: postgres.NewEnumValue("ONE"), + Two: postgres.NewEnumValue("TWO"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go b/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go new file mode 100644 index 000000000..09089eb1e --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go @@ -0,0 +1,20 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var InnerTournamentDisposition = &struct { + Unsettled postgres.StringExpression + Winner postgres.StringExpression + Eliminable postgres.StringExpression +}{ + Unsettled: postgres.NewEnumValue("UNSETTLED"), + Winner: postgres.NewEnumValue("WINNER"), + Eliminable: postgres.NewEnumValue("ELIMINABLE"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go b/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go new file mode 100644 index 000000000..204a1ba82 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var MatchPhase = &struct { + Uninitialized postgres.StringExpression + Bisecting postgres.StringExpression + ReadyToSeal postgres.StringExpression + Sealed postgres.StringExpression +}{ + Uninitialized: postgres.NewEnumValue("UNINITIALIZED"), + Bisecting: postgres.NewEnumValue("BISECTING"), + ReadyToSeal: postgres.NewEnumValue("READY_TO_SEAL"), + Sealed: postgres.NewEnumValue("SEALED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go b/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go new file mode 100644 index 000000000..2dd230bee --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var MatchTimeoutOutcome = &struct { + None postgres.StringExpression + OneWins postgres.StringExpression + TwoWins postgres.StringExpression + EliminateBoth postgres.StringExpression +}{ + None: postgres.NewEnumValue("NONE"), + OneWins: postgres.NewEnumValue("ONE_WINS"), + TwoWins: postgres.NewEnumValue("TWO_WINS"), + EliminateBoth: postgres.NewEnumValue("ELIMINATE_BOTH"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go new file mode 100644 index 000000000..20b2f5107 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var TournamentKind = &struct { + Leaf postgres.StringExpression + NonLeaf postgres.StringExpression +}{ + Leaf: postgres.NewEnumValue("LEAF"), + NonLeaf: postgres.NewEnumValue("NON_LEAF"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go new file mode 100644 index 000000000..483feed12 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go @@ -0,0 +1,28 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var TournamentStandingState = &struct { + MatchesActive postgres.StringExpression + AwaitingClosure postgres.StringExpression + RootWinner postgres.StringExpression + RootFailed postgres.StringExpression + InnerWinner postgres.StringExpression + InnerEliminableNoWinner postgres.StringExpression + InnerEliminableWinnerExpired postgres.StringExpression +}{ + MatchesActive: postgres.NewEnumValue("MATCHES_ACTIVE"), + AwaitingClosure: postgres.NewEnumValue("AWAITING_CLOSURE"), + RootWinner: postgres.NewEnumValue("ROOT_WINNER"), + RootFailed: postgres.NewEnumValue("ROOT_FAILED"), + InnerWinner: postgres.NewEnumValue("INNER_WINNER"), + InnerEliminableNoWinner: postgres.NewEnumValue("INNER_ELIMINABLE_NO_WINNER"), + InnerEliminableWinnerExpired: postgres.NewEnumValue("INNER_ELIMINABLE_WINNER_EXPIRED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/application.go b/internal/repository/postgres/db/rollupsdb/public/table/application.go index 8aa5aa118..34217d390 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/application.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/application.go @@ -32,7 +32,6 @@ type applicationTable struct { WithdrawalLog2MaxNumOfAccounts postgres.ColumnInteger WithdrawalAccountsDriveStartIndex postgres.ColumnFloat WithdrawalOutputBuilder postgres.ColumnBytea - DataAvailability postgres.ColumnBytea ConsensusType postgres.ColumnString Enabled postgres.ColumnBool Status postgres.ColumnString @@ -108,7 +107,6 @@ func newApplicationTableImpl(schemaName, tableName, alias string) applicationTab WithdrawalLog2MaxNumOfAccountsColumn = postgres.IntegerColumn("withdrawal_log2_max_num_of_accounts") WithdrawalAccountsDriveStartIndexColumn = postgres.FloatColumn("withdrawal_accounts_drive_start_index") WithdrawalOutputBuilderColumn = postgres.ByteaColumn("withdrawal_output_builder") - DataAvailabilityColumn = postgres.ByteaColumn("data_availability") ConsensusTypeColumn = postgres.StringColumn("consensus_type") EnabledColumn = postgres.BoolColumn("enabled") StatusColumn = postgres.StringColumn("status") @@ -128,8 +126,8 @@ func newApplicationTableImpl(schemaName, tableName, alias string) applicationTab AccountsDriveMerkleRootColumn = postgres.ByteaColumn("accounts_drive_merkle_root") CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{IDColumn, NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, DataAvailabilityColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, DataAvailabilityColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} + allColumns = postgres.ColumnList{IDColumn, NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} defaultColumns = postgres.ColumnList{ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, EnabledColumn, StatusColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ForecloseBlockColumn, AccountsDriveProvedBlockColumn, CreatedAtColumn, UpdatedAtColumn} ) @@ -152,7 +150,6 @@ func newApplicationTableImpl(schemaName, tableName, alias string) applicationTab WithdrawalLog2MaxNumOfAccounts: WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndex: WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilder: WithdrawalOutputBuilderColumn, - DataAvailability: DataAvailabilityColumn, ConsensusType: ConsensusTypeColumn, Enabled: EnabledColumn, Status: StatusColumn, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go b/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go new file mode 100644 index 000000000..2da37d50d --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go @@ -0,0 +1,123 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var BondEvents = newBondEventsTable("public", "bond_events", "") + +type bondEventsTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + Type postgres.ColumnString + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + Recipient postgres.ColumnBytea + Value postgres.ColumnFloat + Success postgres.ColumnBool + Commitment postgres.ColumnBytea + Claimer postgres.ColumnBytea + Payment postgres.ColumnFloat + Burned postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type BondEventsTable struct { + bondEventsTable + + EXCLUDED bondEventsTable +} + +// AS creates new BondEventsTable with assigned alias +func (a BondEventsTable) AS(alias string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new BondEventsTable with assigned schema name +func (a BondEventsTable) FromSchema(schemaName string) *BondEventsTable { + return newBondEventsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new BondEventsTable with assigned table prefix +func (a BondEventsTable) WithPrefix(prefix string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new BondEventsTable with assigned table suffix +func (a BondEventsTable) WithSuffix(suffix string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newBondEventsTable(schemaName, tableName, alias string) *BondEventsTable { + return &BondEventsTable{ + bondEventsTable: newBondEventsTableImpl(schemaName, tableName, alias), + EXCLUDED: newBondEventsTableImpl("", "excluded", ""), + } +} + +func newBondEventsTableImpl(schemaName, tableName, alias string) bondEventsTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + TypeColumn = postgres.StringColumn("type") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + RecipientColumn = postgres.ByteaColumn("recipient") + ValueColumn = postgres.FloatColumn("value") + SuccessColumn = postgres.BoolColumn("success") + CommitmentColumn = postgres.ByteaColumn("commitment") + ClaimerColumn = postgres.ByteaColumn("claimer") + PaymentColumn = postgres.FloatColumn("payment") + BurnedColumn = postgres.FloatColumn("burned") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, TypeColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, RecipientColumn, ValueColumn, SuccessColumn, CommitmentColumn, ClaimerColumn, PaymentColumn, BurnedColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{EpochIndexColumn, TournamentAddressColumn, TypeColumn, BlockNumberColumn, RecipientColumn, ValueColumn, SuccessColumn, CommitmentColumn, ClaimerColumn, PaymentColumn, BurnedColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return bondEventsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + Type: TypeColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + Recipient: RecipientColumn, + Value: ValueColumn, + Success: SuccessColumn, + Commitment: CommitmentColumn, + Claimer: ClaimerColumn, + Payment: PaymentColumn, + Burned: BurnedColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/commitments.go b/internal/repository/postgres/db/rollupsdb/public/table/commitments.go index 6f2aec2fd..2b55d01dc 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/commitments.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/commitments.go @@ -25,6 +25,12 @@ type commitmentsTable struct { SubmitterAddress postgres.ColumnBytea BlockNumber postgres.ColumnFloat TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Claimer postgres.ColumnBytea + ClockRunning postgres.ColumnBool + ClockDeadline postgres.ColumnFloat + ClockAllowance postgres.ColumnFloat CreatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz @@ -76,10 +82,16 @@ func newCommitmentsTableImpl(schemaName, tableName, alias string) commitmentsTab SubmitterAddressColumn = postgres.ByteaColumn("submitter_address") BlockNumberColumn = postgres.FloatColumn("block_number") TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + ClaimerColumn = postgres.ByteaColumn("claimer") + ClockRunningColumn = postgres.BoolColumn("clock_running") + ClockDeadlineColumn = postgres.FloatColumn("clock_deadline") + ClockAllowanceColumn = postgres.FloatColumn("clock_allowance") CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, CommitmentColumn, FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, CreatedAtColumn, UpdatedAtColumn} + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, CommitmentColumn, FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, AsOfBlockColumn, ClaimerColumn, ClockRunningColumn, ClockDeadlineColumn, ClockAllowanceColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, AsOfBlockColumn, ClaimerColumn, ClockRunningColumn, ClockDeadlineColumn, ClockAllowanceColumn, CreatedAtColumn, UpdatedAtColumn} defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} ) @@ -95,6 +107,12 @@ func newCommitmentsTableImpl(schemaName, tableName, alias string) commitmentsTab SubmitterAddress: SubmitterAddressColumn, BlockNumber: BlockNumberColumn, TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Claimer: ClaimerColumn, + ClockRunning: ClockRunningColumn, + ClockDeadline: ClockDeadlineColumn, + ClockAllowance: ClockAllowanceColumn, CreatedAt: CreatedAtColumn, UpdatedAt: UpdatedAtColumn, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go b/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go index b31317d96..db20d41cd 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go @@ -17,16 +17,19 @@ type matchAdvancesTable struct { postgres.Table // Columns - ApplicationID postgres.ColumnInteger - EpochIndex postgres.ColumnFloat - TournamentAddress postgres.ColumnBytea - IDHash postgres.ColumnBytea - OtherParent postgres.ColumnBytea - LeftNode postgres.ColumnBytea - BlockNumber postgres.ColumnFloat - TxHash postgres.ColumnBytea - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + IDHash postgres.ColumnBytea + OtherParent postgres.ColumnBytea + LeftNode postgres.ColumnBytea + SegmentStartPosition postgres.ColumnFloat + EliminableAt postgres.ColumnFloat + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -68,35 +71,41 @@ func newMatchAdvancesTable(schemaName, tableName, alias string) *MatchAdvancesTa func newMatchAdvancesTableImpl(schemaName, tableName, alias string) matchAdvancesTable { var ( - ApplicationIDColumn = postgres.IntegerColumn("application_id") - EpochIndexColumn = postgres.FloatColumn("epoch_index") - TournamentAddressColumn = postgres.ByteaColumn("tournament_address") - IDHashColumn = postgres.ByteaColumn("id_hash") - OtherParentColumn = postgres.ByteaColumn("other_parent") - LeftNodeColumn = postgres.ByteaColumn("left_node") - BlockNumberColumn = postgres.FloatColumn("block_number") - TxHashColumn = postgres.ByteaColumn("tx_hash") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, OtherParentColumn, LeftNodeColumn, BlockNumberColumn, TxHashColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{LeftNodeColumn, BlockNumberColumn, TxHashColumn, CreatedAtColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + IDHashColumn = postgres.ByteaColumn("id_hash") + OtherParentColumn = postgres.ByteaColumn("other_parent") + LeftNodeColumn = postgres.ByteaColumn("left_node") + SegmentStartPositionColumn = postgres.FloatColumn("segment_start_position") + EliminableAtColumn = postgres.FloatColumn("eliminable_at") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, OtherParentColumn, LeftNodeColumn, SegmentStartPositionColumn, EliminableAtColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{EpochIndexColumn, TournamentAddressColumn, IDHashColumn, OtherParentColumn, LeftNodeColumn, SegmentStartPositionColumn, EliminableAtColumn, BlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} ) return matchAdvancesTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - ApplicationID: ApplicationIDColumn, - EpochIndex: EpochIndexColumn, - TournamentAddress: TournamentAddressColumn, - IDHash: IDHashColumn, - OtherParent: OtherParentColumn, - LeftNode: LeftNodeColumn, - BlockNumber: BlockNumberColumn, - TxHash: TxHashColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + IDHash: IDHashColumn, + OtherParent: OtherParentColumn, + LeftNode: LeftNodeColumn, + SegmentStartPosition: SegmentStartPositionColumn, + EliminableAt: EliminableAtColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/matches.go b/internal/repository/postgres/db/rollupsdb/public/table/matches.go index 4b854d27e..8986e4688 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/matches.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/matches.go @@ -17,21 +17,44 @@ type matchesTable struct { postgres.Table // Columns - ApplicationID postgres.ColumnInteger - EpochIndex postgres.ColumnFloat - TournamentAddress postgres.ColumnBytea - IDHash postgres.ColumnBytea - CommitmentOne postgres.ColumnBytea - CommitmentTwo postgres.ColumnBytea - LeftOfTwo postgres.ColumnBytea - BlockNumber postgres.ColumnFloat - TxHash postgres.ColumnBytea - Winner postgres.ColumnString - DeletionReason postgres.ColumnString - DeletionBlockNumber postgres.ColumnFloat - DeletionTxHash postgres.ColumnBytea - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + IDHash postgres.ColumnBytea + CommitmentOne postgres.ColumnBytea + CommitmentTwo postgres.ColumnBytea + LeftOfTwo postgres.ColumnBytea + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + EliminableAt postgres.ColumnFloat + SealEliminableAt postgres.ColumnFloat + SealBlockNumber postgres.ColumnFloat + SealTxHash postgres.ColumnBytea + SealLogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Phase postgres.ColumnString + TimeoutOutcome postgres.ColumnString + DeferredCharge postgres.ColumnFloat + RevealingParent postgres.ColumnBytea + WaitingLeft postgres.ColumnBytea + WaitingRight postgres.ColumnBytea + SegmentStartPosition postgres.ColumnFloat + SegmentStartCycle postgres.ColumnFloat + CurrentHeight postgres.ColumnFloat + Responder postgres.ColumnString + AgreeState postgres.ColumnBytea + DivergencePosition postgres.ColumnFloat + DivergenceCycle postgres.ColumnFloat + FinalStateOne postgres.ColumnBytea + FinalStateTwo postgres.ColumnBytea + Winner postgres.ColumnString + DeletionReason postgres.ColumnString + DeletionBlockNumber postgres.ColumnFloat + DeletionTxHash postgres.ColumnBytea + DeletionLogIndex postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -73,45 +96,91 @@ func newMatchesTable(schemaName, tableName, alias string) *MatchesTable { func newMatchesTableImpl(schemaName, tableName, alias string) matchesTable { var ( - ApplicationIDColumn = postgres.IntegerColumn("application_id") - EpochIndexColumn = postgres.FloatColumn("epoch_index") - TournamentAddressColumn = postgres.ByteaColumn("tournament_address") - IDHashColumn = postgres.ByteaColumn("id_hash") - CommitmentOneColumn = postgres.ByteaColumn("commitment_one") - CommitmentTwoColumn = postgres.ByteaColumn("commitment_two") - LeftOfTwoColumn = postgres.ByteaColumn("left_of_two") - BlockNumberColumn = postgres.FloatColumn("block_number") - TxHashColumn = postgres.ByteaColumn("tx_hash") - WinnerColumn = postgres.StringColumn("winner") - DeletionReasonColumn = postgres.StringColumn("deletion_reason") - DeletionBlockNumberColumn = postgres.FloatColumn("deletion_block_number") - DeletionTxHashColumn = postgres.ByteaColumn("deletion_tx_hash") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, CreatedAtColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{DeletionBlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + IDHashColumn = postgres.ByteaColumn("id_hash") + CommitmentOneColumn = postgres.ByteaColumn("commitment_one") + CommitmentTwoColumn = postgres.ByteaColumn("commitment_two") + LeftOfTwoColumn = postgres.ByteaColumn("left_of_two") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + EliminableAtColumn = postgres.FloatColumn("eliminable_at") + SealEliminableAtColumn = postgres.FloatColumn("seal_eliminable_at") + SealBlockNumberColumn = postgres.FloatColumn("seal_block_number") + SealTxHashColumn = postgres.ByteaColumn("seal_tx_hash") + SealLogIndexColumn = postgres.FloatColumn("seal_log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + PhaseColumn = postgres.StringColumn("phase") + TimeoutOutcomeColumn = postgres.StringColumn("timeout_outcome") + DeferredChargeColumn = postgres.FloatColumn("deferred_charge") + RevealingParentColumn = postgres.ByteaColumn("revealing_parent") + WaitingLeftColumn = postgres.ByteaColumn("waiting_left") + WaitingRightColumn = postgres.ByteaColumn("waiting_right") + SegmentStartPositionColumn = postgres.FloatColumn("segment_start_position") + SegmentStartCycleColumn = postgres.FloatColumn("segment_start_cycle") + CurrentHeightColumn = postgres.FloatColumn("current_height") + ResponderColumn = postgres.StringColumn("responder") + AgreeStateColumn = postgres.ByteaColumn("agree_state") + DivergencePositionColumn = postgres.FloatColumn("divergence_position") + DivergenceCycleColumn = postgres.FloatColumn("divergence_cycle") + FinalStateOneColumn = postgres.ByteaColumn("final_state_one") + FinalStateTwoColumn = postgres.ByteaColumn("final_state_two") + WinnerColumn = postgres.StringColumn("winner") + DeletionReasonColumn = postgres.StringColumn("deletion_reason") + DeletionBlockNumberColumn = postgres.FloatColumn("deletion_block_number") + DeletionTxHashColumn = postgres.ByteaColumn("deletion_tx_hash") + DeletionLogIndexColumn = postgres.FloatColumn("deletion_log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, EliminableAtColumn, SealEliminableAtColumn, SealBlockNumberColumn, SealTxHashColumn, SealLogIndexColumn, AsOfBlockColumn, PhaseColumn, TimeoutOutcomeColumn, DeferredChargeColumn, RevealingParentColumn, WaitingLeftColumn, WaitingRightColumn, SegmentStartPositionColumn, SegmentStartCycleColumn, CurrentHeightColumn, ResponderColumn, AgreeStateColumn, DivergencePositionColumn, DivergenceCycleColumn, FinalStateOneColumn, FinalStateTwoColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, DeletionLogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, EliminableAtColumn, SealEliminableAtColumn, SealBlockNumberColumn, SealTxHashColumn, SealLogIndexColumn, AsOfBlockColumn, PhaseColumn, TimeoutOutcomeColumn, DeferredChargeColumn, RevealingParentColumn, WaitingLeftColumn, WaitingRightColumn, SegmentStartPositionColumn, SegmentStartCycleColumn, CurrentHeightColumn, ResponderColumn, AgreeStateColumn, DivergencePositionColumn, DivergenceCycleColumn, FinalStateOneColumn, FinalStateTwoColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, DeletionLogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{DeletionBlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} ) return matchesTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - ApplicationID: ApplicationIDColumn, - EpochIndex: EpochIndexColumn, - TournamentAddress: TournamentAddressColumn, - IDHash: IDHashColumn, - CommitmentOne: CommitmentOneColumn, - CommitmentTwo: CommitmentTwoColumn, - LeftOfTwo: LeftOfTwoColumn, - BlockNumber: BlockNumberColumn, - TxHash: TxHashColumn, - Winner: WinnerColumn, - DeletionReason: DeletionReasonColumn, - DeletionBlockNumber: DeletionBlockNumberColumn, - DeletionTxHash: DeletionTxHashColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + IDHash: IDHashColumn, + CommitmentOne: CommitmentOneColumn, + CommitmentTwo: CommitmentTwoColumn, + LeftOfTwo: LeftOfTwoColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + EliminableAt: EliminableAtColumn, + SealEliminableAt: SealEliminableAtColumn, + SealBlockNumber: SealBlockNumberColumn, + SealTxHash: SealTxHashColumn, + SealLogIndex: SealLogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Phase: PhaseColumn, + TimeoutOutcome: TimeoutOutcomeColumn, + DeferredCharge: DeferredChargeColumn, + RevealingParent: RevealingParentColumn, + WaitingLeft: WaitingLeftColumn, + WaitingRight: WaitingRightColumn, + SegmentStartPosition: SegmentStartPositionColumn, + SegmentStartCycle: SegmentStartCycleColumn, + CurrentHeight: CurrentHeightColumn, + Responder: ResponderColumn, + AgreeState: AgreeStateColumn, + DivergencePosition: DivergencePositionColumn, + DivergenceCycle: DivergenceCycleColumn, + FinalStateOne: FinalStateOneColumn, + FinalStateTwo: FinalStateTwoColumn, + Winner: WinnerColumn, + DeletionReason: DeletionReasonColumn, + DeletionBlockNumber: DeletionBlockNumberColumn, + DeletionTxHash: DeletionTxHashColumn, + DeletionLogIndex: DeletionLogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go b/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go index 93fa66ad1..a2000afc3 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go @@ -11,6 +11,7 @@ package table // this method only once at the beginning of the program. func UseSchema(schema string) { Application = Application.FromSchema(schema) + BondEvents = BondEvents.FromSchema(schema) Commitments = Commitments.FromSchema(schema) Epoch = Epoch.FromSchema(schema) ExecutionParameters = ExecutionParameters.FromSchema(schema) diff --git a/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go b/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go index 7f7a2507e..18d56ab2c 100644 --- a/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go +++ b/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go @@ -26,9 +26,29 @@ type tournamentsTable struct { Level postgres.ColumnInteger Log2step postgres.ColumnInteger Height postgres.ColumnInteger + InitialHash postgres.ColumnBytea + BaseCycle postgres.ColumnFloat + Kind postgres.ColumnString + StartInstant postgres.ColumnFloat + Allowance postgres.ColumnFloat + CreationBlockNumber postgres.ColumnFloat + CreationTxHash postgres.ColumnBytea + CreationLogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Standing postgres.ColumnString + AcceptsJoins postgres.ColumnBool + Candidate postgres.ColumnBytea WinnerCommitment postgres.ColumnBytea FinalStateHash postgres.ColumnBytea FinishedAtBlock postgres.ColumnFloat + ParentCommitment postgres.ColumnBytea + WinnerExpiresAt postgres.ColumnFloat + InnerDisposition postgres.ColumnString + InnerParentCommitment postgres.ColumnBytea + InnerPausedAllowance postgres.ColumnFloat + BondDisposition postgres.ColumnString + BondClaimer postgres.ColumnBytea + BondPayment postgres.ColumnFloat CreatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz @@ -81,13 +101,33 @@ func newTournamentsTableImpl(schemaName, tableName, alias string) tournamentsTab LevelColumn = postgres.IntegerColumn("level") Log2stepColumn = postgres.IntegerColumn("log2step") HeightColumn = postgres.IntegerColumn("height") + InitialHashColumn = postgres.ByteaColumn("initial_hash") + BaseCycleColumn = postgres.FloatColumn("base_cycle") + KindColumn = postgres.StringColumn("kind") + StartInstantColumn = postgres.FloatColumn("start_instant") + AllowanceColumn = postgres.FloatColumn("allowance") + CreationBlockNumberColumn = postgres.FloatColumn("creation_block_number") + CreationTxHashColumn = postgres.ByteaColumn("creation_tx_hash") + CreationLogIndexColumn = postgres.FloatColumn("creation_log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + StandingColumn = postgres.StringColumn("standing") + AcceptsJoinsColumn = postgres.BoolColumn("accepts_joins") + CandidateColumn = postgres.ByteaColumn("candidate") WinnerCommitmentColumn = postgres.ByteaColumn("winner_commitment") FinalStateHashColumn = postgres.ByteaColumn("final_state_hash") FinishedAtBlockColumn = postgres.FloatColumn("finished_at_block") + ParentCommitmentColumn = postgres.ByteaColumn("parent_commitment") + WinnerExpiresAtColumn = postgres.FloatColumn("winner_expires_at") + InnerDispositionColumn = postgres.StringColumn("inner_disposition") + InnerParentCommitmentColumn = postgres.ByteaColumn("inner_parent_commitment") + InnerPausedAllowanceColumn = postgres.FloatColumn("inner_paused_allowance") + BondDispositionColumn = postgres.StringColumn("bond_disposition") + BondClaimerColumn = postgres.ByteaColumn("bond_claimer") + BondPaymentColumn = postgres.FloatColumn("bond_payment") CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, AddressColumn, ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, CreatedAtColumn, UpdatedAtColumn} + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, AddressColumn, ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, InitialHashColumn, BaseCycleColumn, KindColumn, StartInstantColumn, AllowanceColumn, CreationBlockNumberColumn, CreationTxHashColumn, CreationLogIndexColumn, AsOfBlockColumn, StandingColumn, AcceptsJoinsColumn, CandidateColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, ParentCommitmentColumn, WinnerExpiresAtColumn, InnerDispositionColumn, InnerParentCommitmentColumn, InnerPausedAllowanceColumn, BondDispositionColumn, BondClaimerColumn, BondPaymentColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, InitialHashColumn, BaseCycleColumn, KindColumn, StartInstantColumn, AllowanceColumn, CreationBlockNumberColumn, CreationTxHashColumn, CreationLogIndexColumn, AsOfBlockColumn, StandingColumn, AcceptsJoinsColumn, CandidateColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, ParentCommitmentColumn, WinnerExpiresAtColumn, InnerDispositionColumn, InnerParentCommitmentColumn, InnerPausedAllowanceColumn, BondDispositionColumn, BondClaimerColumn, BondPaymentColumn, CreatedAtColumn, UpdatedAtColumn} defaultColumns = postgres.ColumnList{FinishedAtBlockColumn, CreatedAtColumn, UpdatedAtColumn} ) @@ -104,9 +144,29 @@ func newTournamentsTableImpl(schemaName, tableName, alias string) tournamentsTab Level: LevelColumn, Log2step: Log2stepColumn, Height: HeightColumn, + InitialHash: InitialHashColumn, + BaseCycle: BaseCycleColumn, + Kind: KindColumn, + StartInstant: StartInstantColumn, + Allowance: AllowanceColumn, + CreationBlockNumber: CreationBlockNumberColumn, + CreationTxHash: CreationTxHashColumn, + CreationLogIndex: CreationLogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Standing: StandingColumn, + AcceptsJoins: AcceptsJoinsColumn, + Candidate: CandidateColumn, WinnerCommitment: WinnerCommitmentColumn, FinalStateHash: FinalStateHashColumn, FinishedAtBlock: FinishedAtBlockColumn, + ParentCommitment: ParentCommitmentColumn, + WinnerExpiresAt: WinnerExpiresAtColumn, + InnerDisposition: InnerDispositionColumn, + InnerParentCommitment: InnerParentCommitmentColumn, + InnerPausedAllowance: InnerPausedAllowanceColumn, + BondDisposition: BondDispositionColumn, + BondClaimer: BondClaimerColumn, + BondPayment: BondPaymentColumn, CreatedAt: CreatedAtColumn, UpdatedAt: UpdatedAtColumn, diff --git a/internal/repository/postgres/epoch.go b/internal/repository/postgres/epoch.go index 6cb5da893..4f582861d 100644 --- a/internal/repository/postgres/epoch.go +++ b/internal/repository/postgres/epoch.go @@ -8,6 +8,7 @@ import ( "database/sql" "errors" "fmt" + "math" "sort" "github.com/cartesi/rollups-node/internal/model" @@ -384,11 +385,6 @@ func (r *PostgresRepository) HasUndrainedEpochsBeforeBlock( appID int64, blockBound uint64, ) (bool, error) { - terminalStatuses := []postgres.Expression{ - enum.EpochStatus.ClaimAccepted, - enum.EpochStatus.ClaimRejected, - enum.EpochStatus.ClaimForeclosed, - } stmt := table.Input. SELECT(table.Input.Index). FROM( @@ -401,7 +397,7 @@ func (r *PostgresRepository) HasUndrainedEpochsBeforeBlock( table.Input.EpochApplicationID.EQ(postgres.Int(appID)). AND(table.Input.BlockNumber.LT_EQ(uint64Expr(blockBound))). AND(table.Input.Status.EQ(enum.InputCompletionStatus.None)). - AND(table.Epoch.Status.NOT_IN(terminalStatuses...)), + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions()...)), ). LIMIT(1) @@ -423,14 +419,6 @@ func (r *PostgresRepository) ForecloseUnacceptedEpochsAtOrAfterBlock( appID int64, blockBound uint64, ) (int64, error) { - statuses := []postgres.Expression{ - enum.EpochStatus.Open, - enum.EpochStatus.Closed, - enum.EpochStatus.InputsProcessed, - enum.EpochStatus.ClaimComputed, - enum.EpochStatus.ClaimSubmitted, - enum.EpochStatus.ClaimStaged, - } updateStmt := table.Epoch. UPDATE(table.Epoch.Status). SET(enum.EpochStatus.ClaimForeclosed). @@ -439,7 +427,7 @@ func (r *PostgresRepository) ForecloseUnacceptedEpochsAtOrAfterBlock( table.Epoch.ApplicationID.EQ(postgres.Int64(appID)). AND(table.Epoch.FirstBlock.LT_EQ(uint64Expr(blockBound))). AND(table.Epoch.LastBlock.GT_EQ(uint64Expr(blockBound))). - AND(table.Epoch.Status.IN(statuses...)). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions()...)). AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). AND(table.Application.ForecloseBlock.GT(uint64Expr(0))). AND(table.Application.ConsensusType.NOT_EQ(enum.Consensus.Prt)), @@ -469,38 +457,17 @@ func (r *PostgresRepository) ForecloseUnacceptedEpochsAtOrAfterBlock( // valid same-block input that executed before Foreclosure. Authority/Quorum // never creates empty epoch rows, so `first_block <= blockBound` does not // introduce false positives. -// unreconciledEpochStatuses are the epoch statuses that still need claim work — -// every EpochStatus except the terminal CLAIM_ACCEPTED / CLAIM_REJECTED / -// CLAIM_FORECLOSED. It drives HasUnreconciledClaimsBeforeBlock's filter and -// MUST stay in sync with the partial-index predicate of "epoch_unreconciled_idx" -// in 000001_create_initial_schema.up.sql; if the two drift, the query silently -// stops matching the index and falls back to a full table scan. -// TestUnreconciledEpochStatusesAreNonTerminal guards this set when a new -// EpochStatus is added. -var unreconciledEpochStatuses = []model.EpochStatus{ - model.EpochStatus_Open, - model.EpochStatus_Closed, - model.EpochStatus_InputsProcessed, - model.EpochStatus_ClaimComputed, - model.EpochStatus_ClaimSubmitted, - model.EpochStatus_ClaimStaged, -} - func (r *PostgresRepository) HasUnreconciledClaimsBeforeBlock( ctx context.Context, appID int64, blockBound uint64, ) (bool, error) { - statuses := make([]postgres.Expression, len(unreconciledEpochStatuses)) - for i, s := range unreconciledEpochStatuses { - statuses[i] = postgres.NewEnumValue(string(s)) - } stmt := table.Epoch. SELECT(table.Epoch.Index). WHERE( table.Epoch.ApplicationID.EQ(postgres.Int(appID)). AND(table.Epoch.FirstBlock.LT_EQ(uint64Expr(blockBound))). - AND(table.Epoch.Status.IN(statuses...)), + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions()...)), ). LIMIT(1) @@ -513,6 +480,15 @@ func (r *PostgresRepository) HasUnreconciledClaimsBeforeBlock( return rows.Next(), rows.Err() } +func nonTerminalEpochStatusExpressions() []postgres.Expression { + statuses := model.NonTerminalEpochStatuses() + expressions := make([]postgres.Expression, len(statuses)) + for i, status := range statuses { + expressions[i] = postgres.NewEnumValue(status.String()) + } + return expressions +} + func (r *PostgresRepository) GetLastAcceptedEpochIndex( ctx context.Context, nameOrAddress string, @@ -876,7 +852,11 @@ func (r *PostgresRepository) UpdateEpochInputsProcessed( err := r.db.QueryRow(ctx, sqlStr, args...).Scan(&index) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return repository.ErrNoUpdate + statusQuery := table.Epoch.SELECT(table.Epoch.Status).FROM( + table.Epoch.INNER_JOIN(table.Application, table.Epoch.ApplicationID.EQ(table.Application.ID)), + ).WHERE(postgres.AND(whereClause, table.Epoch.Index.EQ(uint64Expr(epochIndex)))) + statusSQL, statusArgs := statusQuery.Sql() + return classifyEpochPublicationMiss(r.db.QueryRow(ctx, statusSQL, statusArgs...)) } return err } @@ -887,6 +867,23 @@ func (r *PostgresRepository) UpdateEpochInputsProcessed( return nil } +// classifyEpochPublicationMiss distinguishes a completed foreclosure from a +// missing row or another rejected state transition. The caller must select the +// status with the same application and epoch identity as the rejected update. +func classifyEpochPublicationMiss(row pgx.Row) error { + var status string + if err := row.Scan(&status); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNoUpdate + } + return fmt.Errorf("reading epoch status after rejected publication: %w", err) + } + if status == model.EpochStatus_ClaimForeclosed.String() { + return repository.ErrEpochForeclosed + } + return repository.ErrNoUpdate +} + func (r *PostgresRepository) ListEpochs( ctx context.Context, nameOrAddress string, @@ -894,6 +891,9 @@ func (r *PostgresRepository) ListEpochs( p repository.Pagination, descending bool, ) ([]*model.Epoch, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) @@ -920,6 +920,13 @@ func (r *PostgresRepository) ListEpochs( if f.BeforeBlock != nil { conditions = append(conditions, table.Epoch.LastBlock.LT(uint64Expr(*f.BeforeBlock))) } + if f.HasTournament != nil { + if *f.HasTournament { + conditions = append(conditions, table.Epoch.TournamentAddress.IS_NOT_NULL()) + } else { + conditions = append(conditions, table.Epoch.TournamentAddress.IS_NULL()) + } + } tx, err := beginReadTx(ctx, r.db) if err != nil { diff --git a/internal/repository/postgres/epoch_publication_foreclosure_test.go b/internal/repository/postgres/epoch_publication_foreclosure_test.go new file mode 100644 index 000000000..dc9782b0f --- /dev/null +++ b/internal/repository/postgres/epoch_publication_foreclosure_test.go @@ -0,0 +1,162 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +func newEpochPublicationRepository(t *testing.T) repository.Repository { + t.Helper() + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + return repo +} + +func seedEpochPublication(t *testing.T, repo repository.Repository) (*Application, *Epoch) { + t.Helper() + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, repo) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 1).Build() + input := repotest.NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + require.NoError(t, repo.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {input}}, 10)) + require.NoError(t, repo.StoreAdvanceResult(t.Context(), app.ID, &AdvanceResult{ + EpochIndex: epoch.Index, InputIndex: input.Index, Status: InputCompletionStatus_Accepted, + StateProof: *repotest.DummyStateProof(), Outputs: [][]byte{[]byte("output")}, + })) + return app, epoch +} + +func TestEpochPublicationLosesToForeclosureWithoutWrites(t *testing.T) { + for _, claimPublication := range []bool{false, true} { + name := "closed state proof" + if claimPublication { + name = "inputs processed claim" + } + t.Run(name, func(t *testing.T) { + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + if claimPublication { + require.NoError(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + } + // The publisher holds this snapshot before PRT wins the terminal transition. + stale, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NoError(t, repo.UpdateApplicationForeclosure(t.Context(), app.ID, 100, repotest.UniqueHash(), 100)) + require.NoError(t, repo.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index)) + before, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + outputsBefore, _, err := repo.ListOutputs(t.Context(), app.Name, repository.OutputFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Len(t, outputsBefore, 1) + + if claimPublication { + stale.Commitment = new(repotest.UniqueHash()) + stale.CommitmentProof = make([]common.Hash, Log2EpochComputationHashLeafCount) + output := *outputsBefore[0] + output.Hash = new(repotest.UniqueHash()) + output.OutputHashesSiblings = []common.Hash{repotest.UniqueHash()} + err = repo.StoreClaimAndProofs(t.Context(), stale, []*Output{&output}) + } else { + proof := repotest.DummyStateProof() + proof.MachineHash = repotest.UniqueHash() + proof.TxBufferDataBlock = repotest.UniqueHash() + err = repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof) + } + require.ErrorIs(t, err, repository.ErrEpochForeclosed) + after, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, before, after, "a rejected publication must preserve status, proof fields, and timestamps") + outputsAfter, _, err := repo.ListOutputs(t.Context(), app.Name, repository.OutputFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, outputsBefore, outputsAfter, "claim rejection must occur before any output-proof write") + require.ErrorIs(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, &StateProof{}), + repository.ErrInvalidStateProof, "foreclosure must not bypass proof completeness checks") + }) + } +} + +func TestEpochPublicationDoesNotMisclassifyOtherFailures(t *testing.T) { + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + foreclosedApp, foreclosedEpoch := seedEpochPublication(t, repo) + require.NoError(t, repo.UpdateApplicationForeclosure(t.Context(), foreclosedApp.ID, 100, repotest.UniqueHash(), 100)) + require.NoError(t, repo.UpdateEpochWithForeclosedClaim(t.Context(), foreclosedApp.ID, foreclosedEpoch.Index)) + for _, test := range []struct { + name string + appID int64 + appName string + epoch uint64 + proofOnly bool + }{ + {name: "claim closed epoch", appID: app.ID, epoch: epoch.Index}, + {name: "claim missing epoch", appID: foreclosedApp.ID, epoch: 99}, + {name: "claim missing application", appID: -1, epoch: epoch.Index}, + {name: "proof missing epoch", appName: foreclosedApp.Name, epoch: 99, proofOnly: true}, + {name: "proof missing application", appName: "missing", epoch: epoch.Index, proofOnly: true}, + } { + t.Run(test.name, func(t *testing.T) { + var err error + if test.proofOnly { + err = repo.UpdateEpochInputsProcessed(t.Context(), test.appName, test.epoch, repotest.DummyStateProof()) + } else { + err = repo.StoreClaimAndProofs(t.Context(), &Epoch{ApplicationID: test.appID, Index: test.epoch}, nil) + } + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + }) + } + + // Normal publication still works in the presence of another foreclosed app. + require.NoError(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + err := repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof()) + require.ErrorIs(t, err, repository.ErrNoUpdate, "an already published proof is not foreclosure") + computed, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + computed.Commitment, computed.CommitmentProof = new(repotest.UniqueHash()), make([]common.Hash, Log2EpochComputationHashLeafCount) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), computed, nil)) + stored, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, EpochStatus_ClaimComputed, stored.Status) + require.Equal(t, computed.Commitment, stored.Commitment) + require.Equal(t, computed.CommitmentProof, stored.CommitmentProof) + + canceled, cancel := context.WithCancel(t.Context()) + cancel() + for _, err := range []error{ + repo.UpdateEpochInputsProcessed(canceled, app.Name, epoch.Index, repotest.DummyStateProof()), + repo.StoreClaimAndProofs(canceled, computed, nil), + } { + require.ErrorIs(t, err, context.Canceled) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + } +} + +type failedPublicationStatusRow struct{ err error } + +func (r failedPublicationStatusRow) Scan(...any) error { return r.err } + +func TestEpochPublicationStatusReadFailure(t *testing.T) { + failure := errors.New("status read failed") + err := classifyEpochPublicationMiss(failedPublicationStatusRow{err: failure}) + require.ErrorIs(t, err, failure) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + require.ErrorIs(t, classifyEpochPublicationMiss(failedPublicationStatusRow{err: pgx.ErrNoRows}), repository.ErrNoUpdate) +} diff --git a/internal/repository/postgres/epoch_unreconciled_test.go b/internal/repository/postgres/epoch_unreconciled_test.go index 448517a36..111dd5895 100644 --- a/internal/repository/postgres/epoch_unreconciled_test.go +++ b/internal/repository/postgres/epoch_unreconciled_test.go @@ -4,33 +4,104 @@ package postgres import ( + "fmt" + "slices" "testing" - "github.com/cartesi/rollups-node/internal/model" - "github.com/stretchr/testify/assert" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" ) -// TestUnreconciledEpochStatusesAreNonTerminal pins unreconciledEpochStatuses to -// exactly the non-terminal epoch statuses. HasUnreconciledClaimsBeforeBlock and -// the "epoch_unreconciled_idx" partial index share this set; if a new -// EpochStatus is added and not classified here, the index predicate and the -// query would silently drift and the query would fall back to a full scan. -func TestUnreconciledEpochStatusesAreNonTerminal(t *testing.T) { - terminal := map[model.EpochStatus]bool{ - model.EpochStatus_ClaimAccepted: true, - model.EpochStatus_ClaimRejected: true, - model.EpochStatus_ClaimForeclosed: true, +func TestUnreconciledIndexMatchesNonTerminalEpochStatuses(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + var predicate string + err := r.db.QueryRow(t.Context(), ` + SELECT pg_get_expr(indpred, indrelid) FROM pg_index + WHERE indexrelid = 'epoch_unreconciled_idx'::regclass`).Scan(&predicate) + require.NoError(t, err) + require.NotEmpty(t, predicate) + for _, status := range EpochStatusAllValues { + // Evaluate the actual migrated predicate, not a copied status list. + query := fmt.Sprintf(`SELECT %s FROM (SELECT $1::"EpochStatus" AS status) AS epoch`, predicate) + var indexed bool + require.NoError(t, r.db.QueryRow(t.Context(), query, status.String()).Scan(&indexed)) + require.Equal(t, slices.Contains(NonTerminalEpochStatuses(), status), indexed, status) } +} - var want []model.EpochStatus - for _, s := range model.EpochStatusAllValues { - if !terminal[s] { - want = append(want, s) +func TestForeclosureQueriesUseNonTerminalEpochStatuses(t *testing.T) { + r := newEpochPublicationRepository(t) + for _, action := range []struct { + name string + consensus Consensus + bulk bool + }{ + {name: "single Authority", consensus: Consensus_Authority}, + {name: "single PRT", consensus: Consensus_PRT}, + {name: "bulk Authority", consensus: Consensus_Authority, bulk: true}, + {name: "bulk Quorum", consensus: Consensus_Quorum, bulk: true}, + {name: "bulk excludes PRT", consensus: Consensus_PRT, bulk: true}, + } { + for _, status := range EpochStatusAllValues { + t.Run(action.name+"/"+status.String(), func(t *testing.T) { + app, epoch := seedForeclosureStatus(t, r, action.consensus, status) + nonTerminal := slices.Contains(NonTerminalEpochStatuses(), status) + pending, err := r.HasUnreconciledClaimsBeforeBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + require.Equal(t, nonTerminal, pending) + canForeclose := nonTerminal && (!action.bulk || action.consensus != Consensus_PRT) + if action.bulk { + count, err := r.ForecloseUnacceptedEpochsAtOrAfterBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + if canForeclose { + require.Equal(t, int64(1), count) + } else { + require.Zero(t, count) + } + } else { + err := r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index) + if canForeclose { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, repository.ErrNoUpdate) + } + } + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + wantStatus := status + if canForeclose { + wantStatus = EpochStatus_ClaimForeclosed + } + require.Equal(t, wantStatus, stored.Status) + pending, err = r.HasUnreconciledClaimsBeforeBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + require.Equal(t, nonTerminal && !canForeclose, pending) + }) } } +} - assert.ElementsMatch(t, want, unreconciledEpochStatuses, - "unreconciledEpochStatuses must be exactly the non-terminal epoch statuses. "+ - "If you added an EpochStatus, classify it here AND in the epoch_unreconciled_idx "+ - "predicate in 000001_create_initial_schema.up.sql.") +func seedForeclosureStatus(t *testing.T, r repository.Repository, consensus Consensus, status EpochStatus) (*Application, *Epoch) { + t.Helper() + app := repotest.NewApplicationBuilder().WithConsensus(consensus).Create(t.Context(), t, r) + initial := EpochStatus_Closed + if status == EpochStatus_Open { + initial = status + } + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(initial).WithBlocks(0, 9).WithInputBounds(0, 0).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, 10)) + if status == EpochStatus_ClaimStaged { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, EpochStatus_ClaimSubmitted) + require.NoError(t, r.UpdateEpochToStaged(t.Context(), app.ID, epoch.Index, 4)) + } else if status != initial && status != EpochStatus_ClaimForeclosed { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, status) + } + app.ForecloseBlock = 5 + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, app.ForecloseBlock, repotest.UniqueHash(), 10)) + if status == EpochStatus_ClaimForeclosed { + require.NoError(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index)) + } + return app, epoch } diff --git a/internal/repository/postgres/event_column_test.go b/internal/repository/postgres/event_column_test.go new file mode 100644 index 000000000..7942e1efb --- /dev/null +++ b/internal/repository/postgres/event_column_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +func TestGetColumnForEvent(t *testing.T) { + for _, test := range []struct { + event MonitoredEvent + column postgres.ColumnFloat + }{ + {MonitoredEvent_EpochSealed, table.Application.LastEpochCheckBlock}, + {MonitoredEvent_InputAdded, table.Application.LastInputCheckBlock}, + {MonitoredEvent_OutputExecuted, table.Application.LastOutputCheckBlock}, + {MonitoredEvent_CommitmentJoined, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchAdvanced, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchCreated, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchDeleted, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_NewInnerTournament, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_LeafMatchSealed, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_PartialBondRefund, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_BondRecovered, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_ClaimSubmitted, nil}, + {MonitoredEvent_ClaimAccepted, nil}, + {MonitoredEvent_Foreclosure, nil}, + {MonitoredEvent_Withdrawal, nil}, + {MonitoredEvent_AccountsDriveMerkleRootProved, nil}, + {MonitoredEvent("unknown"), nil}, + } { + t.Run(test.event.String(), func(t *testing.T) { + column, err := getColumnForEvent(test.event) + if test.column == nil { + require.EqualError(t, err, "invalid monitored event type: "+test.event.String()) + require.Nil(t, column) + } else { + require.NoError(t, err) + require.Equal(t, test.column, column) + } + }) + } +} diff --git a/internal/repository/postgres/match.go b/internal/repository/postgres/match.go index bfb84b201..73fa801ac 100644 --- a/internal/repository/postgres/match.go +++ b/internal/repository/postgres/match.go @@ -5,296 +5,103 @@ package postgres import ( "context" - "database/sql" "errors" + "fmt" + "math" - "github.com/ethereum/go-ethereum/common" - "github.com/go-jet/jet/v2/postgres" - - "github.com/cartesi/rollups-node/internal/model" + . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" ) -// ------------------------ MatchRepository Methods ------------------------ // - -func (r *PostgresRepository) CreateMatch( - ctx context.Context, - nameOrAddress string, - m *model.Match, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - selectQuery := table.Application.SELECT( - table.Application.ID, - uint64Expr(m.EpochIndex), - postgres.Bytea(m.TournamentAddress.Bytes()), - postgres.Bytea(m.IDHash.Bytes()), - postgres.Bytea(m.CommitmentOne.Bytes()), - postgres.Bytea(m.CommitmentTwo.Bytes()), - postgres.Bytea(m.LeftOfTwo.Bytes()), - uint64Expr(m.BlockNumber), - postgres.Bytea(m.TxHash.Bytes()), - postgres.NewEnumValue(m.Winner.String()), - postgres.NewEnumValue(m.DeletionReason.String()), - uint64Expr(m.DeletionBlockNumber), - postgres.Bytea(m.DeletionTxHash.Bytes()), - ).WHERE( - whereClause, - ) - - insertStmt := table.Matches.INSERT( - table.Matches.ApplicationID, - table.Matches.EpochIndex, - table.Matches.TournamentAddress, - table.Matches.IDHash, - table.Matches.CommitmentOne, - table.Matches.CommitmentTwo, - table.Matches.LeftOfTwo, - table.Matches.BlockNumber, - table.Matches.TxHash, - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - ).QUERY( - selectQuery, - ) - - sqlStr, args := insertStmt.Sql() +func (r *PostgresRepository) CreateMatch(ctx context.Context, nameOrAddress string, value *Match) error { + if value == nil { + return fmt.Errorf("cannot create a nil match") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := matchValues(value) + values[0] = applicationID + stmt := table.Matches.INSERT(matchColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() _, err := r.db.Exec(ctx, sqlStr, args...) - return err } -func (r *PostgresRepository) UpdateMatch( - ctx context.Context, - nameOrAddress string, - m *model.Match, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - updateStmt := table.Matches. - UPDATE( - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - ). - SET( - m.Winner, - m.DeletionReason, - m.DeletionBlockNumber, - postgres.Bytea(m.DeletionTxHash.Bytes()), - ). - FROM( - table.Application, - ). - WHERE( - whereClause. - AND(table.Matches.ApplicationID.EQ(postgres.Int(m.ApplicationID))). - AND(table.Matches.EpochIndex.EQ(uint64Expr(m.EpochIndex))). - AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(m.TournamentAddress.Bytes()))). - AND(table.Matches.IDHash.EQ(postgres.Bytea(m.IDHash.Bytes()))), - ) - - sqlStr, args := updateStmt.Sql() - cmd, err := r.db.Exec(ctx, sqlStr, args...) - if err != nil { - return err - } - if cmd.RowsAffected() == 0 { - return repository.ErrNotFound - } - return nil -} - func (r *PostgresRepository) GetMatch( - ctx context.Context, - nameOrAddress string, - epochIndex uint64, - tournamentAddress string, - idHashHex string, -) (*model.Match, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - tournamentAddr := common.HexToAddress(tournamentAddress) - idHash := common.HexToHash(idHashHex) - - sel := table.Matches. - SELECT( - table.Matches.ApplicationID, - table.Matches.EpochIndex, - table.Matches.TournamentAddress, - table.Matches.IDHash, - table.Matches.CommitmentOne, - table.Matches.CommitmentTwo, - table.Matches.LeftOfTwo, - table.Matches.BlockNumber, - table.Matches.TxHash, - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - table.Matches.CreatedAt, - table.Matches.UpdatedAt, - ). - FROM( - table.Matches. - INNER_JOIN(table.Application, - table.Matches.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - whereClause. - AND(table.Matches.EpochIndex.EQ(uint64Expr(epochIndex))). - AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))). - AND(table.Matches.IDHash.EQ(postgres.Bytea(idHash.Bytes()))), - ) - + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string, +) (*Match, error) { + sel := table.Matches.SELECT(matchColumns, table.Matches.CreatedAt, table.Matches.UpdatedAt). + FROM(table.Matches.INNER_JOIN(table.Application, table.Matches.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Matches.EpochIndex.EQ(uint64Expr(epochIndex)). + AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes()))). + AND(table.Matches.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes()))), + )) sqlStr, args := sel.Sql() - row := r.db.QueryRow(ctx, sqlStr, args...) - - var m model.Match - err := row.Scan( - &m.ApplicationID, - &m.EpochIndex, - &m.TournamentAddress, - &m.IDHash, - &m.CommitmentOne, - &m.CommitmentTwo, - &m.LeftOfTwo, - &m.BlockNumber, - &m.TxHash, - &m.Winner, - &m.DeletionReason, - &m.DeletionBlockNumber, - &m.DeletionTxHash, - &m.CreatedAt, - &m.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { + value, err := scanMatch(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - if err != nil { - return nil, err - } - return &m, nil + return value, err } func (r *PostgresRepository) ListMatches( - ctx context.Context, - nameOrAddress string, - f repository.MatchFilter, - p repository.Pagination, - descending bool, -) ([]*model.Match, uint64, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - fromClause := table.Matches. - INNER_JOIN(table.Application, - table.Matches.ApplicationID.EQ(table.Application.ID), - ) - - conditions := []postgres.BoolExpression{whereClause} + ctx context.Context, nameOrAddress string, f repository.MatchFilter, p repository.Pagination, descending bool, +) ([]*Match, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Matches.INNER_JOIN(table.Application, table.Matches.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} if f.EpochIndex != nil { conditions = append(conditions, table.Matches.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) } if f.TournamentAddress != nil { - tournamentAddr := common.HexToAddress(*f.TournamentAddress) - conditions = append(conditions, table.Matches.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))) + conditions = append(conditions, + table.Matches.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(*f.TournamentAddress).Bytes()))) } - tx, err := beginReadTx(ctx, r.db) if err != nil { return nil, 0, err } defer tx.Rollback(ctx) //nolint:errcheck - - countStmt := table.Matches.SELECT(postgres.COUNT(postgres.STAR)). - FROM(fromClause).WHERE(postgres.AND(conditions...)) + countStmt := table.Matches.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) total, err := countFromTx(ctx, tx, countStmt) - if err != nil { - return nil, 0, err + if err != nil || total == 0 { + return nil, total, err } - if total == 0 { - return nil, 0, nil - } - - sel := table.Matches. - SELECT( - table.Matches.ApplicationID, - table.Matches.EpochIndex, - table.Matches.TournamentAddress, - table.Matches.IDHash, - table.Matches.CommitmentOne, - table.Matches.CommitmentTwo, - table.Matches.LeftOfTwo, - table.Matches.BlockNumber, - table.Matches.TxHash, - table.Matches.Winner, - table.Matches.DeletionReason, - table.Matches.DeletionBlockNumber, - table.Matches.DeletionTxHash, - table.Matches.CreatedAt, - table.Matches.UpdatedAt, - ). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + sel := table.Matches.SELECT(matchColumns, table.Matches.CreatedAt, table.Matches.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) if descending { - sel = sel.ORDER_BY(table.Matches.EpochIndex.DESC()) + sel = sel.ORDER_BY(table.Matches.EpochIndex.DESC(), table.Matches.TournamentAddress.DESC(), table.Matches.IDHash.DESC()) } else { - sel = sel.ORDER_BY(table.Matches.EpochIndex.ASC()) + sel = sel.ORDER_BY(table.Matches.EpochIndex.ASC(), table.Matches.TournamentAddress.ASC(), table.Matches.IDHash.ASC()) } - - // Apply pagination if p.Limit > 0 { sel = sel.LIMIT(int64(p.Limit)) } if p.Offset > 0 { sel = sel.OFFSET(int64(p.Offset)) } - sqlStr, args := sel.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { return nil, 0, err } defer rows.Close() - - var matches []*model.Match + var values []*Match for rows.Next() { - var m model.Match - err := rows.Scan( - &m.ApplicationID, - &m.EpochIndex, - &m.TournamentAddress, - &m.IDHash, - &m.CommitmentOne, - &m.CommitmentTwo, - &m.LeftOfTwo, - &m.BlockNumber, - &m.TxHash, - &m.Winner, - &m.DeletionReason, - &m.DeletionBlockNumber, - &m.DeletionTxHash, - &m.CreatedAt, - &m.UpdatedAt, - ) + value, err := scanMatch(rows) if err != nil { return nil, 0, err } - matches = append(matches, &m) + values = append(values, value) } if err := rows.Err(); err != nil { return nil, 0, err } - - return matches, total, nil + return values, total, nil } diff --git a/internal/repository/postgres/match_advanced.go b/internal/repository/postgres/match_advanced.go index bde2c3086..b0a8e47aa 100644 --- a/internal/repository/postgres/match_advanced.go +++ b/internal/repository/postgres/match_advanced.go @@ -5,241 +5,106 @@ package postgres import ( "context" - "database/sql" "errors" + "fmt" + "math" - "github.com/ethereum/go-ethereum/common" - "github.com/go-jet/jet/v2/postgres" - - "github.com/cartesi/rollups-node/internal/model" + . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" ) -// ------------------------ MatchAdvancedRepository Methods ------------------------ // - -func (r *PostgresRepository) CreateMatchAdvanced( - ctx context.Context, - nameOrAddress string, - m *model.MatchAdvanced, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - selectQuery := table.Application.SELECT( - table.Application.ID, - uint64Expr(m.EpochIndex), - postgres.Bytea(m.TournamentAddress.Bytes()), - postgres.Bytea(m.IDHash.Bytes()), - postgres.Bytea(m.OtherParent.Bytes()), - postgres.Bytea(m.LeftNode.Bytes()), - uint64Expr(m.BlockNumber), - postgres.Bytea(m.TxHash.Bytes()), - ).WHERE( - whereClause, - ) - - insertStmt := table.MatchAdvances.INSERT( - table.MatchAdvances.ApplicationID, - table.MatchAdvances.EpochIndex, - table.MatchAdvances.TournamentAddress, - table.MatchAdvances.IDHash, - table.MatchAdvances.OtherParent, - table.MatchAdvances.LeftNode, - table.MatchAdvances.BlockNumber, - table.MatchAdvances.TxHash, - ).QUERY( - selectQuery, - ) - - sqlStr, args := insertStmt.Sql() +func (r *PostgresRepository) CreateMatchAdvanced(ctx context.Context, nameOrAddress string, value *MatchAdvanced) error { + if value == nil { + return fmt.Errorf("cannot create a nil matchAdvance") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := matchAdvanceValues(value) + values[0] = applicationID + stmt := table.MatchAdvances.INSERT(matchAdvanceColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() _, err := r.db.Exec(ctx, sqlStr, args...) - return err } func (r *PostgresRepository) GetMatchAdvanced( - ctx context.Context, - nameOrAddress string, - epochIndex uint64, - tournamentAddress string, - idHashHex string, - parentHex string, -) (*model.MatchAdvanced, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - tournamentAddr := common.HexToAddress(tournamentAddress) - idHash := common.HexToHash(idHashHex) - parent := common.HexToHash(parentHex) - - sel := table.MatchAdvances. - SELECT( - table.MatchAdvances.ApplicationID, - table.MatchAdvances.EpochIndex, - table.MatchAdvances.TournamentAddress, - table.MatchAdvances.IDHash, - table.MatchAdvances.OtherParent, - table.MatchAdvances.LeftNode, - table.MatchAdvances.BlockNumber, - table.MatchAdvances.TxHash, - table.MatchAdvances.CreatedAt, - table.MatchAdvances.UpdatedAt, - ). - FROM( - table.MatchAdvances. - INNER_JOIN(table.Application, - table.MatchAdvances.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - whereClause. - AND(table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex))). - AND(table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(tournamentAddr.Bytes()))). - AND(table.MatchAdvances.IDHash.EQ(postgres.Bytea(idHash.Bytes()))). - AND(table.MatchAdvances.OtherParent.EQ(postgres.Bytea(parent.Bytes()))), - ) - + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, txHash common.Hash, logIndex uint64, +) (*MatchAdvanced, error) { + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt). + FROM(table.MatchAdvances.INNER_JOIN(table.Application, table.MatchAdvances.ApplicationID.EQ(table.Application.ID))). + WHERE(postgres.AND( + getWhereClauseFromNameOrAddress(nameOrAddress), + table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex)), + table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes())), + table.MatchAdvances.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes())), + table.MatchAdvances.TxHash.EQ(postgres.Bytea(txHash.Bytes())), + table.MatchAdvances.LogIndex.EQ(uint64Expr(logIndex)), + )) sqlStr, args := sel.Sql() - row := r.db.QueryRow(ctx, sqlStr, args...) - - var m model.MatchAdvanced - err := row.Scan( - &m.ApplicationID, - &m.EpochIndex, - &m.TournamentAddress, - &m.IDHash, - &m.OtherParent, - &m.LeftNode, - &m.BlockNumber, - &m.TxHash, - &m.CreatedAt, - &m.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { + value, err := scanMatchAdvanced(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - if err != nil { - return nil, err - } - return &m, nil + return value, err } func (r *PostgresRepository) ListMatchAdvances( - ctx context.Context, - nameOrAddress string, - epochIndex uint64, - tournamentAddress string, - idHashHex string, - p repository.Pagination, - descending bool, -) ([]*model.MatchAdvanced, uint64, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - fromClause := table.MatchAdvances. - INNER_JOIN(table.Application, - table.MatchAdvances.ApplicationID.EQ(table.Application.ID), - ) - - conditions := []postgres.BoolExpression{whereClause} - conditions = append(conditions, table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex))) - - tAddr := common.HexToAddress(tournamentAddress) - conditions = append(conditions, table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(tAddr.Bytes()))) - - idHash := common.HexToHash(idHashHex) - conditions = append(conditions, table.MatchAdvances.IDHash.EQ(postgres.Bytea(idHash.Bytes()))) - + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, p repository.Pagination, descending bool, +) ([]*MatchAdvanced, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.MatchAdvances.INNER_JOIN(table.Application, table.MatchAdvances.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{ + getWhereClauseFromNameOrAddress(nameOrAddress), + table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex)), + table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes())), + table.MatchAdvances.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes())), + } tx, err := beginReadTx(ctx, r.db) if err != nil { return nil, 0, err } defer tx.Rollback(ctx) //nolint:errcheck - - countStmt := table.MatchAdvances. - SELECT(postgres.COUNT(postgres.STAR)). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + countStmt := table.MatchAdvances.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) total, err := countFromTx(ctx, tx, countStmt) - if err != nil { - return nil, 0, err - } - if total == 0 { - return nil, 0, nil + if err != nil || total == 0 { + return nil, total, err } - - sel := table.MatchAdvances. - SELECT( - table.MatchAdvances.ApplicationID, - table.MatchAdvances.EpochIndex, - table.MatchAdvances.TournamentAddress, - table.MatchAdvances.IDHash, - table.MatchAdvances.OtherParent, - table.MatchAdvances.LeftNode, - table.MatchAdvances.BlockNumber, - table.MatchAdvances.TxHash, - table.MatchAdvances.CreatedAt, - table.MatchAdvances.UpdatedAt, - ). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) if descending { - sel = sel.ORDER_BY( - table.MatchAdvances.ApplicationID.DESC(), - table.MatchAdvances.EpochIndex.DESC(), - table.MatchAdvances.TournamentAddress.DESC(), - table.MatchAdvances.IDHash.DESC(), - table.MatchAdvances.OtherParent.DESC()) + sel = sel.ORDER_BY(table.MatchAdvances.BlockNumber.DESC(), table.MatchAdvances.LogIndex.DESC(), table.MatchAdvances.TxHash.DESC()) } else { - sel = sel.ORDER_BY( - table.MatchAdvances.ApplicationID.ASC(), - table.MatchAdvances.EpochIndex.ASC(), - table.MatchAdvances.TournamentAddress.ASC(), - table.MatchAdvances.IDHash.ASC(), - table.MatchAdvances.OtherParent.ASC()) + sel = sel.ORDER_BY(table.MatchAdvances.BlockNumber.ASC(), table.MatchAdvances.LogIndex.ASC(), table.MatchAdvances.TxHash.ASC()) } - - // Apply pagination if p.Limit > 0 { sel = sel.LIMIT(int64(p.Limit)) } if p.Offset > 0 { sel = sel.OFFSET(int64(p.Offset)) } - sqlStr, args := sel.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { return nil, 0, err } defer rows.Close() - - var matchAdvances []*model.MatchAdvanced + var values []*MatchAdvanced for rows.Next() { - var m model.MatchAdvanced - err := rows.Scan( - &m.ApplicationID, - &m.EpochIndex, - &m.TournamentAddress, - &m.IDHash, - &m.OtherParent, - &m.LeftNode, - &m.BlockNumber, - &m.TxHash, - &m.CreatedAt, - &m.UpdatedAt, - ) + value, err := scanMatchAdvanced(rows) if err != nil { return nil, 0, err } - matchAdvances = append(matchAdvances, &m) + values = append(values, value) } if err := rows.Err(); err != nil { return nil, 0, err } - - return matchAdvances, total, nil + return values, total, nil } diff --git a/internal/repository/postgres/node_config.go b/internal/repository/postgres/node_config.go index a3c303224..93d92df88 100644 --- a/internal/repository/postgres/node_config.go +++ b/internal/repository/postgres/node_config.go @@ -15,6 +15,16 @@ import ( "github.com/go-jet/jet/v2/postgres" ) +func (r *PostgresRepository) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + stmt := table.NodeConfig. + INSERT(table.NodeConfig.Key, table.NodeConfig.Value). + VALUES(key, postgres.Json(rawJSON)). + ON_CONFLICT(table.NodeConfig.Key).DO_NOTHING() + sqlStr, args := stmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + func (r *PostgresRepository) SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { insertStmt := table.NodeConfig. diff --git a/internal/repository/postgres/output.go b/internal/repository/postgres/output.go index b2c2dc6f1..d80f591d7 100644 --- a/internal/repository/postgres/output.go +++ b/internal/repository/postgres/output.go @@ -157,7 +157,7 @@ func (r *PostgresRepository) UpdateOutputsExecution( } } - // Update last claim check block + // Advance the output scan cursor without moving it backwards. appUpdateStmt := table.Application. UPDATE( table.Application.LastOutputCheckBlock, @@ -165,7 +165,9 @@ func (r *PostgresRepository) UpdateOutputsExecution( SET( uint64Expr(lastOutputCheckBlock), ). - WHERE(whereClause) + WHERE( + whereClause.AND(table.Application.LastOutputCheckBlock.LT(uint64Expr(lastOutputCheckBlock))), + ) sqlStr, args := appUpdateStmt.Sql() _, err = tx.Exec(ctx, sqlStr, args...) diff --git a/internal/repository/postgres/pagination_range_test.go b/internal/repository/postgres/pagination_range_test.go new file mode 100644 index 000000000..559b24927 --- /dev/null +++ b/internal/repository/postgres/pagination_range_test.go @@ -0,0 +1,53 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "math" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func TestApplicationAndEpochPaginationIntegerBounds(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, epoch.LastBlock)) + for _, method := range []struct { + name string + list func(repository.Pagination) (int, uint64, error) + }{ + {"applications", func(p repository.Pagination) (int, uint64, error) { + rows, total, err := r.ListApplications(t.Context(), repository.ApplicationFilter{}, p, false) + return len(rows), total, err + }}, + {"epochs", func(p repository.Pagination) (int, uint64, error) { + rows, total, err := r.ListEpochs(t.Context(), app.Name, repository.EpochFilter{}, p, false) + return len(rows), total, err + }}, + } { + t.Run(method.name, func(t *testing.T) { + for _, p := range []repository.Pagination{{Limit: math.MaxInt64}, {Offset: math.MaxInt64}} { + count, total, err := method.list(p) + require.NoError(t, err) + require.Equal(t, uint64(1), total) + if p.Offset == 0 { + require.Equal(t, 1, count) + } else { + require.Zero(t, count) + } + } + for _, p := range []repository.Pagination{{Limit: math.MaxInt64 + 1}, {Offset: math.MaxInt64 + 1}} { + count, total, err := method.list(p) + require.ErrorContains(t, err, "pagination exceeds PostgreSQL integer range") + require.Zero(t, count) + require.Zero(t, total) + } + }) + } +} diff --git a/internal/repository/postgres/passive_observer_test.go b/internal/repository/postgres/passive_observer_test.go new file mode 100644 index 000000000..fcfeacc8b --- /dev/null +++ b/internal/repository/postgres/passive_observer_test.go @@ -0,0 +1,460 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const observerCheckpoint = uint64(100) + +func observerUint256(t *testing.T, text string) Uint256 { + t.Helper() + number, ok := new(big.Int).SetString(text, 10) + require.True(t, ok) + value, err := Uint256FromBig(number) + require.NoError(t, err) + return value +} + +// newPassiveObserverWindow has all eight event types and final current views. +// Its inner match was advanced twice, sealed, deleted, and its bond recovered. +func newPassiveObserverWindow(t *testing.T) (*PostgresRepository, *Application, []*repository.TournamentEventBatch) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, observerCheckpoint)) + root := repotest.NewTournamentBuilder(app.ID).Build() + root.MaxLevel = 2 + makePair := func(tournament *Tournament) (*Commitment, *Commitment, *Match) { + one := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithTournamentAddress(tournament.Address). + WithCommitmentOne(one.Commitment).WithCommitmentTwo(two.Commitment).Build() + one.BlockNumber, two.BlockNumber, match.BlockNumber = 10, 11, 11 + match.TxHash, match.LogIndex = two.TxHash, 1 + match.EliminableAt = 50 + match.Winner = WinnerCommitment_ONE + match.DeletionReason = MatchDeletionReason_CHILD_TOURNAMENT + match.DeletionBlockNumber, match.DeletionTxHash, match.DeletionLogIndex = 80, new(repotest.UniqueHash()), new(uint64(0)) + match.Snapshot = MatchSnapshot{AsOfBlock: observerCheckpoint, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone} + return one, two, match + } + one, two, parentMatch := makePair(root) + child := repotest.NewTournamentBuilder(app.ID).WithLevel(1).WithParent(root.Address, parentMatch.IDHash).Build() + child.MaxLevel, child.Kind, child.Log2Step = 2, TournamentKindLeaf, 0 + child.CreationEvent.BlockNumber = 50 + child.StartInstant = 50 + childOne, childTwo, childMatch := makePair(child) + childOne.BlockNumber, childTwo.BlockNumber, childMatch.BlockNumber = 51, 52, 52 + childMatch.DeletionReason, childMatch.DeletionBlockNumber = MatchDeletionReason_STEP, 70 + childMatch.LeafSeal = &LeafMatchSeal{EliminableAt: 90, BlockNumber: 60, TxHash: repotest.UniqueHash()} + root.Snapshot = TournamentSnapshot{ + AsOfBlock: observerCheckpoint, Standing: TournamentStandingRootWinner, + Candidate: new(one.Commitment), WinnerCommitment: new(one.Commitment), FinalStateHash: new(one.FinalStateHash), + FinishedAtBlock: 90, + BondRecovery: TournamentBondRecovery{ + Disposition: BondDispositionRecoverable, Claimer: new(one.SubmitterAddress), Payment: new(Uint256), + }, + } + child.Snapshot = TournamentSnapshot{ + AsOfBlock: observerCheckpoint, Standing: TournamentStandingInnerWinner, + Candidate: new(childOne.Commitment), WinnerCommitment: new(childOne.Commitment), FinalStateHash: new(childOne.FinalStateHash), + FinishedAtBlock: 80, ParentCommitment: new(parentMatch.CommitmentOne), WinnerExpiresAt: 150, + InnerResult: &TournamentInnerResult{ + Disposition: InnerTournamentWinner, ParentCommitment: new(parentMatch.CommitmentOne), PausedAllowance: 50, + }, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}, + } + childOne.Snapshot.Claimer = common.Address{} + firstAdvance := repotest.NewMatchAdvancedBuilder(app.ID).WithTournamentAddress(child.Address).WithIDHash(childMatch.IDHash).Build() + firstAdvance.BlockNumber, firstAdvance.LogIndex, firstAdvance.EliminableAt = 55, 0, 85 + firstAdvance.SegmentStartPosition = observerUint256(t, "18446744073709551616") + secondAdvance := *firstAdvance + secondAdvance.LogIndex, secondAdvance.EliminableAt = 1, 86 + secondAdvance.LeftNode = repotest.UniqueHash() + refund := &BondEvent{ + ApplicationID: app.ID, EpochIndex: child.EpochIndex, TournamentAddress: child.Address, Type: BondEventPartialRefund, + BlockNumber: childMatch.DeletionBlockNumber, TxHash: *childMatch.DeletionTxHash, LogIndex: 1, + Refund: &PartialBondRefund{Recipient: childOne.SubmitterAddress, Value: observerUint256(t, "7"), Success: false}, + } + recovery := &BondEvent{ + ApplicationID: app.ID, EpochIndex: child.EpochIndex, TournamentAddress: child.Address, Type: BondEventRecovered, + BlockNumber: 95, TxHash: repotest.UniqueHash(), LogIndex: 0, + Recovery: &BondRecovered{Commitment: childOne.Commitment, Claimer: childOne.SubmitterAddress, + Payment: Uint256{}, Burned: observerUint256(t, "18446744073709551616")}, + } + return r, app, []*repository.TournamentEventBatch{ + {Tournament: root, Commitments: []*Commitment{one, two}, Matches: []*Match{parentMatch}}, + {Tournament: child, Commitments: []*Commitment{childOne, childTwo}, Matches: []*Match{childMatch}, + MatchAdvances: []*MatchAdvanced{firstAdvance, &secondAdvance}, BondEvents: []*BondEvent{refund, recovery}}, + } +} + +func TestPassiveObserverEventsAndCurrentViewsRoundTrip(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + for _, batch := range batches { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + require.Equal(t, batch.Tournament.CreationEvent, stored.CreationEvent) + for _, commitment := range batch.Commitments { + got, err := r.GetCommitment( + t.Context(), app.Name, commitment.EpochIndex, commitment.TournamentAddress.Hex(), commitment.Commitment.Hex(), + ) + require.NoError(t, err) + require.Equal(t, commitment.Snapshot, got.Snapshot) + require.Equal(t, commitment.SubmitterAddress, got.SubmitterAddress) + require.Equal(t, commitment.LogIndex, got.LogIndex) + } + for _, match := range batch.Matches { + got, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, match.TournamentAddress.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.Snapshot, got.Snapshot) + require.Equal(t, match.LeafSeal, got.LeafSeal) + require.Equal(t, match.DeletionLogIndex, got.DeletionLogIndex) + require.Equal(t, match.EliminableAt, got.EliminableAt) + } + } + child := batches[1].Tournament + childMatch := batches[1].Matches[0] + advances, total, err := r.ListMatchAdvances( + t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), repository.Pagination{}, false, + ) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, advances[0].OtherParent, advances[1].OtherParent, "parent hashes are not event identities") + require.Equal(t, uint64(0), advances[0].LogIndex) + require.Equal(t, uint64(1), advances[1].LogIndex) + require.Equal(t, batches[1].MatchAdvances[0].SegmentStartPosition, advances[0].SegmentStartPosition) + for _, advance := range advances { + got, err := r.GetMatchAdvanced(t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), + advance.TxHash, advance.LogIndex) + require.NoError(t, err) + require.Equal(t, advance, got) + } + bonds, total, err := r.ListBondEvents(t.Context(), app.Name, repository.BondEventFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, batches[1].BondEvents[0].Refund, bonds[0].Refund) + require.False(t, bonds[0].Refund.Success, "a failed refund is a recorded request, not a transferred amount") + require.Equal(t, batches[1].BondEvents[1].Recovery, bonds[1].Recovery) + require.Zero(t, bonds[1].Recovery.Payment) + for _, bond := range bonds { + got, err := r.GetBondEvent(t.Context(), app.Name, bond.TxHash, bond.LogIndex) + require.NoError(t, err) + require.Equal(t, bond, got) + } + // Replay must retain event rows, their timestamps, and their log identities. + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + replayed, _, err := r.ListBondEvents(t.Context(), app.Name, repository.BondEventFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, bonds, replayed) + replayedAdvances, _, err := r.ListMatchAdvances( + t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), repository.Pagination{}, false, + ) + require.NoError(t, err) + require.Equal(t, advances, replayedAdvances) +} + +func TestPassiveObserverRefreshClearsExpiredInnerWinner(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child := *batches[1].Tournament + child.Snapshot.AsOfBlock = 150 + child.Snapshot.Standing = TournamentStandingInnerEliminableWinnerExpired + child.Snapshot.WinnerCommitment, child.Snapshot.FinalStateHash, child.Snapshot.ParentCommitment = nil, nil, nil + child.Snapshot.WinnerExpiresAt = 0 + child.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentEliminable} + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{Tournament: &child}}, 150)) + stored, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, child.Snapshot, stored.Snapshot) + require.NotNil(t, stored.Snapshot.Candidate) + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + parent, err := r.GetTournament(t.Context(), app.Name, batches[0].Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, parent.Snapshot.AsOfBlock, "a child-only batch does not relabel the parent view") + appState, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(150), appState.LastTournamentCheckBlock) +} + +func TestPassiveObserverEmptyWindowKeepsStoredViews(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, nil, observerCheckpoint+1)) + for _, batch := range batches { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + } + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint+1, storedApp.LastTournamentCheckBlock) +} + +func TestPassiveObserverAdvanceQueriesStayWithinMatch(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child, match := batches[1].Tournament, batches[1].Matches[0] + advance := batches[1].MatchAdvances[0] + otherApp := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + for _, scope := range []struct { + name string + appName string + epoch uint64 + tournament common.Address + match common.Hash + }{ + {"wrong application", otherApp.Name, child.EpochIndex, child.Address, match.IDHash}, + {"wrong epoch", app.Name, child.EpochIndex + 1, child.Address, match.IDHash}, + {"wrong tournament", app.Name, child.EpochIndex, batches[0].Tournament.Address, match.IDHash}, + {"wrong match", app.Name, child.EpochIndex, child.Address, batches[0].Matches[0].IDHash}, + } { + t.Run(scope.name, func(t *testing.T) { + got, err := r.GetMatchAdvanced(t.Context(), scope.appName, scope.epoch, scope.tournament.Hex(), scope.match.Hex(), + advance.TxHash, advance.LogIndex) + require.NoError(t, err) + require.Nil(t, got) + rows, total, err := r.ListMatchAdvances(t.Context(), scope.appName, scope.epoch, scope.tournament.Hex(), scope.match.Hex(), + repository.Pagination{}, false) + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, rows) + }) + } + for _, descending := range []bool{false, true} { + for offset := uint64(0); offset < 2; offset++ { + rows, total, err := r.ListMatchAdvances(t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), match.IDHash.Hex(), + repository.Pagination{Limit: 1, Offset: offset}, descending) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Len(t, rows, 1) + expectedLog := offset + if descending { + expectedLog = 1 - offset + } + require.Equal(t, expectedLog, rows[0].LogIndex) + } + } +} + +func TestPassiveObserverSQLMatchesModelEnums(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, enum := range []struct { + name string + values []string + }{ + {"TournamentKind", observerEnumStrings(TournamentKindAllValues)}, + {"TournamentStandingState", observerEnumStrings(TournamentStandingStateAllValues)}, + {"MatchPhase", observerEnumStrings(MatchPhaseAllValues)}, + {"CommitmentSide", observerEnumStrings(CommitmentSideAllValues)}, + {"MatchTimeoutOutcome", observerEnumStrings(MatchTimeoutOutcomeAllValues)}, + {"InnerTournamentDisposition", observerEnumStrings(InnerTournamentDispositionAllValues)}, + {"BondDisposition", observerEnumStrings(BondDispositionAllValues)}, + {"BondEventType", observerEnumStrings(BondEventTypeAllValues)}, + } { + t.Run(enum.name, func(t *testing.T) { + rows, err := r.db.Query(t.Context(), ` + SELECT enumlabel FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + WHERE pg_type.typname = $1 ORDER BY enumsortorder`, enum.name) + require.NoError(t, err) + labels, err := pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + require.Equal(t, enum.values, labels) + }) + } +} + +func observerEnumStrings[T ~string](values []T) []string { + labels := make([]string, len(values)) + for i, value := range values { + labels[i] = string(value) + } + return labels +} + +func TestPassiveObserverRejectsImmutableFactChangesAndOlderViews(t *testing.T) { + for _, test := range []struct { + name string + change func(*repository.TournamentEventBatch) + }{ + {"child creation", func(b *repository.TournamentEventBatch) { b.Tournament.CreationEvent.LogIndex++ }}, + {"commitment join", func(b *repository.TournamentEventBatch) { b.Commitments[0].FinalStateHash = repotest.UniqueHash() }}, + {"match creation", func(b *repository.TournamentEventBatch) { b.Matches[0].LeftOfTwo = repotest.UniqueHash() }}, + {"leaf seal", func(b *repository.TournamentEventBatch) { b.Matches[0].LeafSeal.EliminableAt++ }}, + {"match deletion", func(b *repository.TournamentEventBatch) { b.Matches[0].DeletionTxHash = new(repotest.UniqueHash()) }}, + {"older view", func(b *repository.TournamentEventBatch) { + b.Tournament.Snapshot.AsOfBlock = observerCheckpoint - 1 + }}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + batch := batches[1] + before, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + batch.Tournament.Snapshot.AsOfBlock++ + test.change(batch) + head := batch.Tournament.Snapshot.AsOfBlock + for _, commitment := range batch.Commitments { + commitment.Snapshot.AsOfBlock = head + } + for _, match := range batch.Matches { + match.Snapshot.AsOfBlock = head + } + err = r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, head) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + after, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, before, after, "the earlier tournament refresh must roll back with a later conflict") + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestPassiveObserverConflictingEventRollsBackSnapshot(t *testing.T) { + for _, event := range []string{"advance", "bond"} { + t.Run(event, func(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child := *batches[1].Tournament + child.Snapshot.AsOfBlock = 101 + child.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentWinner, + ParentCommitment: child.Snapshot.ParentCommitment, PausedAllowance: 49} + batch := &repository.TournamentEventBatch{Tournament: &child} + if event == "advance" { + advance := *batches[1].MatchAdvances[0] + advance.LeftNode = repotest.UniqueHash() + batch.MatchAdvances = []*MatchAdvanced{&advance} + } else { + bond := *batches[1].BondEvents[0] + refund := *bond.Refund + refund.Success = true + bond.Refund = &refund + batch.BondEvents = []*BondEvent{&bond} + } + err := r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, 101) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + stored, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batches[1].Tournament.Snapshot, stored.Snapshot) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestUint256DomainBoundsAndExactIntegerScale(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, text := range []string{"0", "1.00", "18446744073709551616", + "115792089237316195423570985008687907853269984665640564039457584007913129639935"} { + var value Uint256 + require.NoError(t, r.db.QueryRow(t.Context(), "SELECT $1::uint256", text).Scan(&value)) + } + for _, text := range []string{"-1", "0.5", "NaN", "Infinity", + "115792089237316195423570985008687907853269984665640564039457584007913129639936"} { + var value Uint256 + err := r.db.QueryRow(t.Context(), "SELECT $1::uint256", text).Scan(&value) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr, text) + require.Equal(t, "23514", pgErr.Code, text) + } +} + +func TestPassiveObserverMatchPhaseRoundTrip(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + tournament.Snapshot.Standing = TournamentStandingMatchesActive + for _, phase := range []MatchPhase{MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed, MatchPhaseUninitialized} { + t.Run(phase.String(), func(t *testing.T) { + head := tournament.Snapshot.AsOfBlock + 1 + tournament.Snapshot.AsOfBlock = head + tournament.Snapshot.AcceptsJoins = false + match.Snapshot.AsOfBlock = head + match.Snapshot.Phase = phase + switch phase { + case MatchPhaseBisecting: + match.Snapshot.Bisection.SegmentStartPosition = observerUint256(t, "18446744073709551616") + match.Snapshot.Bisection.SegmentStartCycle = observerUint256(t, "18446744073709551617") + case MatchPhaseReadyToSeal: + match.Snapshot.Bisection.CurrentHeight = nil + case MatchPhaseSealed: + match.Snapshot.Bisection = nil + match.Snapshot.Sealed = &MatchSealedSnapshot{AgreeState: common.Hash{}, + DivergencePosition: observerUint256(t, "18446744073709551616"), + DivergenceCycle: observerUint256(t, "18446744073709551617"), + FinalStateOne: repotest.UniqueHash(), FinalStateTwo: repotest.UniqueHash()} + case MatchPhaseUninitialized: + match.Snapshot.Sealed = nil + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber, match.DeletionTxHash, match.DeletionLogIndex = head, new(common.Hash), new(uint64(0)) + tournament.Snapshot.Standing = TournamentStandingRootFailed + tournament.Snapshot.FinishedAtBlock = head + tournament.Snapshot.BondRecovery.Disposition = BondDispositionNoWinner + } + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, + []*repository.TournamentEventBatch{{Tournament: tournament, Matches: []*Match{match}}}, head)) + stored, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.Snapshot, stored.Snapshot) + }) + } +} + +func TestPassiveObserverRejectsInvalidPhasePayload(t *testing.T) { + r, app, _, initial := newTournamentIntegrityFixture(t) + for _, test := range []struct { + name string + change func(*Match) + }{ + {"bisecting without payload", func(m *Match) { m.Snapshot.Bisection = nil }}, + {"ready with height", func(m *Match) { m.Snapshot.Phase = MatchPhaseReadyToSeal }}, + {"sealed with bisection", func(m *Match) { m.Snapshot.Phase = MatchPhaseSealed }}, + {"uninitialized without deletion", func(m *Match) { m.Snapshot.Phase = MatchPhaseUninitialized }}, + } { + t.Run(test.name, func(t *testing.T) { + match := *initial + test.change(&match) + err := r.CreateMatch(t.Context(), app.Name, &match) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "matches_current_phase_check", pgErr.ConstraintName) + }) + } +} + +func TestPassiveObserverEpochFilterIncludesTerminalEpochs(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + accepted := repotest.NewEpochBuilder(app.ID).WithIndex(0).WithStatus(EpochStatus_Closed).Build() + closed := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed).Build() + open := repotest.NewEpochBuilder(app.ID).WithIndex(2).WithStatus(EpochStatus_Open).Build() + closed.TournamentAddress, accepted.TournamentAddress = new(repotest.UniqueAddress()), new(repotest.UniqueAddress()) + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, + map[*Epoch][]*Input{closed: {}, accepted: {}, open: {}}, observerCheckpoint)) + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, accepted, EpochStatus_ClaimAccepted) + epochs, total, err := r.ListEpochs(t.Context(), app.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, EpochStatus_ClaimAccepted, epochs[0].Status) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) +} diff --git a/internal/repository/postgres/postgres_repo_test.go b/internal/repository/postgres/postgres_repo_test.go index a182beae6..e7badb604 100644 --- a/internal/repository/postgres/postgres_repo_test.go +++ b/internal/repository/postgres/postgres_repo_test.go @@ -53,6 +53,10 @@ func TestPostgresRepository(t *testing.T) { } func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { + const ( + advanceMaxCyclesColumn = "advance_max_cycles" + inspectMaxCyclesColumn = "inspect_max_cycles" + ) endpoint, err := db.GetTestDatabaseEndpoint() if err != nil { t.Skipf("Skipping: %v", err) @@ -103,6 +107,21 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { "UNEXPECTED_YIELD", }, labels) + rows, err = conn.Query(ctx, ` + SELECT enumlabel + FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + WHERE pg_type.typname = 'EpochStatus' + ORDER BY enumsortorder`) + require.NoError(t, err) + labels, err = pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + expectedEpochLabels := make([]string, len(model.EpochStatusAllValues)) + for i, status := range model.EpochStatusAllValues { + expectedEpochLabels[i] = status.String() + } + require.Equal(t, expectedEpochLabels, labels) + rows, err = conn.Query(ctx, ` SELECT column_name FROM information_schema.columns @@ -110,8 +129,8 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { require.NoError(t, err) columns, err := pgx.CollectRows(rows, pgx.RowTo[string]) require.NoError(t, err) - require.Contains(t, columns, "advance_max_cycles") - require.Contains(t, columns, "inspect_max_cycles") + require.Contains(t, columns, advanceMaxCyclesColumn) + require.Contains(t, columns, inspectMaxCyclesColumn) require.Contains(t, columns, "advance_inc_cycles") require.Contains(t, columns, "inspect_inc_cycles") @@ -131,7 +150,7 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { require.Contains(t, epochColumns, column) } - for _, column := range []string{"advance_max_cycles", "inspect_max_cycles"} { + for _, column := range []string{advanceMaxCyclesColumn, inspectMaxCyclesColumn} { var defaultValue string err := conn.QueryRow(ctx, ` SELECT column_default @@ -146,7 +165,7 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { require.NoError(t, err) t.Cleanup(repo.Close) app := repotest.NewApplicationBuilder().Create(ctx, t, repo) - for _, column := range []string{"advance_max_cycles", "inspect_max_cycles"} { + for _, column := range []string{advanceMaxCyclesColumn, inspectMaxCyclesColumn} { for _, value := range []int64{0, int64(model.MaxExecutionCycleSpan)} { _, err := conn.Exec(ctx, fmt.Sprintf( `UPDATE execution_parameters SET %s = $1 WHERE application_id = $2`, @@ -161,10 +180,10 @@ func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { }{ {"advance_inc_cycles", 0}, {"inspect_inc_cycles", 0}, - {"advance_max_cycles", -1}, - {"inspect_max_cycles", -1}, - {"advance_max_cycles", int64(model.MaxExecutionCycles)}, - {"inspect_max_cycles", int64(model.MaxExecutionCycles)}, + {advanceMaxCyclesColumn, -1}, + {inspectMaxCyclesColumn, -1}, + {advanceMaxCyclesColumn, int64(model.MaxExecutionCycles)}, + {inspectMaxCyclesColumn, int64(model.MaxExecutionCycles)}, } { _, err := conn.Exec(ctx, fmt.Sprintf( `UPDATE execution_parameters SET %s = $1 WHERE application_id = $2`, diff --git a/internal/repository/postgres/prt_rows.go b/internal/repository/postgres/prt_rows.go new file mode 100644 index 000000000..2c1168f42 --- /dev/null +++ b/internal/repository/postgres/prt_rows.go @@ -0,0 +1,529 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func nullableAddressBytes(value *common.Address) any { + if value == nil { + return nil + } + return value.Bytes() +} + +var tournamentColumns = postgres.ColumnList{ + table.Tournaments.ApplicationID, + table.Tournaments.EpochIndex, + table.Tournaments.Address, + table.Tournaments.ParentTournamentAddress, + table.Tournaments.ParentMatchIDHash, + table.Tournaments.MaxLevel, + table.Tournaments.Level, + table.Tournaments.Log2step, + table.Tournaments.Height, + table.Tournaments.InitialHash, + table.Tournaments.BaseCycle, + table.Tournaments.Kind, + table.Tournaments.StartInstant, + table.Tournaments.Allowance, + table.Tournaments.CreationBlockNumber, + table.Tournaments.CreationTxHash, + table.Tournaments.CreationLogIndex, + table.Tournaments.AsOfBlock, + table.Tournaments.Standing, + table.Tournaments.AcceptsJoins, + table.Tournaments.Candidate, + table.Tournaments.WinnerCommitment, + table.Tournaments.FinalStateHash, + table.Tournaments.FinishedAtBlock, + table.Tournaments.ParentCommitment, + table.Tournaments.WinnerExpiresAt, + table.Tournaments.InnerDisposition, + table.Tournaments.InnerParentCommitment, + table.Tournaments.InnerPausedAllowance, + table.Tournaments.BondDisposition, + table.Tournaments.BondClaimer, + table.Tournaments.BondPayment, +} + +func tournamentValues(v *Tournament) []any { + var creationBlock, creationTx, creationLog any + if v.CreationEvent != nil { + creationBlock = uint64Expr(v.CreationEvent.BlockNumber) + creationTx = v.CreationEvent.TxHash.Bytes() + creationLog = uint64Expr(v.CreationEvent.LogIndex) + } + var innerDisposition, innerParent, innerAllowance any + if v.Snapshot.InnerResult != nil { + innerDisposition = v.Snapshot.InnerResult.Disposition + innerParent = hashToBytes(v.Snapshot.InnerResult.ParentCommitment) + innerAllowance = uint64Expr(v.Snapshot.InnerResult.PausedAllowance) + } + var bondPayment any + if v.Snapshot.BondRecovery.Payment != nil { + bondPayment = *v.Snapshot.BondRecovery.Payment + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.Address.Bytes(), + nullableAddressBytes(v.ParentTournamentAddress), + hashToBytes(v.ParentMatchIDHash), + uint64Expr(v.MaxLevel), + uint64Expr(v.Level), + uint64Expr(v.Log2Step), + uint64Expr(v.Height), + v.InitialHash.Bytes(), + v.BaseCycle, + v.Kind, + uint64Expr(v.StartInstant), + uint64Expr(v.Allowance), + creationBlock, + creationTx, + creationLog, + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Standing, + v.Snapshot.AcceptsJoins, + hashToBytes(v.Snapshot.Candidate), + hashToBytes(v.Snapshot.WinnerCommitment), + hashToBytes(v.Snapshot.FinalStateHash), + uint64Expr(v.Snapshot.FinishedAtBlock), + hashToBytes(v.Snapshot.ParentCommitment), + uint64Expr(v.Snapshot.WinnerExpiresAt), + innerDisposition, + innerParent, + innerAllowance, + v.Snapshot.BondRecovery.Disposition, + nullableAddressBytes(v.Snapshot.BondRecovery.Claimer), + bondPayment, + } +} + +func scanTournament(row pgx.Row) (*Tournament, error) { + var v Tournament + var creationBlock, creationLog *uint64 + var creationTx *common.Hash + var innerDisposition *InnerTournamentDisposition + var innerParent *common.Hash + var innerAllowance *uint64 + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.Address, + &v.ParentTournamentAddress, + &v.ParentMatchIDHash, + &v.MaxLevel, + &v.Level, + &v.Log2Step, + &v.Height, + &v.InitialHash, + &v.BaseCycle, + &v.Kind, + &v.StartInstant, + &v.Allowance, + &creationBlock, + &creationTx, + &creationLog, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Standing, + &v.Snapshot.AcceptsJoins, + &v.Snapshot.Candidate, + &v.Snapshot.WinnerCommitment, + &v.Snapshot.FinalStateHash, + &v.Snapshot.FinishedAtBlock, + &v.Snapshot.ParentCommitment, + &v.Snapshot.WinnerExpiresAt, + &innerDisposition, + &innerParent, + &innerAllowance, + &v.Snapshot.BondRecovery.Disposition, + &v.Snapshot.BondRecovery.Claimer, + &v.Snapshot.BondRecovery.Payment, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if creationBlock != nil { + v.CreationEvent = &TournamentCreationEvent{BlockNumber: *creationBlock, TxHash: *creationTx, LogIndex: *creationLog} + } + if innerDisposition != nil { + v.Snapshot.InnerResult = &TournamentInnerResult{ + Disposition: *innerDisposition, ParentCommitment: innerParent, PausedAllowance: *innerAllowance, + } + } + return &v, nil +} + +var commitmentColumns = postgres.ColumnList{ + table.Commitments.ApplicationID, + table.Commitments.EpochIndex, + table.Commitments.TournamentAddress, + table.Commitments.Commitment, + table.Commitments.FinalStateHash, + table.Commitments.SubmitterAddress, + table.Commitments.BlockNumber, + table.Commitments.TxHash, + table.Commitments.LogIndex, + table.Commitments.AsOfBlock, + table.Commitments.Claimer, + table.Commitments.ClockRunning, + table.Commitments.ClockDeadline, + table.Commitments.ClockAllowance, +} + +func commitmentValues(v *Commitment) []any { + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.Commitment.Bytes(), + v.FinalStateHash.Bytes(), + v.SubmitterAddress.Bytes(), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Claimer.Bytes(), + v.Snapshot.ClockRunning, + uint64Expr(v.Snapshot.ClockDeadline), + uint64Expr(v.Snapshot.ClockAllowance), + } +} + +func scanCommitment(row pgx.Row) (*Commitment, error) { + var v Commitment + + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.Commitment, + &v.FinalStateHash, + &v.SubmitterAddress, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Claimer, + &v.Snapshot.ClockRunning, + &v.Snapshot.ClockDeadline, + &v.Snapshot.ClockAllowance, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + + return &v, nil +} + +var matchColumns = postgres.ColumnList{ + table.Matches.ApplicationID, + table.Matches.EpochIndex, + table.Matches.TournamentAddress, + table.Matches.IDHash, + table.Matches.CommitmentOne, + table.Matches.CommitmentTwo, + table.Matches.LeftOfTwo, + table.Matches.BlockNumber, + table.Matches.TxHash, + table.Matches.LogIndex, + table.Matches.EliminableAt, + table.Matches.SealEliminableAt, + table.Matches.SealBlockNumber, + table.Matches.SealTxHash, + table.Matches.SealLogIndex, + table.Matches.AsOfBlock, + table.Matches.Phase, + table.Matches.TimeoutOutcome, + table.Matches.DeferredCharge, + table.Matches.RevealingParent, + table.Matches.WaitingLeft, + table.Matches.WaitingRight, + table.Matches.SegmentStartPosition, + table.Matches.SegmentStartCycle, + table.Matches.CurrentHeight, + table.Matches.Responder, + table.Matches.AgreeState, + table.Matches.DivergencePosition, + table.Matches.DivergenceCycle, + table.Matches.FinalStateOne, + table.Matches.FinalStateTwo, + table.Matches.Winner, + table.Matches.DeletionReason, + table.Matches.DeletionBlockNumber, + table.Matches.DeletionTxHash, + table.Matches.DeletionLogIndex, +} + +func matchValues(v *Match) []any { + var sealEliminable, sealBlock, sealTx, sealLog any + if v.LeafSeal != nil { + sealEliminable = uint64Expr(v.LeafSeal.EliminableAt) + sealBlock = uint64Expr(v.LeafSeal.BlockNumber) + sealTx = v.LeafSeal.TxHash.Bytes() + sealLog = uint64Expr(v.LeafSeal.LogIndex) + } + var revealingParent, waitingLeft, waitingRight, segmentPosition, segmentCycle, currentHeight, responder any + if s := v.Snapshot.Bisection; s != nil { + revealingParent, waitingLeft, waitingRight = s.RevealingParent.Bytes(), s.WaitingLeft.Bytes(), s.WaitingRight.Bytes() + segmentPosition, segmentCycle, responder = s.SegmentStartPosition, s.SegmentStartCycle, s.Responder + if s.CurrentHeight != nil { + currentHeight = uint64Expr(*s.CurrentHeight) + } + } + var agreeState, divergencePosition, divergenceCycle, finalStateOne, finalStateTwo any + if s := v.Snapshot.Sealed; s != nil { + agreeState, finalStateOne, finalStateTwo = s.AgreeState.Bytes(), s.FinalStateOne.Bytes(), s.FinalStateTwo.Bytes() + divergencePosition, divergenceCycle = s.DivergencePosition, s.DivergenceCycle + } + var deletionLog any + if v.DeletionLogIndex != nil { + deletionLog = uint64Expr(*v.DeletionLogIndex) + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.IDHash.Bytes(), + v.CommitmentOne.Bytes(), + v.CommitmentTwo.Bytes(), + v.LeftOfTwo.Bytes(), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + uint64Expr(v.EliminableAt), + sealEliminable, + sealBlock, + sealTx, + sealLog, + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Phase, + v.Snapshot.TimeoutOutcome, + uint64Expr(v.Snapshot.DeferredCharge), + revealingParent, + waitingLeft, + waitingRight, + segmentPosition, + segmentCycle, + currentHeight, + responder, + agreeState, + divergencePosition, + divergenceCycle, + finalStateOne, + finalStateTwo, + v.Winner, + v.DeletionReason, + uint64Expr(v.DeletionBlockNumber), + hashToBytes(v.DeletionTxHash), + deletionLog, + } +} + +func scanMatch(row pgx.Row) (*Match, error) { + var v Match + var sealEliminable, sealBlock, sealLog *uint64 + var sealTx *common.Hash + var revealingParent, waitingLeft, waitingRight *common.Hash + var segmentPosition, segmentCycle *Uint256 + var currentHeight *uint64 + var responder *CommitmentSide + var agreeState, finalStateOne, finalStateTwo *common.Hash + var divergencePosition, divergenceCycle *Uint256 + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.IDHash, + &v.CommitmentOne, + &v.CommitmentTwo, + &v.LeftOfTwo, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.EliminableAt, + &sealEliminable, + &sealBlock, + &sealTx, + &sealLog, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Phase, + &v.Snapshot.TimeoutOutcome, + &v.Snapshot.DeferredCharge, + &revealingParent, + &waitingLeft, + &waitingRight, + &segmentPosition, + &segmentCycle, + ¤tHeight, + &responder, + &agreeState, + &divergencePosition, + &divergenceCycle, + &finalStateOne, + &finalStateTwo, + &v.Winner, + &v.DeletionReason, + &v.DeletionBlockNumber, + &v.DeletionTxHash, + &v.DeletionLogIndex, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if sealBlock != nil { + v.LeafSeal = &LeafMatchSeal{EliminableAt: *sealEliminable, BlockNumber: *sealBlock, TxHash: *sealTx, LogIndex: *sealLog} + } + if revealingParent != nil { + v.Snapshot.Bisection = &MatchBisectionSnapshot{ + RevealingParent: *revealingParent, WaitingLeft: *waitingLeft, WaitingRight: *waitingRight, + SegmentStartPosition: *segmentPosition, SegmentStartCycle: *segmentCycle, CurrentHeight: currentHeight, Responder: *responder, + } + } + if agreeState != nil { + v.Snapshot.Sealed = &MatchSealedSnapshot{ + AgreeState: *agreeState, DivergencePosition: *divergencePosition, DivergenceCycle: *divergenceCycle, + FinalStateOne: *finalStateOne, FinalStateTwo: *finalStateTwo, + } + } + return &v, nil +} + +var matchAdvanceColumns = postgres.ColumnList{ + table.MatchAdvances.ApplicationID, + table.MatchAdvances.EpochIndex, + table.MatchAdvances.TournamentAddress, + table.MatchAdvances.IDHash, + table.MatchAdvances.OtherParent, + table.MatchAdvances.LeftNode, + table.MatchAdvances.SegmentStartPosition, + table.MatchAdvances.EliminableAt, + table.MatchAdvances.BlockNumber, + table.MatchAdvances.TxHash, + table.MatchAdvances.LogIndex, +} + +func matchAdvanceValues(v *MatchAdvanced) []any { + + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.IDHash.Bytes(), + v.OtherParent.Bytes(), + v.LeftNode.Bytes(), + v.SegmentStartPosition, + uint64Expr(v.EliminableAt), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + } +} + +func scanMatchAdvanced(row pgx.Row) (*MatchAdvanced, error) { + var v MatchAdvanced + + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.IDHash, + &v.OtherParent, + &v.LeftNode, + &v.SegmentStartPosition, + &v.EliminableAt, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + + return &v, nil +} + +var bondEventColumns = postgres.ColumnList{ + table.BondEvents.ApplicationID, + table.BondEvents.EpochIndex, + table.BondEvents.TournamentAddress, + table.BondEvents.Type, + table.BondEvents.BlockNumber, + table.BondEvents.TxHash, + table.BondEvents.LogIndex, + table.BondEvents.Recipient, + table.BondEvents.Value, + table.BondEvents.Success, + table.BondEvents.Commitment, + table.BondEvents.Claimer, + table.BondEvents.Payment, + table.BondEvents.Burned, +} + +func bondEventValues(v *BondEvent) []any { + var recipient, value, success, commitment, claimer, payment, burned any + if v.Refund != nil { + recipient, value, success = v.Refund.Recipient.Bytes(), v.Refund.Value, v.Refund.Success + } + if v.Recovery != nil { + commitment, claimer = v.Recovery.Commitment.Bytes(), v.Recovery.Claimer.Bytes() + payment, burned = v.Recovery.Payment, v.Recovery.Burned + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.Type, + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + recipient, + value, + success, + commitment, + claimer, + payment, + burned, + } +} + +func scanBondEvent(row pgx.Row) (*BondEvent, error) { + var v BondEvent + var recipient, claimer *common.Address + var value, payment, burned *Uint256 + var success *bool + var commitment *common.Hash + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.Type, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &recipient, + &value, + &success, + &commitment, + &claimer, + &payment, + &burned, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if recipient != nil { + v.Refund = &PartialBondRefund{Recipient: *recipient, Value: *value, Success: *success} + } + if commitment != nil { + v.Recovery = &BondRecovered{Commitment: *commitment, Claimer: *claimer, Payment: *payment, Burned: *burned} + } + return &v, nil +} diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql index 553bd3ecf..bd6f506d9 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql @@ -7,6 +7,8 @@ DROP TRIGGER IF EXISTS "state_hashes_set_updated_at" ON "state_hashes"; DROP INDEX IF EXISTS "state_hashes_input_index_idx"; DROP TABLE IF EXISTS "state_hashes"; +DROP TABLE IF EXISTS "bond_events"; + DROP TRIGGER IF EXISTS "match_advances_set_updated_at" ON "match_advances"; DROP INDEX IF EXISTS "match_advances_block_number_idx"; DROP TABLE IF EXISTS "match_advances"; @@ -23,6 +25,7 @@ DROP TABLE IF EXISTS "commitments"; DROP TRIGGER IF EXISTS "tournaments_set_updated_at" ON "tournaments"; DROP INDEX IF EXISTS "tournaments_parent_match_nonroot_idx"; +DROP INDEX IF EXISTS "tournaments_address_idx"; DROP INDEX IF EXISTS "unique_root_per_epoch_idx"; DROP TABLE IF EXISTS "tournaments"; @@ -68,7 +71,6 @@ DROP TABLE IF EXISTS "execution_parameters"; DROP TRIGGER IF EXISTS "application_validate_status_transition" ON "application"; DROP TRIGGER IF EXISTS "application_set_updated_at" ON "application"; -DROP INDEX IF EXISTS "application_data_availability_selector_idx"; DROP INDEX IF EXISTS "application_foreclosed_idx"; DROP TABLE IF EXISTS "application"; @@ -77,6 +79,14 @@ DROP FUNCTION IF EXISTS "update_updated_at_column"; DROP FUNCTION IF EXISTS "check_hash_siblings"; DROP TYPE IF EXISTS "WinnerCommitment"; +DROP TYPE IF EXISTS "TournamentKind"; +DROP TYPE IF EXISTS "TournamentStandingState"; +DROP TYPE IF EXISTS "MatchPhase"; +DROP TYPE IF EXISTS "CommitmentSide"; +DROP TYPE IF EXISTS "MatchTimeoutOutcome"; +DROP TYPE IF EXISTS "InnerTournamentDisposition"; +DROP TYPE IF EXISTS "BondDisposition"; +DROP TYPE IF EXISTS "BondEventType"; DROP TYPE IF EXISTS "MatchDeletionReason"; DROP TYPE IF EXISTS "Consensus"; DROP TYPE IF EXISTS "SnapshotPolicy"; @@ -84,9 +94,9 @@ DROP TYPE IF EXISTS "EpochStatus"; DROP TYPE IF EXISTS "DefaultBlock"; DROP TYPE IF EXISTS "InputCompletionStatus"; DROP TYPE IF EXISTS "ApplicationStatus"; -DROP DOMAIN IF EXISTS "data_availability"; DROP DOMAIN IF EXISTS "hash"; DROP DOMAIN IF EXISTS "uint64"; +DROP DOMAIN IF EXISTS "uint256"; DROP DOMAIN IF EXISTS "ethereum_address"; COMMIT; diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql index ef342341f..329496163 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql @@ -6,7 +6,6 @@ BEGIN; CREATE DOMAIN "ethereum_address" AS BYTEA CHECK (octet_length(VALUE) = 20); CREATE DOMAIN "uint64" AS NUMERIC(20, 0) CHECK (VALUE >= 0 AND VALUE <= 18446744073709551615); CREATE DOMAIN "hash" AS BYTEA CHECK (octet_length(VALUE) = 32); -CREATE DOMAIN "data_availability" AS BYTEA CHECK (octet_length(VALUE) >= 4); CREATE TYPE "ApplicationStatus" AS ENUM ( 'OK', @@ -106,7 +105,6 @@ CREATE TABLE "application" "withdrawal_log2_max_num_of_accounts" SMALLINT NOT NULL DEFAULT 0 CHECK ("withdrawal_log2_max_num_of_accounts" BETWEEN 0 AND 255), "withdrawal_accounts_drive_start_index" uint64 NOT NULL DEFAULT 0, "withdrawal_output_builder" ethereum_address NOT NULL DEFAULT '\x0000000000000000000000000000000000000000', - "data_availability" data_availability NOT NULL, "consensus_type" "Consensus" NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT true, "status" "ApplicationStatus" NOT NULL DEFAULT 'OK', @@ -161,7 +159,6 @@ CREATE TABLE "application" CONSTRAINT "application_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "application_data_availability_selector_idx" ON "application"(substring("data_availability" FROM 1 for 4)); -- Supports ListApplications(ForeclosureRecorded = true), used by the claimer's -- listEnabledForeclosedNonPRTApps once per tick. The filtered set is small -- (foreclosed apps), so a partial index keyed on foreclose_block > 0 keeps @@ -350,8 +347,8 @@ CREATE INDEX "epoch_status_idx" ON "epoch"("application_id", "status"); -- scan an index-only lookup; the bare epoch_status_idx covers the status -- filter but adds a per-row comparison on first_block. -- --- The status list below MUST match unreconciledEpochStatuses in --- internal/repository/postgres/epoch.go. If they drift, the query stops +-- The status list below MUST match NonTerminalEpochStatuses in +-- internal/model/models.go. If they drift, the query stops -- matching this partial index and silently degrades to a full table scan. CREATE INDEX "epoch_unreconciled_idx" ON "epoch"("application_id", "first_block") WHERE "status" IN ('OPEN','CLOSED','INPUTS_PROCESSED', @@ -364,8 +361,8 @@ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- The state machine is: -- OPEN → CLOSED → INPUTS_PROCESSED → CLAIM_COMPUTED -- CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_STAGED → CLAIM_ACCEPTED (v3 normal) --- CLAIM_COMPUTED → CLAIM_STAGED (restart recovery: chain at STAGED before we submitted) --- CLAIM_COMPUTED → CLAIM_ACCEPTED (PRT skips SUBMITTED; also valid in deep reader-mode catch-up) +-- CLAIM_COMPUTED → CLAIM_STAGED (chain already staged, including Dave/PRT) +-- CLAIM_COMPUTED → CLAIM_ACCEPTED (deep reader-mode catch-up, including PRT) -- CLAIM_COMPUTED → CLAIM_REJECTED (conflicting Quorum claim staged/accepted before we submitted) -- CLAIM_SUBMITTED → CLAIM_REJECTED (we submitted, then a different Quorum claim was staged/accepted) -- OPEN → CLAIM_FORECLOSED (guardian foreclosed before epoch could finish) @@ -383,9 +380,9 @@ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- transitioning to CLAIM_COMPUTED, this trigger additionally requires the -- PRT commitment and commitment proof. -- --- CLAIM_STAGED is NEVER valid for PRT apps (PRT settles via tournaments, --- not the staging flow). The trigger rejects this regardless of which --- transition led to it. +-- Dave/PRT also uses CLAIM_STAGED while its tournament result waits for +-- sentry agreement or the claim-staging period. Like Authority/Quorum, every +-- CLAIM_STAGED row must record the corresponding on-chain staging block. CREATE FUNCTION enforce_epoch_status_transition() RETURNS trigger AS $$ DECLARE valid_transitions text[][] := ARRAY[ @@ -441,25 +438,14 @@ BEGIN END IF; END IF; - -- Enforce CLAIM_STAGED is never valid for PRT consensus, and that - -- staged_at_block is set when entering CLAIM_STAGED. The - -- staged_requires_block table CHECK constraint also enforces the latter; - -- this trigger gives a clearer error message on the state-machine path. + -- staged_at_block is required when entering CLAIM_STAGED. The + -- staged_requires_block table CHECK constraint also enforces this; the + -- trigger gives a clearer error message on the state-machine path. IF NEW.status::text = 'CLAIM_STAGED' THEN IF NEW.staged_at_block IS NULL THEN RAISE EXCEPTION 'CLAIM_STAGED requires staged_at_block to be non-null'; END IF; - - SELECT a.consensus_type::text INTO app_consensus - FROM application a - WHERE a.id = NEW.application_id; - - IF app_consensus = 'PRT' THEN - RAISE EXCEPTION - 'CLAIM_STAGED is not valid for PRT consensus ' - '(PRT settles via tournaments, not the staging flow)'; - END IF; END IF; RETURN NEW; @@ -493,6 +479,9 @@ CREATE TABLE "input" ("status" <> 'EXCEPTION' AND "exception_data" IS NULL) ), CONSTRAINT "input_pkey" PRIMARY KEY ("epoch_application_id", "index"), + -- The state_hashes foreign key references this exact three-column key + -- to verify that the input belongs to the stated epoch. A plain index + -- cannot replace the unique key required by that foreign key. CONSTRAINT "input_epoch_index_unique" UNIQUE ("epoch_application_id", "epoch_index", "index"), CONSTRAINT "input_application_id_tx_hash_log_index_unique" UNIQUE ("epoch_application_id", "transaction_hash", "log_index"), CONSTRAINT "input_completed_hashes_check" CHECK ( @@ -643,6 +632,66 @@ CREATE TABLE "node_config" CREATE TRIGGER "config_set_updated_at" BEFORE UPDATE ON "node_config" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE DOMAIN uint256 AS NUMERIC CHECK ( + VALUE >= 0 AND VALUE = trunc(VALUE) AND VALUE <= + 115792089237316195423570985008687907853269984665640564039457584007913129639935 +); + +CREATE TYPE "TournamentKind" AS ENUM ( + 'LEAF', + 'NON_LEAF' +); + +CREATE TYPE "TournamentStandingState" AS ENUM ( + 'MATCHES_ACTIVE', + 'AWAITING_CLOSURE', + 'ROOT_WINNER', + 'ROOT_FAILED', + 'INNER_WINNER', + 'INNER_ELIMINABLE_NO_WINNER', + 'INNER_ELIMINABLE_WINNER_EXPIRED' +); + +CREATE TYPE "MatchPhase" AS ENUM ( + 'UNINITIALIZED', + 'BISECTING', + 'READY_TO_SEAL', + 'SEALED' +); + +CREATE TYPE "CommitmentSide" AS ENUM ( + 'ONE', + 'TWO' +); + +CREATE TYPE "MatchTimeoutOutcome" AS ENUM ( + 'NONE', + 'ONE_WINS', + 'TWO_WINS', + 'ELIMINATE_BOTH' +); + +CREATE TYPE "InnerTournamentDisposition" AS ENUM ( + 'UNSETTLED', + 'WINNER', + 'ELIMINABLE' +); + +CREATE TYPE "BondDisposition" AS ENUM ( + 'TOURNAMENT_RUNNING', + 'NO_WINNER', + 'RECOVERABLE', + 'RECOVERED' +); + +CREATE TYPE "BondEventType" AS ENUM ( + 'PARTIAL_BOND_REFUND', + 'BOND_RECOVERED' +); + +-- Tournament, commitment, and match views describe current state at their +-- as_of_block. These rows are not a historical snapshot API. Event facts are +-- immutable and are published atomically with each current-state window. CREATE TABLE "tournaments" ( "application_id" INT4 NOT NULL, @@ -650,13 +699,33 @@ CREATE TABLE "tournaments" "address" ethereum_address NOT NULL, "parent_tournament_address" ethereum_address, "parent_match_id_hash" hash, - "max_level" INT NOT NULL CHECK("max_level" >= 0), + "max_level" INT NOT NULL CHECK("max_level" > 0), "level" INT NOT NULL CHECK("level" >= 0), "log2step" INT NOT NULL CHECK("log2step" >= 0), "height" INT NOT NULL CHECK("height" >= 0), + "initial_hash" hash NOT NULL, + "base_cycle" uint256 NOT NULL, + "kind" "TournamentKind" NOT NULL, + "start_instant" uint64 NOT NULL, + "allowance" uint64 NOT NULL, + "creation_block_number" uint64, + "creation_tx_hash" hash, + "creation_log_index" uint64, + "as_of_block" uint64 NOT NULL, + "standing" "TournamentStandingState" NOT NULL, + "accepts_joins" BOOLEAN NOT NULL, + "candidate" hash, "winner_commitment" hash, "final_state_hash" hash, - "finished_at_block" uint64 DEFAULT 0, + "finished_at_block" uint64 NOT NULL DEFAULT 0, + "parent_commitment" hash, + "winner_expires_at" uint64 NOT NULL, + "inner_disposition" "InnerTournamentDisposition", + "inner_parent_commitment" hash, + "inner_paused_allowance" uint64, + "bond_disposition" "BondDisposition" NOT NULL, + "bond_claimer" ethereum_address, + "bond_payment" uint256, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "tournaments_pkey" PRIMARY KEY ("application_id","epoch_index","address"), @@ -669,13 +738,61 @@ CREATE TABLE "tournaments" OR ("level" > 0 AND "parent_tournament_address" IS NOT NULL AND "parent_match_id_hash" IS NOT NULL) ), - CONSTRAINT "tournaments_max_level_gte_level_check" CHECK ("max_level" >= "level") + CONSTRAINT "tournaments_max_level_gt_level_check" CHECK ("max_level" > "level"), + CONSTRAINT "tournaments_creation_event_check" CHECK ( + ("level" = 0 AND num_nonnulls("creation_block_number", "creation_tx_hash", "creation_log_index") = 0) + OR + ("level" > 0 AND num_nonnulls("creation_block_number", "creation_tx_hash", "creation_log_index") = 3) + ), + CONSTRAINT "tournaments_current_winner_check" CHECK ( + ("standing" IN ('ROOT_WINNER', 'INNER_WINNER') AND "candidate" IS NOT NULL + AND "winner_commitment" IS NOT NULL AND "winner_commitment" = "candidate" AND "final_state_hash" IS NOT NULL) + OR + ("standing" NOT IN ('ROOT_WINNER', 'INNER_WINNER') AND "winner_commitment" IS NULL AND "final_state_hash" IS NULL) + ), + CONSTRAINT "tournaments_current_result_check" CHECK ( + ("standing" IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE') AND "finished_at_block" = 0) + OR + ("standing" NOT IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE') AND "finished_at_block" > 0 + AND "finished_at_block" <= "as_of_block") + ), + CONSTRAINT "tournaments_inner_winner_check" CHECK ( + ("standing" = 'INNER_WINNER' AND "level" > 0 AND "parent_commitment" IS NOT NULL + AND "winner_expires_at" > "as_of_block") + OR + ("standing" <> 'INNER_WINNER' AND "parent_commitment" IS NULL AND "winner_expires_at" = 0) + ), + CONSTRAINT "tournaments_standing_level_check" CHECK ( + ("level" = 0 AND "standing" IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE', 'ROOT_WINNER', 'ROOT_FAILED')) + OR + ("level" > 0 AND "standing" NOT IN ('ROOT_WINNER', 'ROOT_FAILED')) + ), + CONSTRAINT "tournaments_inner_result_check" CHECK ( + ("level" = 0 AND num_nonnulls("inner_disposition", "inner_parent_commitment", "inner_paused_allowance") = 0) + OR + ("level" > 0 AND "inner_disposition" IS NOT NULL AND "inner_paused_allowance" IS NOT NULL AND ( + ("inner_disposition" = 'WINNER' AND "inner_parent_commitment" IS NOT NULL AND "inner_paused_allowance" > 0) + OR + ("inner_disposition" <> 'WINNER' AND "inner_parent_commitment" IS NULL AND "inner_paused_allowance" = 0) + )) + ), + CONSTRAINT "tournaments_bond_recovery_check" CHECK ( + ("bond_disposition" = 'RECOVERABLE' AND "bond_claimer" IS NOT NULL AND "bond_payment" IS NOT NULL) + OR + ("bond_disposition" <> 'RECOVERABLE' AND "bond_claimer" IS NULL AND "bond_payment" IS NULL) + ), + CONSTRAINT "tournaments_creation_event_key" UNIQUE ("application_id", "creation_tx_hash", "creation_log_index") ); CREATE UNIQUE INDEX "unique_root_per_epoch_idx" ON "tournaments"("application_id","epoch_index") WHERE "level" = 0; +-- A tournament address identifies one epoch within an application. This key +-- also supports tournament lookups that do not specify an epoch. +CREATE UNIQUE INDEX "tournaments_address_idx" + ON "tournaments"("application_id","address"); + CREATE INDEX "tournaments_parent_match_nonroot_idx" ON "tournaments"("application_id","epoch_index","parent_tournament_address","parent_match_id_hash") WHERE "level" > 0; @@ -694,10 +811,18 @@ CREATE TABLE "commitments" "submitter_address" ethereum_address NOT NULL, "block_number" uint64 NOT NULL, "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "as_of_block" uint64 NOT NULL, + "claimer" ethereum_address NOT NULL, + "clock_running" BOOLEAN NOT NULL, + "clock_deadline" uint64 NOT NULL, + "clock_allowance" uint64 NOT NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "commitments_pkey" PRIMARY KEY ("application_id","epoch_index","tournament_address","commitment"), + CONSTRAINT "commitments_event_key" UNIQUE ("application_id", "tx_hash", "log_index"), + CONSTRAINT "commitments_paused_clock_check" CHECK ("clock_running" OR "clock_deadline" = 0), CONSTRAINT "commitments_tournament_fkey" FOREIGN KEY ("application_id","epoch_index","tournament_address") REFERENCES "tournaments"("application_id","epoch_index","address") @@ -722,15 +847,68 @@ CREATE TABLE "matches" "left_of_two" hash NOT NULL, "block_number" uint64 NOT NULL, "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "eliminable_at" uint64 NOT NULL, + "seal_eliminable_at" uint64, + "seal_block_number" uint64, + "seal_tx_hash" hash, + "seal_log_index" uint64, + "as_of_block" uint64 NOT NULL, + "phase" "MatchPhase" NOT NULL, + "timeout_outcome" "MatchTimeoutOutcome" NOT NULL, + "deferred_charge" uint64 NOT NULL, + "revealing_parent" hash, + "waiting_left" hash, + "waiting_right" hash, + "segment_start_position" uint256, + "segment_start_cycle" uint256, + "current_height" uint64, + "responder" "CommitmentSide", + "agree_state" hash, + "divergence_position" uint256, + "divergence_cycle" uint256, + "final_state_one" hash, + "final_state_two" hash, "winner" "WinnerCommitment" NOT NULL, "deletion_reason" "MatchDeletionReason" NOT NULL, - "deletion_block_number" uint64 DEFAULT 0, + "deletion_block_number" uint64 NOT NULL DEFAULT 0, "deletion_tx_hash" hash, + "deletion_log_index" uint64, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "matches_pkey" PRIMARY KEY ("application_id","epoch_index","tournament_address","id_hash"), + CONSTRAINT "matches_deletion_columns_set_together" CHECK ( + ("deletion_reason" = 'NOT_DELETED' AND "deletion_block_number" = 0 + AND "deletion_tx_hash" IS NULL AND "deletion_log_index" IS NULL) + OR + ("deletion_reason" <> 'NOT_DELETED' AND "deletion_block_number" > 0 + AND "deletion_tx_hash" IS NOT NULL AND "deletion_log_index" IS NOT NULL) + ), + + CONSTRAINT "matches_seal_tuple_check" CHECK ( + num_nonnulls("seal_eliminable_at", "seal_block_number", "seal_tx_hash", "seal_log_index") IN (0, 4) + ), + CONSTRAINT "matches_event_key" UNIQUE ("application_id", "tx_hash", "log_index"), + CONSTRAINT "matches_seal_event_key" UNIQUE ("application_id", "seal_tx_hash", "seal_log_index"), + CONSTRAINT "matches_deletion_event_key" UNIQUE ("application_id", "deletion_tx_hash", "deletion_log_index"), + CONSTRAINT "matches_current_phase_check" CHECK ( + ("phase" IN ('BISECTING', 'READY_TO_SEAL') AND "deletion_reason" = 'NOT_DELETED' + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder") = 6 + AND num_nonnulls("agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 0 + AND (("phase" = 'BISECTING' AND "current_height" IS NOT NULL) + OR ("phase" = 'READY_TO_SEAL' AND "current_height" IS NULL))) + OR + ("phase" = 'SEALED' AND "deletion_reason" = 'NOT_DELETED' + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder", "current_height") = 0 + AND num_nonnulls("agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 5) + OR + ("phase" = 'UNINITIALIZED' AND "deletion_reason" <> 'NOT_DELETED' AND "timeout_outcome" = 'NONE' AND "deferred_charge" = 0 + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder", "current_height", + "agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 0) + ), + CONSTRAINT "matches_tournament_fkey" FOREIGN KEY ("application_id","epoch_index","tournament_address") REFERENCES "tournaments"("application_id","epoch_index","address") @@ -769,12 +947,15 @@ CREATE TABLE "match_advances" "id_hash" hash NOT NULL, -- keccak256(abi.encode(one,two)) "other_parent" hash NOT NULL, "left_node" hash NOT NULL, + "segment_start_position" uint256 NOT NULL, + "eliminable_at" uint64 NOT NULL, "block_number" uint64 NOT NULL, "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "match_advances_pkey" - PRIMARY KEY ("application_id","epoch_index","tournament_address","id_hash","other_parent"), + PRIMARY KEY ("application_id","tx_hash","log_index"), CONSTRAINT "match_advances_matches_fkey" FOREIGN KEY ("application_id","epoch_index","tournament_address","id_hash") @@ -783,12 +964,51 @@ CREATE TABLE "match_advances" ); CREATE INDEX "match_advances_block_number_idx" - ON "match_advances"("application_id","epoch_index","tournament_address","id_hash","block_number"); + ON "match_advances"("application_id","epoch_index","tournament_address","id_hash","block_number","log_index","tx_hash"); CREATE TRIGGER "match_advances_set_updated_at" BEFORE UPDATE ON "match_advances" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TABLE "bond_events" +( + "application_id" INT4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "tournament_address" ethereum_address NOT NULL, + "type" "BondEventType" NOT NULL, + "block_number" uint64 NOT NULL, + "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "recipient" ethereum_address, + "value" uint256, + "success" BOOLEAN, + "commitment" hash, + "claimer" ethereum_address, + "payment" uint256, + "burned" uint256, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "bond_events_pkey" PRIMARY KEY ("application_id", "tx_hash", "log_index"), + CONSTRAINT "bond_events_tournament_fkey" FOREIGN KEY ("application_id", "epoch_index", "tournament_address") + REFERENCES "tournaments"("application_id", "epoch_index", "address") ON DELETE CASCADE, + CONSTRAINT "bond_events_variant_check" CHECK ( + ("type" = 'PARTIAL_BOND_REFUND' AND num_nonnulls("recipient", "value", "success") = 3 + AND num_nonnulls("commitment", "claimer", "payment", "burned") = 0) + OR + ("type" = 'BOND_RECOVERED' AND num_nonnulls("recipient", "value", "success") = 0 + AND num_nonnulls("commitment", "claimer", "payment", "burned") = 4) + ) +); + +CREATE INDEX "bond_events_application_order_idx" + ON "bond_events"("application_id", "block_number", "log_index", "tx_hash"); + +CREATE INDEX "bond_events_tournament_order_idx" + ON "bond_events"("application_id", "epoch_index", "tournament_address", "block_number", "log_index", "tx_hash"); + +CREATE TRIGGER "bond_events_set_updated_at" BEFORE UPDATE ON "bond_events" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + CREATE TABLE "state_hashes" ( "input_epoch_application_id" int4 NOT NULL, diff --git a/internal/repository/postgres/tournament.go b/internal/repository/postgres/tournament.go index 7281dad18..e86a750e7 100644 --- a/internal/repository/postgres/tournament.go +++ b/internal/repository/postgres/tournament.go @@ -5,227 +5,57 @@ package postgres import ( "context" - "database/sql" "errors" "fmt" + "math" - "github.com/ethereum/go-ethereum/common" - "github.com/go-jet/jet/v2/postgres" - - "github.com/cartesi/rollups-node/internal/model" + . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" ) -// ------------------------ TournamentRepository Methods ------------------------ // - -func (r *PostgresRepository) CreateTournament( - ctx context.Context, - nameOrAddress string, - t *model.Tournament, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - insertStmt := table.Tournaments. - INSERT( - table.Tournaments.ApplicationID, - table.Tournaments.EpochIndex, - table.Tournaments.Address, - table.Tournaments.ParentTournamentAddress, - table.Tournaments.ParentMatchIDHash, - table.Tournaments.MaxLevel, - table.Tournaments.Level, - table.Tournaments.Log2step, - table.Tournaments.Height, - table.Tournaments.WinnerCommitment, - table.Tournaments.FinalStateHash, - table.Tournaments.FinishedAtBlock, - ) - - parentAddress := postgres.NULL - if t.ParentTournamentAddress != nil { - parentAddress = postgres.Bytea(t.ParentTournamentAddress.Bytes()) - } - parentMatch := postgres.NULL - if t.ParentMatchIDHash != nil { - parentMatch = postgres.Bytea(t.ParentMatchIDHash.Bytes()) - } - winnerCommitment := postgres.NULL - if t.WinnerCommitment != nil { - winnerCommitment = postgres.Bytea(t.WinnerCommitment.Bytes()) - } - finalState := postgres.NULL - if t.FinalStateHash != nil { - finalState = postgres.Bytea(t.FinalStateHash.Bytes()) +func (r *PostgresRepository) CreateTournament(ctx context.Context, nameOrAddress string, value *Tournament) error { + if value == nil { + return fmt.Errorf("cannot create a nil tournament") } - - selectQuery := table.Application.SELECT( - table.Application.ID, - uint64Expr(t.EpochIndex), - postgres.Bytea(t.Address.Bytes()), - parentAddress, - parentMatch, - uint64Expr(t.MaxLevel), - uint64Expr(t.Level), - uint64Expr(t.Log2Step), - uint64Expr(t.Height), - winnerCommitment, - finalState, - uint64Expr(t.FinishedAtBlock), - ).WHERE( - whereClause, - ) - - // Tournament addresses come from the chain and may be observed again after - // an interrupted shutdown. Ignore only an exact replay of the tournament - // identity; other conflicts, such as a different root for the same epoch, - // must still surface as errors. - sqlStr, args := insertStmt. - QUERY(selectQuery). - ON_CONFLICT( - table.Tournaments.ApplicationID, - table.Tournaments.EpochIndex, - table.Tournaments.Address, - ). - DO_NOTHING(). - Sql() - _, err := r.db.Exec(ctx, sqlStr, args...) - - return err -} - -func (r *PostgresRepository) UpdateTournament( - ctx context.Context, - nameOrAddress string, - t *model.Tournament, -) error { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - winnerCommitment := postgres.NULL - if t.WinnerCommitment != nil { - winnerCommitment = postgres.Bytea(t.WinnerCommitment.Bytes()) - } - finalState := postgres.NULL - if t.FinalStateHash != nil { - finalState = postgres.Bytea(t.FinalStateHash.Bytes()) - } - - updateStmt := table.Tournaments. - UPDATE( - table.Tournaments.WinnerCommitment, - table.Tournaments.FinalStateHash, - table.Tournaments.FinishedAtBlock, - ). - SET( - winnerCommitment, - finalState, - t.FinishedAtBlock, - ). - FROM( - table.Application, - ). - WHERE(postgres.AND( - whereClause, - table.Tournaments.ApplicationID.EQ(postgres.Int(t.ApplicationID)), - table.Tournaments.EpochIndex.EQ(uint64Expr(t.EpochIndex)), - table.Tournaments.Address.EQ(postgres.Bytea(t.Address.Bytes())), - )) - - sqlStr, args := updateStmt.Sql() - cmd, err := r.db.Exec(ctx, sqlStr, args...) + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + stmt := tournamentUpsert(value, applicationID) + sqlStr, args := stmt.Sql() + command, err := r.db.Exec(ctx, sqlStr, args...) if err != nil { return err } - if cmd.RowsAffected() == 0 { - return repository.ErrNotFound + if command.RowsAffected() == 0 { + return fmt.Errorf("%w: tournament %s", repository.ErrTournamentEventConflict, value.Address) } return nil } -func (r *PostgresRepository) GetTournament( - ctx context.Context, - nameOrAddress string, - address string, -) (*model.Tournament, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - tournamentAddress := common.HexToAddress(address) - sel := table.Tournaments. - SELECT( - table.Tournaments.ApplicationID, - table.Tournaments.EpochIndex, - table.Tournaments.Address, - table.Tournaments.ParentTournamentAddress, - table.Tournaments.ParentMatchIDHash, - table.Tournaments.MaxLevel, - table.Tournaments.Level, - table.Tournaments.Log2step, - table.Tournaments.Height, - table.Tournaments.WinnerCommitment, - table.Tournaments.FinalStateHash, - table.Tournaments.FinishedAtBlock, - table.Tournaments.CreatedAt, - table.Tournaments.UpdatedAt, - ). - FROM( - table.Tournaments. - INNER_JOIN(table.Application, - table.Tournaments.ApplicationID.EQ(table.Application.ID), - ), - ). - WHERE( - whereClause. - AND(table.Tournaments.Address.EQ(postgres.Bytea(tournamentAddress.Bytes()))), - ) - +func (r *PostgresRepository) GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) { + sel := table.Tournaments.SELECT(tournamentColumns, table.Tournaments.CreatedAt, table.Tournaments.UpdatedAt). + FROM(table.Tournaments.INNER_JOIN(table.Application, table.Tournaments.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Tournaments.Address.EQ(postgres.Bytea(common.HexToAddress(address).Bytes())), + )) sqlStr, args := sel.Sql() - row := r.db.QueryRow(ctx, sqlStr, args...) - - var t model.Tournament - err := row.Scan( - &t.ApplicationID, - &t.EpochIndex, - &t.Address, - &t.ParentTournamentAddress, - &t.ParentMatchIDHash, - &t.MaxLevel, - &t.Level, - &t.Log2Step, - &t.Height, - &t.WinnerCommitment, - &t.FinalStateHash, - &t.FinishedAtBlock, - &t.CreatedAt, - &t.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { + value, err := scanTournament(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - if err != nil { - return nil, err - } - return &t, nil + return value, err } func (r *PostgresRepository) ListTournaments( - ctx context.Context, - nameOrAddress string, - f repository.TournamentFilter, - p repository.Pagination, - descending bool, -) ([]*model.Tournament, uint64, error) { - - whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - - fromClause := table.Tournaments. - INNER_JOIN(table.Application, - table.Tournaments.ApplicationID.EQ(table.Application.ID), - ) - - conditions := []postgres.BoolExpression{whereClause} + ctx context.Context, nameOrAddress string, f repository.TournamentFilter, p repository.Pagination, descending bool, +) ([]*Tournament, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Tournaments.INNER_JOIN(table.Application, table.Tournaments.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} if f.EpochIndex != nil { conditions = append(conditions, table.Tournaments.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) } @@ -238,90 +68,45 @@ func (r *PostgresRepository) ListTournaments( if f.ParentMatchIDHash != nil { conditions = append(conditions, table.Tournaments.ParentMatchIDHash.EQ(postgres.Bytea(f.ParentMatchIDHash.Bytes()))) } - tx, err := beginReadTx(ctx, r.db) if err != nil { return nil, 0, err } defer tx.Rollback(ctx) //nolint:errcheck - - countStmt := table.Tournaments.SELECT(postgres.COUNT(postgres.STAR)). - FROM(fromClause).WHERE(postgres.AND(conditions...)) + countStmt := table.Tournaments.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) total, err := countFromTx(ctx, tx, countStmt) - if err != nil { - return nil, 0, err - } - if total == 0 { - return nil, 0, nil + if err != nil || total == 0 { + return nil, total, err } - - sel := table.Tournaments. - SELECT( - table.Tournaments.ApplicationID, - table.Tournaments.EpochIndex, - table.Tournaments.Address, - table.Tournaments.ParentTournamentAddress, - table.Tournaments.ParentMatchIDHash, - table.Tournaments.MaxLevel, - table.Tournaments.Level, - table.Tournaments.Log2step, - table.Tournaments.Height, - table.Tournaments.WinnerCommitment, - table.Tournaments.FinalStateHash, - table.Tournaments.FinishedAtBlock, - table.Tournaments.CreatedAt, - table.Tournaments.UpdatedAt, - ). - FROM(fromClause). - WHERE(postgres.AND(conditions...)) - + sel := table.Tournaments.SELECT(tournamentColumns, table.Tournaments.CreatedAt, table.Tournaments.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) if descending { - sel = sel.ORDER_BY(table.Tournaments.EpochIndex.DESC(), table.Tournaments.Level.DESC()) + sel = sel.ORDER_BY(table.Tournaments.EpochIndex.DESC(), table.Tournaments.Level.DESC(), table.Tournaments.Address.DESC()) } else { - sel = sel.ORDER_BY(table.Tournaments.EpochIndex.ASC(), table.Tournaments.Level.ASC()) + sel = sel.ORDER_BY(table.Tournaments.EpochIndex.ASC(), table.Tournaments.Level.ASC(), table.Tournaments.Address.ASC()) } - if p.Limit > 0 { sel = sel.LIMIT(int64(p.Limit)) } if p.Offset > 0 { sel = sel.OFFSET(int64(p.Offset)) } - sqlStr, args := sel.Sql() rows, err := tx.Query(ctx, sqlStr, args...) if err != nil { return nil, 0, err } defer rows.Close() - - var tournaments []*model.Tournament + var values []*Tournament for rows.Next() { - var t model.Tournament - err := rows.Scan( - &t.ApplicationID, - &t.EpochIndex, - &t.Address, - &t.ParentTournamentAddress, - &t.ParentMatchIDHash, - &t.MaxLevel, - &t.Level, - &t.Log2Step, - &t.Height, - &t.WinnerCommitment, - &t.FinalStateHash, - &t.FinishedAtBlock, - &t.CreatedAt, - &t.UpdatedAt, - ) + value, err := scanTournament(rows) if err != nil { return nil, 0, err } - tournaments = append(tournaments, &t) + values = append(values, value) } if err := rows.Err(); err != nil { return nil, 0, err } - - return tournaments, total, nil + return values, total, nil } diff --git a/internal/repository/postgres/tournament_events.go b/internal/repository/postgres/tournament_events.go new file mode 100644 index 000000000..30141b7a3 --- /dev/null +++ b/internal/repository/postgres/tournament_events.go @@ -0,0 +1,374 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +// StoreTournamentEvents publishes event facts and pinned current snapshots in +// one application-wide transaction. Parents must precede their children. +func (r *PostgresRepository) StoreTournamentEvents( + ctx context.Context, appID int64, batches []*repository.TournamentEventBatch, lastBlock uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning tournament event window: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + // Keep the application aggregate lock before every child-row lock. + lockStmt := table.Application.SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(postgres.Int64(appID))).FOR(postgres.NO_KEY_UPDATE()) + lockSQL, lockArgs := lockStmt.Sql() + var lockedID int64 + if err := tx.QueryRow(ctx, lockSQL, lockArgs...).Scan(&lockedID); errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNotFound + } else if err != nil { + return fmt.Errorf("locking tournament application %d: %w", appID, err) + } + for _, batch := range batches { + if err := validateTournamentBatch(appID, batch, lastBlock); err != nil { + return err + } + if err := storeTournamentProjection(ctx, tx, appID, batch.Tournament); err != nil { + return err + } + if err := insertCommitments(ctx, tx, appID, batch.Commitments); err != nil { + return fmt.Errorf("storing tournament commitments: %w", err) + } + if err := insertMatches(ctx, tx, appID, batch.Matches); err != nil { + return fmt.Errorf("storing tournament matches: %w", err) + } + if err := insertMatchAdvanced(ctx, tx, appID, batch.MatchAdvances); err != nil { + return fmt.Errorf("storing tournament match advances: %w", err) + } + if err := insertBondEvents(ctx, tx, appID, batch.BondEvents); err != nil { + return fmt.Errorf("storing tournament bonds: %w", err) + } + } + if err := updateLastProcessedBlock(ctx, tx, appID, lastBlock); err != nil { + return fmt.Errorf("storing tournament checkpoint: %w", err) + } + return tx.Commit(ctx) +} + +func validateTournamentBatch(appID int64, batch *repository.TournamentEventBatch, lastBlock uint64) error { + if batch == nil || batch.Tournament == nil { + return fmt.Errorf("nil tournament batch for application %d", appID) + } + t := batch.Tournament + if t.ApplicationID != appID || t.Snapshot.AsOfBlock != lastBlock { + return fmt.Errorf("tournament projection does not match application %d at block %d", appID, lastBlock) + } + for _, v := range batch.Commitments { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.Snapshot.AsOfBlock != lastBlock { + return fmt.Errorf("commitment does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + for _, v := range batch.Matches { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.Snapshot.AsOfBlock != lastBlock { + return fmt.Errorf("match does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + for _, v := range batch.MatchAdvances { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.BlockNumber > lastBlock { + return fmt.Errorf("advance does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + for _, v := range batch.BondEvents { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.BlockNumber > lastBlock { + return fmt.Errorf("bond event does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + return nil +} + +func tournamentUpsert(v *Tournament, applicationID any) postgres.InsertStatement { + values := tournamentValues(v) + values[0] = applicationID + return table.Tournaments.INSERT(tournamentColumns).ON_CONFLICT( + table.Tournaments.ApplicationID, table.Tournaments.EpochIndex, table.Tournaments.Address, + ).DO_UPDATE(postgres.SET( + table.Tournaments.AsOfBlock.SET(table.Tournaments.EXCLUDED.AsOfBlock), + table.Tournaments.Standing.SET(table.Tournaments.EXCLUDED.Standing), + table.Tournaments.AcceptsJoins.SET(table.Tournaments.EXCLUDED.AcceptsJoins), + table.Tournaments.Candidate.SET(table.Tournaments.EXCLUDED.Candidate), + table.Tournaments.WinnerCommitment.SET(table.Tournaments.EXCLUDED.WinnerCommitment), + table.Tournaments.FinalStateHash.SET(table.Tournaments.EXCLUDED.FinalStateHash), + table.Tournaments.FinishedAtBlock.SET(table.Tournaments.EXCLUDED.FinishedAtBlock), + table.Tournaments.ParentCommitment.SET(table.Tournaments.EXCLUDED.ParentCommitment), + table.Tournaments.WinnerExpiresAt.SET(table.Tournaments.EXCLUDED.WinnerExpiresAt), + table.Tournaments.InnerDisposition.SET(table.Tournaments.EXCLUDED.InnerDisposition), + table.Tournaments.InnerParentCommitment.SET(table.Tournaments.EXCLUDED.InnerParentCommitment), + table.Tournaments.InnerPausedAllowance.SET(table.Tournaments.EXCLUDED.InnerPausedAllowance), + table.Tournaments.BondDisposition.SET(table.Tournaments.EXCLUDED.BondDisposition), + table.Tournaments.BondClaimer.SET(table.Tournaments.EXCLUDED.BondClaimer), + table.Tournaments.BondPayment.SET(table.Tournaments.EXCLUDED.BondPayment), + ).WHERE(postgres.AND( + table.Tournaments.EpochIndex.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.EpochIndex), + table.Tournaments.Address.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Address), + table.Tournaments.ParentTournamentAddress.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.ParentTournamentAddress), + table.Tournaments.ParentMatchIDHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.ParentMatchIDHash), + table.Tournaments.MaxLevel.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.MaxLevel), + table.Tournaments.Level.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Level), + table.Tournaments.Log2step.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Log2step), + table.Tournaments.Height.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Height), + table.Tournaments.InitialHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.InitialHash), + table.Tournaments.BaseCycle.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.BaseCycle), + table.Tournaments.Kind.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Kind), + table.Tournaments.StartInstant.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.StartInstant), + table.Tournaments.Allowance.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Allowance), + table.Tournaments.CreationBlockNumber.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationBlockNumber), + table.Tournaments.CreationTxHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationTxHash), + table.Tournaments.CreationLogIndex.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationLogIndex), + table.Tournaments.AsOfBlock.LT_EQ(table.Tournaments.EXCLUDED.AsOfBlock), + ))).VALUES(values[0], values[1:]...) +} + +func storeTournamentProjection(ctx context.Context, tx pgx.Tx, appID int64, v *Tournament) error { + stmt := tournamentUpsert(v, appID) + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("storing tournament %s: %w", v.Address, err) + } + if command.RowsAffected() != 1 { + return fmt.Errorf("%w: tournament %s", repository.ErrTournamentEventConflict, v.Address) + } + return nil +} + +func insertCommitments(ctx context.Context, tx pgx.Tx, appID int64, values []*Commitment) error { + if len(values) == 0 { + return nil + } + stmt := table.Commitments.INSERT(commitmentColumns).ON_CONFLICT( + table.Commitments.ApplicationID, table.Commitments.EpochIndex, table.Commitments.TournamentAddress, table.Commitments.Commitment, + ).DO_UPDATE(postgres.SET( + table.Commitments.AsOfBlock.SET(table.Commitments.EXCLUDED.AsOfBlock), + table.Commitments.Claimer.SET(table.Commitments.EXCLUDED.Claimer), + table.Commitments.ClockRunning.SET(table.Commitments.EXCLUDED.ClockRunning), + table.Commitments.ClockDeadline.SET(table.Commitments.EXCLUDED.ClockDeadline), + table.Commitments.ClockAllowance.SET(table.Commitments.EXCLUDED.ClockAllowance), + ).WHERE(postgres.AND( + table.Commitments.EpochIndex.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.EpochIndex), + table.Commitments.TournamentAddress.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.TournamentAddress), + table.Commitments.Commitment.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.Commitment), + table.Commitments.FinalStateHash.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.FinalStateHash), + table.Commitments.SubmitterAddress.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.SubmitterAddress), + table.Commitments.BlockNumber.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.BlockNumber), + table.Commitments.TxHash.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.TxHash), + table.Commitments.LogIndex.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.LogIndex), + table.Commitments.AsOfBlock.LT_EQ(table.Commitments.EXCLUDED.AsOfBlock), + ))) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid commitment application identity") + } + row := commitmentValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() != int64(len(values)) { + return fmt.Errorf("%w: commitment window", repository.ErrTournamentEventConflict) + } + return nil +} + +func insertMatches(ctx context.Context, tx pgx.Tx, appID int64, values []*Match) error { + if len(values) == 0 { + return nil + } + stmt := table.Matches.INSERT(matchColumns).ON_CONFLICT( + table.Matches.ApplicationID, table.Matches.EpochIndex, table.Matches.TournamentAddress, table.Matches.IDHash, + ).DO_UPDATE(postgres.SET( + table.Matches.SealEliminableAt.SET(table.Matches.EXCLUDED.SealEliminableAt), + table.Matches.SealBlockNumber.SET(table.Matches.EXCLUDED.SealBlockNumber), + table.Matches.SealTxHash.SET(table.Matches.EXCLUDED.SealTxHash), + table.Matches.SealLogIndex.SET(table.Matches.EXCLUDED.SealLogIndex), + table.Matches.AsOfBlock.SET(table.Matches.EXCLUDED.AsOfBlock), + table.Matches.Phase.SET(table.Matches.EXCLUDED.Phase), + table.Matches.TimeoutOutcome.SET(table.Matches.EXCLUDED.TimeoutOutcome), + table.Matches.DeferredCharge.SET(table.Matches.EXCLUDED.DeferredCharge), + table.Matches.RevealingParent.SET(table.Matches.EXCLUDED.RevealingParent), + table.Matches.WaitingLeft.SET(table.Matches.EXCLUDED.WaitingLeft), + table.Matches.WaitingRight.SET(table.Matches.EXCLUDED.WaitingRight), + table.Matches.SegmentStartPosition.SET(table.Matches.EXCLUDED.SegmentStartPosition), + table.Matches.SegmentStartCycle.SET(table.Matches.EXCLUDED.SegmentStartCycle), + table.Matches.CurrentHeight.SET(table.Matches.EXCLUDED.CurrentHeight), + table.Matches.Responder.SET(table.Matches.EXCLUDED.Responder), + table.Matches.AgreeState.SET(table.Matches.EXCLUDED.AgreeState), + table.Matches.DivergencePosition.SET(table.Matches.EXCLUDED.DivergencePosition), + table.Matches.DivergenceCycle.SET(table.Matches.EXCLUDED.DivergenceCycle), + table.Matches.FinalStateOne.SET(table.Matches.EXCLUDED.FinalStateOne), + table.Matches.FinalStateTwo.SET(table.Matches.EXCLUDED.FinalStateTwo), + table.Matches.Winner.SET(table.Matches.EXCLUDED.Winner), + table.Matches.DeletionReason.SET(table.Matches.EXCLUDED.DeletionReason), + table.Matches.DeletionBlockNumber.SET(table.Matches.EXCLUDED.DeletionBlockNumber), + table.Matches.DeletionTxHash.SET(table.Matches.EXCLUDED.DeletionTxHash), + table.Matches.DeletionLogIndex.SET(table.Matches.EXCLUDED.DeletionLogIndex), + ).WHERE(postgres.AND( + table.Matches.EpochIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.EpochIndex), + table.Matches.TournamentAddress.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.TournamentAddress), + table.Matches.IDHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.IDHash), + table.Matches.CommitmentOne.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.CommitmentOne), + table.Matches.CommitmentTwo.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.CommitmentTwo), + table.Matches.LeftOfTwo.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.LeftOfTwo), + table.Matches.BlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.BlockNumber), + table.Matches.TxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.TxHash), + table.Matches.LogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.LogIndex), + table.Matches.EliminableAt.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.EliminableAt), + table.Matches.AsOfBlock.LT_EQ(table.Matches.EXCLUDED.AsOfBlock), + table.Matches.SealTxHash.IS_NULL().OR(postgres.AND( + table.Matches.SealEliminableAt.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealEliminableAt), + table.Matches.SealBlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealBlockNumber), + table.Matches.SealTxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealTxHash), + table.Matches.SealLogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealLogIndex), + )), + table.Matches.DeletionReason.EQ(postgres.NewEnumValue(MatchDeletionReason_NOT_DELETED.String())).OR(postgres.AND( + table.Matches.Winner.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.Winner), + table.Matches.DeletionReason.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionReason), + table.Matches.DeletionBlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionBlockNumber), + table.Matches.DeletionTxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionTxHash), + table.Matches.DeletionLogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionLogIndex), + )), + ))) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid match application identity") + } + row := matchValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() != int64(len(values)) { + return fmt.Errorf("%w: match window", repository.ErrTournamentEventConflict) + } + return nil +} + +func insertMatchAdvanced(ctx context.Context, tx pgx.Tx, appID int64, values []*MatchAdvanced) error { + if len(values) == 0 { + return nil + } + stmt := table.MatchAdvances.INSERT(matchAdvanceColumns) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid matchAdvance application identity") + } + row := matchAdvanceValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.ON_CONFLICT( + table.MatchAdvances.ApplicationID, table.MatchAdvances.TxHash, table.MatchAdvances.LogIndex, + ).DO_NOTHING().Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() == int64(len(values)) { + return nil + } + // A duplicate event keeps its original row and timestamps. Check its payload + // after the insert; a same-key contradiction must roll back the window. + for _, v := range values { + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt).WHERE( + table.MatchAdvances.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.MatchAdvances.TxHash.EQ(postgres.Bytea(v.TxHash.Bytes()))). + AND(table.MatchAdvances.LogIndex.EQ(uint64Expr(v.LogIndex)))) + query, params := sel.Sql() + stored, err := scanMatchAdvanced(tx.QueryRow(ctx, query, params...)) + if err != nil { + return err + } + if !sameMatchAdvancedEvent(stored, v) { + return fmt.Errorf("%w: transaction %s log %d", repository.ErrTournamentEventConflict, v.TxHash, v.LogIndex) + } + } + return nil +} + +func sameMatchAdvancedEvent(a, b *MatchAdvanced) bool { + return a.ApplicationID == b.ApplicationID && + a.EpochIndex == b.EpochIndex && + a.TournamentAddress == b.TournamentAddress && + a.IDHash == b.IDHash && + a.OtherParent == b.OtherParent && + a.LeftNode == b.LeftNode && + a.SegmentStartPosition == b.SegmentStartPosition && + a.EliminableAt == b.EliminableAt && + a.BlockNumber == b.BlockNumber && + a.TxHash == b.TxHash && + a.LogIndex == b.LogIndex +} + +func insertBondEvents(ctx context.Context, tx pgx.Tx, appID int64, values []*BondEvent) error { + if len(values) == 0 { + return nil + } + stmt := table.BondEvents.INSERT(bondEventColumns) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid bondEvent application identity") + } + row := bondEventValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.ON_CONFLICT(table.BondEvents.ApplicationID, table.BondEvents.TxHash, table.BondEvents.LogIndex).DO_NOTHING().Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() == int64(len(values)) { + return nil + } + // A duplicate event keeps its original row and timestamps. Check its payload + // after the insert; a same-key contradiction must roll back the window. + for _, v := range values { + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt).WHERE( + table.BondEvents.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.BondEvents.TxHash.EQ(postgres.Bytea(v.TxHash.Bytes()))). + AND(table.BondEvents.LogIndex.EQ(uint64Expr(v.LogIndex)))) + query, params := sel.Sql() + stored, err := scanBondEvent(tx.QueryRow(ctx, query, params...)) + if err != nil { + return err + } + if !sameBondEvent(stored, v) { + return fmt.Errorf("%w: transaction %s log %d", repository.ErrTournamentEventConflict, v.TxHash, v.LogIndex) + } + } + return nil +} + +func sameBondEvent(a, b *BondEvent) bool { + if a.ApplicationID != b.ApplicationID || a.EpochIndex != b.EpochIndex || a.TournamentAddress != b.TournamentAddress || + a.Type != b.Type || a.BlockNumber != b.BlockNumber || a.TxHash != b.TxHash || a.LogIndex != b.LogIndex { + return false + } + if (a.Refund == nil) != (b.Refund == nil) || (a.Recovery == nil) != (b.Recovery == nil) { + return false + } + return (a.Refund == nil || *a.Refund == *b.Refund) && (a.Recovery == nil || *a.Recovery == *b.Recovery) +} diff --git a/internal/repository/postgres/tournament_events_test.go b/internal/repository/postgres/tournament_events_test.go new file mode 100644 index 000000000..36a06a07b --- /dev/null +++ b/internal/repository/postgres/tournament_events_test.go @@ -0,0 +1,199 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +func TestTournamentEventWindowLocksApplicationBeforeChildren(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + newRepository := func() *PostgresRepository { + cfg, err := pgxpool.ParseConfig(endpoint) + require.NoError(t, err) + cfg.MaxConns = 1 // Each operation has a stable PostgreSQL backend PID. + pool, err := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(t, err) + t.Cleanup(pool.Close) + return &PostgresRepository{db: pool} + } + writer, deleter := newRepository(), newRepository() + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).WithEnabled(false).Create(ctx, t, writer) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).WithInputBounds(0, 0).Build() + require.NoError(t, writer.CreateEpochsAndInputs(ctx, app.Name, map[*Epoch][]*Input{epoch: {}}, 10)) + var writerPID, deleterPID int32 + require.NoError(t, writer.db.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&writerPID)) + require.NoError(t, deleter.db.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&deleterPID)) + + gate, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, gate.Close(context.Background())) }) + gateTx, err := gate.Begin(ctx) + require.NoError(t, err) + defer gateTx.Rollback(context.Background()) //nolint:errcheck + _, err = gateTx.Exec(ctx, `SELECT 1 FROM epoch WHERE application_id = $1 FOR UPDATE`, app.ID) + require.NoError(t, err) + gatePID := int32(gate.PgConn().PID()) //nolint:gosec // PostgreSQL backend PIDs fit in int32. + waitForBlocker := func(blocked, blocker int32) { + t.Helper() + require.Eventually(t, func() bool { + var blockedByExpected bool + err := gate.QueryRow(ctx, "SELECT $1 = ANY(pg_blocking_pids($2))", blocker, blocked).Scan(&blockedByExpected) + return err == nil && blockedByExpected + }, 5*time.Second, 10*time.Millisecond, "backend %d must wait for backend %d", blocked, blocker) + } + + stored := make(chan error, 1) + go func() { + batch := &repository.TournamentEventBatch{Tournament: repotest.NewTournamentBuilder(app.ID).Build()} + batch.Tournament.Snapshot.AsOfBlock = 100 + stored <- writer.StoreTournamentEvents(ctx, app.ID, []*repository.TournamentEventBatch{batch}, 100) + }() + // The epoch lock pauses the first projection's foreign-key check. The + // writer must already hold the application lock before it reaches here. + waitForBlocker(writerPID, gatePID) + deleted := make(chan error, 1) + go func() { deleted <- deleter.DeleteApplication(ctx, app.ID) }() + waitForBlocker(deleterPID, writerPID) + + require.NoError(t, gateTx.Commit(ctx)) + require.NoError(t, <-stored, "the event window must commit before deletion can take its application lock") + require.NoError(t, <-deleted) + for _, query := range []string{"SELECT count(*) FROM application", "SELECT count(*) FROM epoch", "SELECT count(*) FROM tournaments"} { + var count int + require.NoError(t, writer.db.QueryRow(ctx, query).Scan(&count)) + require.Zero(t, count, "deletion must remove the complete committed projection") + } +} + +func TestTournamentEventWindowIsAtomic(t *testing.T) { + for _, test := range []struct { + name string + fault func([]*repository.TournamentEventBatch) + }{ + {name: "child row", fault: func(batches []*repository.TournamentEventBatch) { + child := *batches[1].Tournament + child.ParentMatchIDHash = new(repotest.UniqueHash()) + batches[1].Tournament = &child + }}, + {name: "child event", fault: func(batches []*repository.TournamentEventBatch) { + commitment := *batches[1].Commitments[0] + commitment.TournamentAddress = repotest.UniqueAddress() + batches[1].Commitments = []*Commitment{&commitment} + }}, + {name: "last root event", fault: func(batches []*repository.TournamentEventBatch) { + last := batches[len(batches)-1] + invalid := repotest.NewMatchBuilder(last.Tournament.ApplicationID). + WithEpochIndex(last.Tournament.EpochIndex).WithTournamentAddress(last.Tournament.Address).Build() + invalid.Snapshot.AsOfBlock = 100 + last.Matches = append(last.Matches, invalid) + }}, + {name: "duplicate commitment", fault: func(batches []*repository.TournamentEventBatch) { + batches[1].Commitments = append(batches[1].Commitments, batches[1].Commitments[0]) + }}, + } { + t.Run(test.name, func(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + raw, err := NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(raw.Close) + r := raw.(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, raw) + epoch0 := repotest.NewEpochBuilder(app.ID).WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + epoch1 := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(9, 19).WithInputBounds(0, 0).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, + map[*Epoch][]*Input{epoch0: {}, epoch1: {}}, 20)) + + root := repotest.NewTournamentBuilder(app.ID).Build() + root.Snapshot.AsOfBlock = 50 + require.NoError(t, r.CreateTournament(t.Context(), app.Name, root)) + updatedRoot := *root + updatedRoot.Snapshot.AsOfBlock = 100 + updatedRoot.Snapshot.AcceptsJoins = false + makeBatch := func(tournament *Tournament) *repository.TournamentEventBatch { + tournament.Snapshot.AsOfBlock = 100 + tournament.Snapshot.Standing = TournamentStandingMatchesActive + one := repotest.NewCommitmentBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).WithCommitmentOne(one.Commitment). + WithCommitmentTwo(two.Commitment).Build() + one.Snapshot.AsOfBlock = 100 + two.Snapshot.AsOfBlock = 100 + match.Snapshot.AsOfBlock = 100 + return &repository.TournamentEventBatch{Tournament: tournament, + Commitments: []*Commitment{one, two}, Matches: []*Match{match}} + } + parent := makeBatch(&updatedRoot) + child := repotest.NewTournamentBuilder(app.ID).WithLevel(1). + WithParent(root.Address, parent.Matches[0].IDHash).Build() + secondRoot := repotest.NewTournamentBuilder(app.ID).WithEpochIndex(1).Build() + good := []*repository.TournamentEventBatch{parent, makeBatch(child), makeBatch(secondRoot)} + bad := make([]*repository.TournamentEventBatch, len(good)) + for i, batch := range good { + batchCopy := *batch + bad[i] = &batchCopy + } + test.fault(bad) + require.Error(t, r.StoreTournamentEvents(t.Context(), app.ID, bad, 100)) + storedRoot, err := r.GetTournament(t.Context(), app.Name, root.Address.Hex()) + require.NoError(t, err) + require.Equal(t, root.Snapshot, storedRoot.Snapshot, "projection update must roll back with the last batch") + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Zero(t, storedApp.LastTournamentCheckBlock) + for _, target := range []struct { + query string + count int + }{ + {"SELECT count(*) FROM tournaments", 1}, {"SELECT count(*) FROM commitments", 0}, + {"SELECT count(*) FROM matches", 0}, {"SELECT count(*) FROM match_advances", 0}, + } { + var count int + require.NoError(t, r.db.QueryRow(t.Context(), target.query).Scan(&count)) + require.Equal(t, target.count, count, target.query) + } + + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, good, 100)) + storedApp, err = r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(100), storedApp.LastTournamentCheckBlock) + for _, batch := range good { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.NotNil(t, stored) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + commitments, total, err := r.ListCommitments(t.Context(), app.Name, + repository.CommitmentFilter{TournamentAddress: new(batch.Tournament.Address.Hex())}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Len(t, commitments, 2, "retry must not duplicate the rolled-back event rows") + } + }) + } +} diff --git a/internal/repository/postgres/tournament_integrity_test.go b/internal/repository/postgres/tournament_integrity_test.go new file mode 100644 index 000000000..dc4d47689 --- /dev/null +++ b/internal/repository/postgres/tournament_integrity_test.go @@ -0,0 +1,274 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const ( + integrityFirstCheckpoint = uint64(100) + integrityNextCheckpoint = uint64(200) +) + +func newTournamentIntegrityFixture(t *testing.T) (*PostgresRepository, *Application, *Tournament, *Match) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, epoch.LastBlock)) + tournament := repotest.NewTournamentBuilder(app.ID).Build() + one := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithTournamentAddress(tournament.Address). + WithCommitmentOne(one.Commitment).WithCommitmentTwo(two.Commitment).Build() + tournament.Snapshot.AsOfBlock = integrityFirstCheckpoint + one.Snapshot.AsOfBlock = integrityFirstCheckpoint + two.Snapshot.AsOfBlock = integrityFirstCheckpoint + match.Snapshot.AsOfBlock = integrityFirstCheckpoint + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{ + Tournament: tournament, Commitments: []*Commitment{one, two}, + }}, integrityFirstCheckpoint)) + return r, app, tournament, match +} + +func TestMatchDeletionMetadataRoundTrip(t *testing.T) { + for _, test := range []struct { + name string + hash *common.Hash + }{ + {name: "live"}, + {name: "deleted with zero hash", hash: new(common.Hash)}, + {name: "deleted with nonzero hash", hash: new(common.HexToHash("0x123"))}, + } { + for _, bulk := range []bool{false, true} { + writer := "create" + if bulk { + writer = "event window" + } + t.Run(test.name+"/"+writer, func(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + tournament.Snapshot.AsOfBlock = integrityNextCheckpoint + match.Snapshot.AsOfBlock = integrityNextCheckpoint + match.DeletionTxHash = test.hash + if test.hash != nil { + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = integrityNextCheckpoint + match.DeletionLogIndex = new(uint64(0)) + match.Snapshot.Phase = MatchPhaseUninitialized + match.Snapshot.Bisection = nil + } + if bulk { + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{ + Tournament: tournament, Matches: []*Match{match}, + }}, integrityNextCheckpoint)) + } else { + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + } + stored, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.DeletionReason, stored.DeletionReason) + require.Equal(t, match.DeletionBlockNumber, stored.DeletionBlockNumber) + require.Equal(t, match.DeletionTxHash, stored.DeletionTxHash) + listed, _, err := r.ListMatches(t.Context(), app.Name, repository.MatchFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Len(t, listed, 1) + require.Equal(t, match.DeletionTxHash, listed[0].DeletionTxHash) + var isNull bool + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT deletion_tx_hash IS NULL FROM matches`).Scan(&isNull)) + require.Equal(t, test.hash == nil, isNull) + }) + } + } +} + +func TestMatchDeletionMetadataRejectsIncompleteTuples(t *testing.T) { + r, app, _, match := newTournamentIntegrityFixture(t) + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + for _, test := range []struct { + name string + reason MatchDeletionReason + block any + hash *common.Hash + code string + }{ + {"live with transaction", MatchDeletionReason_NOT_DELETED, 0, new(common.Hash), "23514"}, + {"live with block", MatchDeletionReason_NOT_DELETED, 1, nil, "23514"}, + {"deleted without transaction", MatchDeletionReason_TIMEOUT, 1, nil, "23514"}, + {"deleted without block", MatchDeletionReason_TIMEOUT, 0, new(common.Hash), "23514"}, + {"live with null block", MatchDeletionReason_NOT_DELETED, nil, nil, "23502"}, + {"deleted with null block", MatchDeletionReason_TIMEOUT, nil, new(common.Hash), "23502"}, + } { + t.Run(test.name, func(t *testing.T) { + // Keep the current phase coherent so this test isolates the event tuple. + _, err := r.db.Exec(t.Context(), `UPDATE matches SET + deletion_reason = $1::"MatchDeletionReason", deletion_block_number = $2, deletion_tx_hash = $3, + deletion_log_index = CASE WHEN $1 = 'NOT_DELETED' THEN NULL ELSE 0 END, + phase = CASE WHEN $1 = 'NOT_DELETED' THEN 'BISECTING'::"MatchPhase" ELSE 'UNINITIALIZED'::"MatchPhase" END, + revealing_parent = CASE WHEN $1 = 'NOT_DELETED' THEN revealing_parent ELSE NULL END, + waiting_left = CASE WHEN $1 = 'NOT_DELETED' THEN waiting_left ELSE NULL END, + waiting_right = CASE WHEN $1 = 'NOT_DELETED' THEN waiting_right ELSE NULL END, + segment_start_position = CASE WHEN $1 = 'NOT_DELETED' THEN segment_start_position ELSE NULL END, + segment_start_cycle = CASE WHEN $1 = 'NOT_DELETED' THEN segment_start_cycle ELSE NULL END, + current_height = CASE WHEN $1 = 'NOT_DELETED' THEN current_height ELSE NULL END, + responder = CASE WHEN $1 = 'NOT_DELETED' THEN responder ELSE NULL END`, + test.reason.String(), test.block, hashToBytes(test.hash)) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, test.code, pgErr.Code) + if test.block == nil { + require.Equal(t, "deletion_block_number", pgErr.ColumnName) + } else { + require.Equal(t, "matches_deletion_columns_set_together", pgErr.ConstraintName) + } + }) + } +} + +func TestTournamentWindowRollsBackInvalidDeletion(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + tournament.Snapshot.AsOfBlock = integrityNextCheckpoint + tournament.Snapshot.Standing = TournamentStandingRootWinner + tournament.Snapshot.FinishedAtBlock = integrityNextCheckpoint + tournament.Snapshot.Candidate = new(match.CommitmentOne) + tournament.Snapshot.WinnerCommitment = new(match.CommitmentOne) + tournament.Snapshot.FinalStateHash = new(common.Hash) + deletion := *match + deletion.Winner = WinnerCommitment_ONE + deletion.DeletionReason = MatchDeletionReason_TIMEOUT + deletion.DeletionBlockNumber = integrityNextCheckpoint + deletion.DeletionLogIndex = new(uint64(0)) + deletion.Snapshot = MatchSnapshot{AsOfBlock: integrityNextCheckpoint, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone} + batch := &repository.TournamentEventBatch{Tournament: tournament, Matches: []*Match{&deletion}} + err := r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, integrityNextCheckpoint) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "matches_deletion_columns_set_together", pgErr.ConstraintName) + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Zero(t, stored.Snapshot.FinishedAtBlock) + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + storedMatch, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, MatchDeletionReason_NOT_DELETED, storedMatch.DeletionReason) + require.Nil(t, storedMatch.DeletionTxHash) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityFirstCheckpoint, storedApp.LastTournamentCheckBlock) + + // The atomic path replaces the removed single-row update methods. Verify + // its successful update as well as the failed window above. + deletion.DeletionTxHash = new(common.Hash) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, integrityNextCheckpoint)) + stored, err = r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.Snapshot, stored.Snapshot) + storedMatch, err = r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, deletion.Winner, storedMatch.Winner) + require.Equal(t, deletion.DeletionReason, storedMatch.DeletionReason) + require.Equal(t, deletion.DeletionBlockNumber, storedMatch.DeletionBlockNumber) + require.Equal(t, deletion.DeletionTxHash, storedMatch.DeletionTxHash) + storedApp, err = r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityNextCheckpoint, storedApp.LastTournamentCheckBlock) +} + +func TestTournamentAddressIdentifiesOneEpochPerApplication(t *testing.T) { + r, app, tournament, _ := newTournamentIntegrityFixture(t) + // Re-observing the same identity remains idempotent. + require.NoError(t, r.CreateTournament(t.Context(), app.Name, tournament)) + nextEpoch := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{nextEpoch: {}}, nextEpoch.LastBlock)) + duplicate := *tournament + duplicate.EpochIndex = nextEpoch.Index + err := r.CreateTournament(t.Context(), app.Name, &duplicate) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "tournaments_address_idx", pgErr.ConstraintName) + + updated := *tournament + updated.Snapshot.AsOfBlock = integrityNextCheckpoint + updated.Snapshot.Standing = TournamentStandingRootFailed + updated.Snapshot.FinishedAtBlock = integrityNextCheckpoint + duplicate.Snapshot.AsOfBlock = integrityNextCheckpoint + err = r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{ + {Tournament: &updated}, {Tournament: &duplicate}, + }, integrityNextCheckpoint) + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "tournaments_address_idx", pgErr.ConstraintName) + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.EpochIndex, stored.EpochIndex) + require.Zero(t, stored.Snapshot.FinishedAtBlock) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityFirstCheckpoint, storedApp.LastTournamentCheckBlock) + + otherApp := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + otherEpoch := repotest.NewEpochBuilder(otherApp.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), otherApp.Name, map[*Epoch][]*Input{otherEpoch: {}}, otherEpoch.LastBlock)) + otherTournament := *tournament + otherTournament.ApplicationID = otherApp.ID + require.NoError(t, r.CreateTournament(t.Context(), otherApp.Name, &otherTournament), "the uniqueness key includes the application") +} + +func TestTournamentLevelConstraints(t *testing.T) { + for _, test := range []struct { + name string + maxLevel uint64 + level uint64 + constraint string + }{ + {name: "single level root", maxLevel: 1, level: 0}, + {name: "multi level root", maxLevel: 3, level: 0}, + {name: "inner nonleaf", maxLevel: 3, level: 1}, + {name: "inner leaf", maxLevel: 2, level: 1}, + {name: "zero levels", maxLevel: 0, level: 0, constraint: "tournaments_max_level_check"}, + {name: "level equals count", maxLevel: 1, level: 1, constraint: "tournaments_max_level_gt_level_check"}, + {name: "level exceeds count", maxLevel: 1, level: 2, constraint: "tournaments_max_level_gt_level_check"}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + if test.level > 0 { + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + tournament = repotest.NewTournamentBuilder(app.ID).WithLevel(1). + WithParent(tournament.Address, match.IDHash).Build() + require.NoError(t, r.CreateTournament(t.Context(), app.Name, tournament)) + } + kind := TournamentKindNonLeaf + if test.maxLevel > 0 && test.level == test.maxLevel-1 { + kind = TournamentKindLeaf + } + + // Write directly to test the database constraint, not a Go-side guard. + _, err := r.db.Exec(t.Context(), `UPDATE tournaments SET max_level = $1, level = $2, kind = $3 + WHERE application_id = $4 AND epoch_index = $5 AND address = $6`, + test.maxLevel, test.level, kind.String(), app.ID, tournament.EpochIndex, tournament.Address.Bytes()) + if test.constraint != "" { + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "23514", pgErr.Code) + require.Equal(t, test.constraint, pgErr.ConstraintName) + } else { + require.NoError(t, err) + tournament.MaxLevel, tournament.Level, tournament.Kind = test.maxLevel, test.level, kind + } + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.MaxLevel, stored.MaxLevel) + require.Equal(t, tournament.Level, stored.Level) + require.Equal(t, tournament.Kind, stored.Kind) + }) + } +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index af649162f..bc6674f2c 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -17,6 +17,9 @@ import ( var ( ErrNotFound = errors.New("not found") ErrNoUpdate = errors.New("update did not take effect") + // ErrEpochForeclosed means a stale epoch publication lost to foreclosure. + // The stored epoch is CLAIM_FORECLOSED; no new claim or proof was stored. + ErrEpochForeclosed = errors.New("epoch publication superseded by foreclosure") // ErrApplicationNotRunnable means an advance result cannot be stored because // the application's durable status does not allow machine execution. ErrApplicationNotRunnable = errors.New("application is not runnable") @@ -29,6 +32,9 @@ var ( // ErrInvalidStateProof means an advance result or epoch publication did not // include the complete three-leaf machine state proof. ErrInvalidStateProof = errors.New("invalid machine state proof") + // ErrTournamentEventConflict means an observation contradicts an already + // stored immutable tournament fact or would replace a newer snapshot. + ErrTournamentEventConflict = errors.New("tournament observation conflicts with stored data") // ErrInputLogIdentityConflict indicates an input insert conflicted with a // stored row on the L1 log identity (transaction_hash, log_index) under a @@ -45,12 +51,11 @@ type Pagination struct { } type ApplicationFilter struct { - Enabled *bool - Status *ApplicationStatus - Statuses []ApplicationStatus - DataAvailability *DataAvailabilitySelector - ConsensusType *Consensus - ConsensusTypes []Consensus + Enabled *bool + Status *ApplicationStatus + Statuses []ApplicationStatus + ConsensusType *Consensus + ConsensusTypes []Consensus // ForeclosureRecorded filters by the foreclose_block column: when non-nil // and true, returns only apps whose foreclosure has been observed and // recorded by the evmreader; when non-nil and false, returns only apps @@ -69,9 +74,10 @@ func ExecutableApplicationsFilter() ApplicationFilter { } type EpochFilter struct { - Status []EpochStatus - BeforeBlock *uint64 - IndexRange *Range + Status []EpochStatus + BeforeBlock *uint64 + IndexRange *Range + HasTournament *bool } type InputFilter struct { @@ -126,6 +132,11 @@ type MatchFilter struct { TournamentAddress *string } +type BondEventFilter struct { + EpochIndex *uint64 + TournamentAddress *common.Address +} + type WithdrawalFilter struct { AccountIndex *uint64 } @@ -303,11 +314,10 @@ type StateHashRepository interface { } type TournamentRepository interface { - // CreateTournament is idempotent only for an exact - // (application_id, epoch_index, address) replay. Other constraint conflicts - // remain errors. + // CreateTournament preserves immutable facts for an existing + // (application_id, epoch_index, address) and can refresh its current snapshot. + // Conflicting facts and older snapshots return ErrTournamentEventConflict. CreateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error - UpdateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) ListTournaments(ctx context.Context, nameOrAddress string, f TournamentFilter, p Pagination, descending bool) ([]*Tournament, uint64, error) @@ -321,25 +331,44 @@ type CommitmentRepository interface { type MatchRepository interface { CreateMatch(ctx context.Context, nameOrAddress string, m *Match) error - UpdateMatch(ctx context.Context, nameOrAddress string, m *Match) error GetMatch(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string) (*Match, error) ListMatches(ctx context.Context, nameOrAddress string, f MatchFilter, p Pagination, descending bool) ([]*Match, uint64, error) } type MatchAdvancedRepository interface { CreateMatchAdvanced(ctx context.Context, nameOrAddress string, m *MatchAdvanced) error - GetMatchAdvanced(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string, parentHex string) (*MatchAdvanced, error) - ListMatchAdvances(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string, p Pagination, descending bool) ([]*MatchAdvanced, uint64, error) + GetMatchAdvanced(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, txHash common.Hash, logIndex uint64) (*MatchAdvanced, error) + ListMatchAdvances(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, p Pagination, descending bool) ([]*MatchAdvanced, uint64, error) +} + +type BondEventRepository interface { + GetBondEvent(ctx context.Context, nameOrAddress string, txHash common.Hash, logIndex uint64) (*BondEvent, error) + ListBondEvents(ctx context.Context, nameOrAddress string, f BondEventFilter, p Pagination, descending bool) ([]*BondEvent, uint64, error) +} + +// TournamentEventBatch contains one tournament projection and the events from +// a completed scan window. Batches are ordered with parents before children: +// a child tournament references a match written in its parent's batch. +type TournamentEventBatch struct { + Tournament *Tournament + Commitments []*Commitment + Matches []*Match + MatchAdvances []*MatchAdvanced + BondEvents []*BondEvent } type BulkOperationsRepository interface { StoreAdvanceResult(ctx context.Context, appID int64, result *AdvanceResult) error StoreClaimAndProofs(ctx context.Context, epoch *Epoch, outputs []*Output) error - StoreTournamentEvents(ctx context.Context, appID int64, commitments []*Commitment, matches []*Match, - matchAdvanced []*MatchAdvanced, matchDeleted []*Match, lastBlock uint64) error + // StoreTournamentEvents commits every tournament batch and one application + // cursor together. Callers must finish all event reads before this call. + StoreTournamentEvents(ctx context.Context, appID int64, batches []*TournamentEventBatch, lastBlock uint64) error } type NodeConfigRepository interface { + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) } @@ -443,6 +472,7 @@ type Repository interface { CommitmentRepository MatchRepository MatchAdvancedRepository + BondEventRepository BulkOperationsRepository NodeConfigRepository ClaimerRepository @@ -466,15 +496,36 @@ func SaveNodeConfig[T any]( return nil } +// InitializeNodeConfig saves only a missing key, then returns the stored value. +// Callers must validate that value against the requested configuration. A +// concurrent initializer may have won with a different value. +func InitializeNodeConfig[T any]( + ctx context.Context, repo NodeConfigRepository, nc *NodeConfig[T], +) (*NodeConfig[T], error) { + data, err := json.Marshal(nc.Value) + if err != nil { + return nil, fmt.Errorf("marshal initial node_config value: %w", err) + } + if err := repo.InitializeNodeConfigRaw(ctx, nc.Key, data); err != nil { + return nil, fmt.Errorf("initialize node_config %q: %w", nc.Key, err) + } + return LoadNodeConfig[T](ctx, repo, nc.Key) +} + +// LoadNodeConfig returns a non-nil record when err is nil. A missing JSON payload +// is an error, even if the repository reports a successful read. func LoadNodeConfig[T any]( ctx context.Context, repo NodeConfigRepository, key string, ) (*NodeConfig[T], error) { raw, createdAt, updatedAt, err := repo.LoadNodeConfigRaw(ctx, key) - if err != nil || raw == nil { + if err != nil { return nil, err } + if raw == nil { + return nil, fmt.Errorf("node config %q has no JSON value", key) + } var val T if err := json.Unmarshal(raw, &val); err != nil { return nil, fmt.Errorf("unmarshal node_config value failed: %w", err) diff --git a/internal/repository/repotest/application_test_cases.go b/internal/repository/repotest/application_test_cases.go index 988774286..54014ab5b 100644 --- a/internal/repository/repotest/application_test_cases.go +++ b/internal/repository/repotest/application_test_cases.go @@ -79,7 +79,6 @@ func (s *ApplicationSuite) TestGetApplication() { s.Equal(app.ConsensusType, got.ConsensusType) s.Equal(app.Enabled, got.Enabled) s.Equal(app.Status, got.Status) - s.Equal(app.DataAvailability, got.DataAvailability) s.False(got.CreatedAt.IsZero(), "CreatedAt should be set") s.False(got.UpdatedAt.IsZero(), "UpdatedAt should be set") }) @@ -183,29 +182,6 @@ func (s *ApplicationSuite) TestListApplications() { s.Equal(Consensus_PRT, apps[0].ConsensusType) }) - s.Run("FilterByDataAvailability", func() { - NewApplicationBuilder(). - WithDataAvailability(DataAvailability_InputBox[:]). - Create(s.Ctx, s.T(), s.Repo) - // Create another app with a different DA selector - otherDA := DataAvailabilitySelector{0xaa, 0xbb, 0xcc, 0xdd} - NewApplicationBuilder(). - WithDataAvailability(otherDA[:]). - Create(s.Ctx, s.T(), s.Repo) - - da := DataAvailability_InputBox - apps, total, err := s.Repo.ListApplications( - s.Ctx, - repository.ApplicationFilter{DataAvailability: &da}, - repository.Pagination{Limit: 10}, - false, - ) - s.Require().NoError(err) - s.Len(apps, 1) - s.Equal(uint64(1), total) - s.Equal(DataAvailability_InputBox[:], apps[0].DataAvailability[:4]) - }) - s.Run("Pagination", func() { for range 5 { NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) @@ -245,24 +221,23 @@ func (s *ApplicationSuite) TestListApplications() { // Descending: second created should be first s.Equal(a2.ID, apps[0].ID) s.Equal(a1.ID, apps[1].ID) + s.Equal(a2.IInputBoxAddress, apps[0].IInputBoxAddress) + s.Equal(a1.IInputBoxAddress, apps[1].IInputBoxAddress) }) s.Run("CombinedFilters", func() { - // Create apps with different combinations of enabled flag, status, consensus, and DA. + // Create apps with different combinations of enabled flag, status, and consensus. NewApplicationBuilder(). WithStatus(ApplicationStatus_OK). WithConsensus(Consensus_Authority). - WithDataAvailability(DataAvailability_InputBox[:]). Create(s.Ctx, s.T(), s.Repo) NewApplicationBuilder(). WithStatus(ApplicationStatus_OK). WithConsensus(Consensus_PRT). - WithDataAvailability(DataAvailability_InputBox[:]). Create(s.Ctx, s.T(), s.Repo) NewApplicationBuilder(). WithEnabled(false). WithConsensus(Consensus_Authority). - WithDataAvailability(DataAvailability_InputBox[:]). Create(s.Ctx, s.T(), s.Repo) enabled := true @@ -314,35 +289,6 @@ func (s *ApplicationSuite) TestListApplications() { s.Equal(uint64(1), total) s.NotEqual(foreclosed.ID, got[0].ID) }) - - s.Run("CombinedStateAndDataAvailability", func() { - NewApplicationBuilder(). - WithStatus(ApplicationStatus_OK). - WithDataAvailability(DataAvailability_InputBox[:]). - Create(s.Ctx, s.T(), s.Repo) - - otherDA := DataAvailabilitySelector{0xaa, 0xbb, 0xcc, 0xdd} - NewApplicationBuilder(). - WithStatus(ApplicationStatus_OK). - WithDataAvailability(otherDA[:]). - Create(s.Ctx, s.T(), s.Repo) - - status := ApplicationStatus_OK - da := DataAvailability_InputBox - apps, total, err := s.Repo.ListApplications( - s.Ctx, - repository.ApplicationFilter{ - Status: &status, - DataAvailability: &da, - }, - repository.Pagination{Limit: 10}, - false, - ) - s.Require().NoError(err) - s.Len(apps, 1) - s.Equal(uint64(1), total) - s.Equal(DataAvailability_InputBox[:], apps[0].DataAvailability[:4]) - }) } func (s *ApplicationSuite) TestUpdateApplicationStatus() { @@ -1091,12 +1037,14 @@ func (s *ApplicationSuite) TestUpdateApplication() { s.Run("UpdatesFields", func() { app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) app.EpochLength = 20 + app.IInputBoxAddress = UniqueAddress() err := s.Repo.UpdateApplication(s.Ctx, app) s.Require().NoError(err) got, err := s.Repo.GetApplication(s.Ctx, app.Name) s.Require().NoError(err) s.Equal(uint64(20), got.EpochLength) + s.Equal(app.IInputBoxAddress, got.IInputBoxAddress) }) // UpdateApplication must not touch status or foreclosure columns. Those diff --git a/internal/repository/repotest/builders.go b/internal/repository/repotest/builders.go index f21545ed8..245c338b2 100644 --- a/internal/repository/repotest/builders.go +++ b/internal/repository/repotest/builders.go @@ -38,12 +38,12 @@ func nextID() uint64 { // UniqueAddress returns a unique Ethereum address for test isolation. func UniqueAddress() common.Address { - return common.BigToAddress(big.NewInt(int64(nextID()))) + return common.BigToAddress(new(big.Int).SetUint64(nextID())) } // UniqueHash returns a unique hash for test isolation. func UniqueHash() common.Hash { - return common.BigToHash(big.NewInt(int64(nextID()))) + return common.BigToHash(new(big.Int).SetUint64(nextID())) } // --------------------------------------------------------------------------- @@ -68,7 +68,6 @@ func NewApplicationBuilder() *ApplicationBuilder { EpochLength: 10, ClaimStagingPeriod: 7, WithdrawalConfig: defaultWithdrawalConfig(), - DataAvailability: DataAvailability_InputBox[:], ConsensusType: Consensus_Authority, Enabled: true, Status: ApplicationStatus_OK, @@ -111,11 +110,6 @@ func (b *ApplicationBuilder) WithClaimStagingPeriod(p uint64) *ApplicationBuilde return b } -func (b *ApplicationBuilder) WithDataAvailability(da []byte) *ApplicationBuilder { - b.app.DataAvailability = da - return b -} - func (b *ApplicationBuilder) WithWithdrawalConfig(wc WithdrawalConfig) *ApplicationBuilder { b.app.WithdrawalConfig = wc return b @@ -306,6 +300,16 @@ func NewTournamentBuilder(appID int64) *TournamentBuilder { Level: 0, Log2Step: 20, Height: 2, + Kind: TournamentKindNonLeaf, + InitialHash: UniqueHash(), + StartInstant: 1, + Allowance: 100, + Snapshot: TournamentSnapshot{ + AsOfBlock: 100, + Standing: TournamentStandingAwaitingClosure, + AcceptsJoins: true, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionTournamentRunning}, + }, }, } } @@ -322,12 +326,19 @@ func (b *TournamentBuilder) WithAddress(addr common.Address) *TournamentBuilder func (b *TournamentBuilder) WithLevel(l uint64) *TournamentBuilder { b.tournament.Level = l + if l == b.tournament.MaxLevel-1 { + b.tournament.Kind = TournamentKindLeaf + } else { + b.tournament.Kind = TournamentKindNonLeaf + } return b } func (b *TournamentBuilder) WithParent(addr common.Address, matchIDHash common.Hash) *TournamentBuilder { b.tournament.ParentTournamentAddress = &addr b.tournament.ParentMatchIDHash = &matchIDHash + b.tournament.CreationEvent = &TournamentCreationEvent{BlockNumber: 100, TxHash: UniqueHash()} + b.tournament.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentUnsettled} return b } @@ -346,6 +357,7 @@ type CommitmentBuilder struct { } func NewCommitmentBuilder(appID int64) *CommitmentBuilder { + submitter := UniqueAddress() return &CommitmentBuilder{ commitment: &Commitment{ ApplicationID: appID, @@ -353,9 +365,14 @@ func NewCommitmentBuilder(appID int64) *CommitmentBuilder { TournamentAddress: UniqueAddress(), Commitment: UniqueHash(), FinalStateHash: UniqueHash(), - SubmitterAddress: UniqueAddress(), + SubmitterAddress: submitter, BlockNumber: 100, TxHash: UniqueHash(), + Snapshot: CommitmentSnapshot{ + AsOfBlock: 100, + Claimer: submitter, + ClockAllowance: 100, + }, }, } } @@ -403,6 +420,18 @@ func NewMatchBuilder(appID int64) *MatchBuilder { TxHash: UniqueHash(), Winner: WinnerCommitment_NONE, DeletionReason: MatchDeletionReason_NOT_DELETED, + Snapshot: MatchSnapshot{ + AsOfBlock: 100, + Phase: MatchPhaseBisecting, + TimeoutOutcome: MatchTimeoutNone, + Bisection: &MatchBisectionSnapshot{ + RevealingParent: UniqueHash(), + WaitingLeft: UniqueHash(), + WaitingRight: UniqueHash(), + CurrentHeight: new(uint64(2)), + Responder: CommitmentSideOne, + }, + }, }, } } @@ -437,8 +466,14 @@ func (b *MatchBuilder) WithCommitmentTwo(h common.Hash) *MatchBuilder { return b } -func (b *MatchBuilder) WithDeletionReason(r MatchDeletionReason) *MatchBuilder { +func (b *MatchBuilder) WithDeletion(r MatchDeletionReason, block uint64, txHash common.Hash) *MatchBuilder { b.match.DeletionReason = r + b.match.DeletionBlockNumber = block + b.match.DeletionTxHash = new(txHash) + b.match.DeletionLogIndex = new(uint64(0)) + b.match.Snapshot = MatchSnapshot{ + AsOfBlock: max(b.match.Snapshot.AsOfBlock, block), Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, + } return b } diff --git a/internal/repository/repotest/bulk_test_cases.go b/internal/repository/repotest/bulk_test_cases.go index 81e33a41a..bcb95a835 100644 --- a/internal/repository/repotest/bulk_test_cases.go +++ b/internal/repository/repotest/bulk_test_cases.go @@ -5,7 +5,6 @@ package repotest import ( "context" - "encoding/hex" "fmt" "sync" @@ -722,12 +721,18 @@ func (s *BulkOperationsSuite) TestStoreClaimAndProofs() { } func (s *BulkOperationsSuite) TestStoreTournamentEvents() { + const ( + joinedEventScanEnd uint64 = 100 + advancedEventScanEnd uint64 = 200 + deletedEventScanEnd uint64 = 300 + ) // setupTournamentWithMatch creates a PRT app with a tournament, two // commitments, and one match, all stored via StoreTournamentEvents. type tournamentSetup struct { - app *Application - tournAddr common.Address - match *Match + app *Application + tournAddr common.Address + tournament *Tournament + match *Match } setupTournamentWithMatch := func() *tournamentSetup { s.T().Helper() @@ -767,12 +772,11 @@ func (s *BulkOperationsSuite) TestStoreTournamentEvents() { err = s.Repo.StoreTournamentEvents( s.Ctx, app.ID, - []*Commitment{commitment1, commitment2}, - []*Match{match}, - nil, nil, 100) + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{commitment1, commitment2}, Matches: []*Match{match}}}, joinedEventScanEnd) s.Require().NoError(err) - return &tournamentSetup{app: app, tournAddr: tournAddr, match: match} + return &tournamentSetup{app: app, tournAddr: tournAddr, tournament: tournament, match: match} } s.Run("StoresCommitmentsAndMatches", func() { @@ -802,18 +806,17 @@ func (s *BulkOperationsSuite) TestStoreTournamentEvents() { WithTournamentAddress(ts.tournAddr). WithIDHash(ts.match.IDHash). Build() + ts.tournament.Snapshot.AsOfBlock = advancedEventScanEnd err := s.Repo.StoreTournamentEvents( s.Ctx, ts.app.ID, - nil, nil, - []*MatchAdvanced{ma}, nil, 200) + []*repository.TournamentEventBatch{{Tournament: ts.tournament, MatchAdvances: []*MatchAdvanced{ma}}}, advancedEventScanEnd) s.Require().NoError(err) // Verify match advanced was stored gotMA, err := s.Repo.GetMatchAdvanced( s.Ctx, ts.app.IApplicationAddress.String(), - 0, ts.tournAddr.String(), ts.match.IDHash.Hex(), - hex.EncodeToString(ma.OtherParent[:])) + 0, ts.tournAddr.String(), ts.match.IDHash.Hex(), ma.TxHash, ma.LogIndex) s.Require().NoError(err) s.Require().NotNil(gotMA) s.Equal(ma.OtherParent, gotMA.OtherParent) @@ -823,20 +826,20 @@ func (s *BulkOperationsSuite) TestStoreTournamentEvents() { ts := setupTournamentWithMatch() // Mark the match as deleted (winner decided) - deletedMatch := &Match{ - EpochIndex: 0, - TournamentAddress: ts.tournAddr, - IDHash: ts.match.IDHash, - Winner: WinnerCommitment_ONE, - DeletionReason: MatchDeletionReason_TIMEOUT, - DeletionBlockNumber: 200, - DeletionTxHash: UniqueHash(), + deletedMatch := *ts.match + deletedMatch.Winner = WinnerCommitment_ONE + deletedMatch.DeletionReason = MatchDeletionReason_TIMEOUT + deletedMatch.DeletionBlockNumber = advancedEventScanEnd + deletedMatch.DeletionTxHash = new(UniqueHash()) + deletedMatch.DeletionLogIndex = new(uint64(0)) + deletedMatch.Snapshot = MatchSnapshot{ + AsOfBlock: deletedEventScanEnd, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, } + ts.tournament.Snapshot.AsOfBlock = deletedEventScanEnd err := s.Repo.StoreTournamentEvents( s.Ctx, ts.app.ID, - nil, nil, nil, - []*Match{deletedMatch}, 300) + []*repository.TournamentEventBatch{{Tournament: ts.tournament, Matches: []*Match{&deletedMatch}}}, deletedEventScanEnd) s.Require().NoError(err) // Verify the match was updated @@ -846,6 +849,8 @@ func (s *BulkOperationsSuite) TestStoreTournamentEvents() { s.Require().NoError(err) s.Equal(WinnerCommitment_ONE, gotMatch.Winner) s.Equal(MatchDeletionReason_TIMEOUT, gotMatch.DeletionReason) + s.Equal(deletedMatch.DeletionBlockNumber, gotMatch.DeletionBlockNumber) + s.Equal(deletedMatch.DeletionTxHash, gotMatch.DeletionTxHash) }) } @@ -1137,8 +1142,9 @@ func (s *BulkOperationsSuite) TestStoreClaimAndProofsRollback() { } func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { + const failedWindowEnd uint64 = 100 // Helper: create a PRT application with one closed epoch and a tournament. - setupPRTApp := func() (app *Application, tournAddr common.Address) { + setupPRTApp := func() (app *Application, tournament *Tournament) { s.T().Helper() app = NewApplicationBuilder(). WithConsensus(Consensus_PRT). @@ -1153,39 +1159,38 @@ func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { map[*Epoch][]*Input{epoch: {input}}, 10) s.Require().NoError(err) - tournAddr = UniqueAddress() - tournament := NewTournamentBuilder(app.ID). + tournAddr := UniqueAddress() + tournament = NewTournamentBuilder(app.ID). WithEpochIndex(0).WithAddress(tournAddr).Build() err = s.Repo.CreateTournament( s.Ctx, app.IApplicationAddress.String(), tournament) s.Require().NoError(err) - return app, tournAddr + return app, tournament } // Insert valid commitments + a match that references a non-existent - // tournament address, causing an FK violation. The commitments + // commitment, causing an FK violation. The commitments // inserted in the same transaction must be rolled back. s.Run("RollbackOnMatchInsertFailure", func() { - app, tournAddr := setupPRTApp() + app, tournament := setupPRTApp() + tournAddr := tournament.Address // Valid commitment targeting the real tournament commitment := NewCommitmentBuilder(app.ID). WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() - // Match targeting a non-existent tournament → FK violation - bogusAddr := UniqueAddress() + // Match referencing a non-existent second commitment → FK violation match := NewMatchBuilder(app.ID). WithEpochIndex(0). - WithTournamentAddress(bogusAddr). + WithTournamentAddress(tournAddr). WithCommitmentOne(commitment.Commitment). WithCommitmentTwo(UniqueHash()). Build() err := s.Repo.StoreTournamentEvents( s.Ctx, app.ID, - []*Commitment{commitment}, - []*Match{match}, - nil, nil, 100) + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{commitment}, Matches: []*Match{match}}}, failedWindowEnd) s.Require().Error(err) // Verify the commitment was rolled back @@ -1196,30 +1201,27 @@ func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { s.Nil(got, "commitment should have been rolled back") }) - // Insert valid commitments + try to delete (update) a non-existent - // match. updateMatches returns an error when RowsAffected == 0. - // The commitments inserted earlier in the same tx must be rolled back. - s.Run("RollbackOnMatchDeleteFailure", func() { - app, tournAddr := setupPRTApp() + // A newly observed deleted match still needs both joined commitments. + // Its missing second commitment must roll back the valid first join. + s.Run("RollbackOnDeletedMatchInsertFailure", func() { + app, tournament := setupPRTApp() + tournAddr := tournament.Address // Valid new commitment newCommitment := NewCommitmentBuilder(app.ID). WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() - // Try to delete a match that doesn't exist - deletedMatch := &Match{ - EpochIndex: 0, - TournamentAddress: tournAddr, - IDHash: UniqueHash(), // doesn't exist - Winner: WinnerCommitment_ONE, - DeletionReason: MatchDeletionReason_TIMEOUT, - } + deletedMatch := NewMatchBuilder(app.ID). + WithTournamentAddress(tournAddr). + WithCommitmentOne(newCommitment.Commitment). + WithCommitmentTwo(UniqueHash()). + WithWinner(WinnerCommitment_ONE). + WithDeletion(MatchDeletionReason_TIMEOUT, failedWindowEnd, UniqueHash()).Build() err := s.Repo.StoreTournamentEvents( s.Ctx, app.ID, - []*Commitment{newCommitment}, - nil, nil, - []*Match{deletedMatch}, 100) + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{newCommitment}, Matches: []*Match{deletedMatch}}}, failedWindowEnd) s.Require().Error(err) // Verify the new commitment was rolled back @@ -1233,7 +1235,8 @@ func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { // Insert a match advanced event for a match that doesn't exist, // causing an FK violation. Commitments in the same tx should roll back. s.Run("RollbackOnMatchAdvancedInsertFailure", func() { - app, tournAddr := setupPRTApp() + app, tournament := setupPRTApp() + tournAddr := tournament.Address // Valid new commitment newCommitment := NewCommitmentBuilder(app.ID). @@ -1248,10 +1251,8 @@ func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { err := s.Repo.StoreTournamentEvents( s.Ctx, app.ID, - []*Commitment{newCommitment}, - nil, - []*MatchAdvanced{bogusMA}, - nil, 100) + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{newCommitment}, MatchAdvances: []*MatchAdvanced{bogusMA}}}, failedWindowEnd) s.Require().Error(err) // Verify the commitment was rolled back diff --git a/internal/repository/repotest/claimer_test_cases.go b/internal/repository/repotest/claimer_test_cases.go index 4c7674b8e..73047e450 100644 --- a/internal/repository/repotest/claimer_test_cases.go +++ b/internal/repository/repotest/claimer_test_cases.go @@ -68,6 +68,69 @@ func (s *ClaimerSuite) TestSelectClaimsToSubmitPerApp() { s.Contains(apps, app.ID) }) + s.Run("LoadsCompleteStateProofForClaimAndBarrier", func() { + const ( + firstEpochLastBlock uint64 = 9 + secondEpochFirstBlock uint64 = 10 + secondEpochLastBlock uint64 = 19 + firstInputBlock uint64 = 5 + secondInputBlock uint64 = 15 + thirdEpochFirstBlock uint64 = 20 + ) + + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, firstEpochLastBlock).WithInputBounds(0, 0).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(secondEpochFirstBlock, secondEpochLastBlock).WithInputBounds(1, 1).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, + app.IApplicationAddress.String(), + map[*Epoch][]*Input{ + epoch0: {NewInputBuilder().WithIndex(0).WithBlockNumber(firstInputBlock).Build()}, + epoch1: {NewInputBuilder().WithIndex(1).WithBlockNumber(secondInputBlock).Build()}, + }, + thirdEpochFirstBlock, + ) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch0, EpochStatus_ClaimComputed) + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, epoch0.Index, UniqueHash()) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch1, EpochStatus_ClaimComputed) + + barriers, computed, _, err := s.Repo.SelectClaimsToSubmitPerApp(s.Ctx) + s.Require().NoError(err) + s.Require().Contains(barriers, app.ID) + s.Require().Contains(computed, app.ID) + + assertStateProof := func(expected, actual *Epoch) { + s.Require().True(actual.HasCompleteStateProof()) + s.Equal(expected.MachineHash, actual.MachineHash) + s.Equal(expected.TxBufferDataBlock, actual.TxBufferDataBlock) + s.Equal(expected.TxBufferProof, actual.TxBufferProof) + s.Equal(expected.IflagsYDataBlock, actual.IflagsYDataBlock) + s.Equal(expected.IflagsYProof, actual.IflagsYProof) + s.Equal(expected.HtifTohostDataBlock, actual.HtifTohostDataBlock) + s.Equal(expected.HtifTohostProof, actual.HtifTohostProof) + } + + s.Equal(epoch0.Index, barriers[app.ID].Index) + s.Equal(EpochStatus_ClaimSubmitted, barriers[app.ID].Status) + assertStateProof(epoch0, barriers[app.ID]) + + s.Equal(epoch1.Index, computed[app.ID].Index) + s.Equal(EpochStatus_ClaimComputed, computed[app.ID].Status) + assertStateProof(epoch1, computed[app.ID]) + }) + s.Run("IncludesForeclosedComputedAppForTerminalization", func() { app := s.createAppWithClaimComputedEpoch() err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 100, UniqueHash(), 100) @@ -501,6 +564,63 @@ func (s *ClaimerSuite) TestSelectClaimsToAcceptPerApp() { }) } +//nolint:mnd // Explicit epoch and block values define the barrier ordering. +func (s *ClaimerSuite) TestNewestBarrierFiltersStatusBeforeLimit() { + s.Run("PreservesProofsAndForeclosedBarriers", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epochs := make([]*Epoch, 3) + inputs := make(map[*Epoch][]*Input, len(epochs)) + for i := range epochs { + index := uint64(i) + epochs[i] = NewEpochBuilder(app.ID).WithIndex(index).WithStatus(EpochStatus_Closed). + WithBlocks(index*10, index*10+9).WithInputBounds(index, index).Build() + inputs[epochs[i]] = []*Input{NewInputBuilder().WithIndex(index).WithBlockNumber(index*10 + 5).Build()} + } + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, app.Name, inputs, 30)) + for _, epoch := range epochs { + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, app.Name, epoch, EpochStatus_ClaimComputed) + } + for index := range uint64(2) { + s.Require().NoError(s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, index, UniqueHash())) + s.Require().NoError(s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, index, 40+index)) + } + acceptedTx := UniqueHash() + s.Require().NoError(s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, &acceptedTx)) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 100, UniqueHash(), 100)) + + submit, _, _, err := s.Repo.SelectClaimsToSubmitPerApp(s.Ctx) + s.Require().NoError(err) + stage, _, _, err := s.Repo.SelectClaimsToStagePerApp(s.Ctx) + s.Require().NoError(err) + accept, _, _, err := s.Repo.SelectClaimsToAcceptPerApp(s.Ctx) + s.Require().NoError(err) + for _, result := range []struct { + barriers map[int64]*Epoch + index uint64 + }{{submit, 1}, {stage, 0}, {accept, 0}} { + s.Require().Contains(result.barriers, app.ID) + actual := result.barriers[app.ID] + expected, err := s.Repo.GetEpoch(s.Ctx, app.Name, result.index) + s.Require().NoError(err) + s.Equal(expected.Index, actual.Index) + s.Equal(expected.Status, actual.Status) + s.Equal(expected.StagedAtBlock, actual.StagedAtBlock) + s.Equal(expected.ClaimTransactionHash, actual.ClaimTransactionHash) + s.Equal(expected.VirtualIndex, actual.VirtualIndex) + s.Equal(expected.CreatedAt, actual.CreatedAt) + s.Equal(expected.UpdatedAt, actual.UpdatedAt) + s.Require().True(actual.HasCompleteStateProof()) + s.Equal(expected.MachineHash, actual.MachineHash) + s.Equal(expected.TxBufferDataBlock, actual.TxBufferDataBlock) + s.Equal(expected.TxBufferProof, actual.TxBufferProof) + s.Equal(expected.IflagsYDataBlock, actual.IflagsYDataBlock) + s.Equal(expected.IflagsYProof, actual.IflagsYProof) + s.Equal(expected.HtifTohostDataBlock, actual.HtifTohostDataBlock) + s.Equal(expected.HtifTohostProof, actual.HtifTohostProof) + } + }) +} + func (s *ClaimerSuite) TestUpdateEpochWithSubmittedClaim() { s.Run("SetsClaimSubmitted", func() { app := s.createAppWithClaimComputedEpoch() diff --git a/internal/repository/repotest/epoch_test_cases.go b/internal/repository/repotest/epoch_test_cases.go index 3bb013dc7..b2d94deef 100644 --- a/internal/repository/repotest/epoch_test_cases.go +++ b/internal/repository/repotest/epoch_test_cases.go @@ -1188,20 +1188,10 @@ func (s *EpochSuite) TestEpochStatusTransitionTrigger() { s.Contains(err.Error(), "PRT") }) - // Verify the trigger rejects CLAIM_STAGED for PRT apps. PRT settles via - // tournaments and never goes through the staging contract path; an - // attempt to mark a PRT epoch as STAGED would be local data corruption. - // The trigger guard is the last line of defense against any caller - // that bypasses the higher-level claimer/PRT services. We advance the - // PRT epoch through CLAIM_SUBMITTED (a transition the trigger does - // permit, just never exercised in production for PRT) so that - // UpdateEpochToStaged sets the staged_at_block atomically and the - // PRT guard is the only remaining check that can reject the UPDATE. - s.Run("RejectsPRTStaged", func() { + newPRTComputedEpoch := func() (*Application, *Epoch) { app := NewApplicationBuilder(). WithConsensus(Consensus_PRT). Create(s.Ctx, s.T(), s.Repo) - epoch := NewEpochBuilder(app.ID). WithIndex(0).WithStatus(EpochStatus_Closed). WithBlocks(0, 9).WithInputBounds(0, 0). @@ -1215,11 +1205,40 @@ func (s *EpochSuite) TestEpochStatusTransitionTrigger() { AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, app.IApplicationAddress.String(), epoch, - EpochStatus_ClaimSubmitted) + EpochStatus_ClaimComputed) + return app, epoch + } - err = s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, epoch.Index, 42) - s.Require().Error(err) - s.Contains(err.Error(), "PRT") + // Dave/PRT stages a finished tournament result directly from + // CLAIM_COMPUTED. The repository records the staging block without a + // transaction hash. The acceptance evidence supplies that hash later. + s.Run("AllowsPRTStagedThenAccepted", func() { + const stagedAtBlock uint64 = 42 + + app, epoch := newPRTComputedEpoch() + + err := s.Repo.UpdateEpochReconciledStaged(s.Ctx, app.ID, epoch.Index, stagedAtBlock) + s.Require().NoError(err) + + staged, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimStaged, staged.Status) + s.Require().NotNil(staged.StagedAtBlock) + s.Equal(stagedAtBlock, *staged.StagedAtBlock) + s.Nil(staged.ClaimTransactionHash) + + acceptanceTransactionHash := UniqueHash() + err = s.Repo.UpdateEpochWithAcceptedClaim( + s.Ctx, app.ID, epoch.Index, &acceptanceTransactionHash) + s.Require().NoError(err) + + accepted, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, accepted.Status) + s.Require().NotNil(accepted.StagedAtBlock) + s.Equal(stagedAtBlock, *accepted.StagedAtBlock) + s.Require().NotNil(accepted.ClaimTransactionHash) + s.Equal(acceptanceTransactionHash, *accepted.ClaimTransactionHash) }) // Verify the trigger / CHECK constraint rejects any transition into @@ -1227,22 +1246,18 @@ func (s *EpochSuite) TestEpochStatusTransitionTrigger() { // only writes the Status column, so it cannot set staged_at_block // atomically — that is exactly the situation this invariant is meant // to catch. - s.Run("RejectsStagedWithoutBlock", func() { - seed := Seed(s.Ctx, s.T(), s.Repo) - - AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, - seed.App.IApplicationAddress.String(), seed.Epoch, - EpochStatus_ClaimComputed) + s.Run("RejectsPRTStagedWithoutBlock", func() { + app, epoch := newPRTComputedEpoch() // Sanity: staged_at_block is NULL on this freshly built row. got, err := s.Repo.GetEpoch( - s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Ctx, app.IApplicationAddress.String(), epoch.Index) s.Require().NoError(err) s.Require().Nil(got.StagedAtBlock) - seed.Epoch.Status = EpochStatus_ClaimStaged + epoch.Status = EpochStatus_ClaimStaged err = s.Repo.UpdateEpochStatus( - s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Ctx, app.IApplicationAddress.String(), epoch) s.Require().Error(err) // The trigger surfaces first with this exact phrasing; if a future // refactor disables the trigger, the CHECK constraint @@ -1286,6 +1301,8 @@ func (s *EpochSuite) TestDrainGates() { inputStatus := InputCompletionStatus_None switch target { + case EpochStatus_Open, EpochStatus_Closed: + // These states have no completed input. case EpochStatus_InputsProcessed, EpochStatus_ClaimComputed, EpochStatus_ClaimSubmitted, diff --git a/internal/repository/repotest/match_advanced_test_cases.go b/internal/repository/repotest/match_advanced_test_cases.go index 09c900655..eb7e52fbb 100644 --- a/internal/repository/repotest/match_advanced_test_cases.go +++ b/internal/repository/repotest/match_advanced_test_cases.go @@ -4,8 +4,6 @@ package repotest import ( - "encoding/hex" - "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/common" ) @@ -85,8 +83,7 @@ func (s *MatchAdvancedSuite) TestGetMatchAdvanced() { got, err := s.Repo.GetMatchAdvanced( s.Ctx, seed.App.IApplicationAddress.String(), - 0, tournAddr.String(), matchIDHash.Hex(), - hex.EncodeToString(ma.OtherParent[:])) + 0, tournAddr.String(), matchIDHash.Hex(), ma.TxHash, ma.LogIndex) s.Require().NoError(err) s.Equal(ma.IDHash, got.IDHash) s.Equal(ma.OtherParent, got.OtherParent) @@ -97,8 +94,7 @@ func (s *MatchAdvancedSuite) TestGetMatchAdvanced() { nonExistent := UniqueHash() got, err := s.Repo.GetMatchAdvanced( s.Ctx, seed.App.IApplicationAddress.String(), - 0, tournAddr.String(), matchIDHash.Hex(), - hex.EncodeToString(nonExistent[:])) + 0, tournAddr.String(), matchIDHash.Hex(), nonExistent, 0) s.Require().NoError(err) s.Nil(got) }) diff --git a/internal/repository/repotest/match_test_cases.go b/internal/repository/repotest/match_test_cases.go index 6f5515c46..6ef6d9c21 100644 --- a/internal/repository/repotest/match_test_cases.go +++ b/internal/repository/repotest/match_test_cases.go @@ -4,7 +4,6 @@ package repotest import ( - . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" "github.com/ethereum/go-ethereum/common" ) @@ -99,33 +98,6 @@ func (s *MatchSuite) TestGetMatch() { }) } -func (s *MatchSuite) TestUpdateMatch() { - s.Run("UpdatesWinner", func() { - setup := s.setupTournamentWithCommitments() - match := NewMatchBuilder(setup.seed.App.ID). - WithEpochIndex(0). - WithTournamentAddress(setup.tournAddr). - WithCommitmentOne(setup.commitHash1). - WithCommitmentTwo(setup.commitHash2). - Build() - - err := s.Repo.CreateMatch( - s.Ctx, setup.seed.App.IApplicationAddress.String(), match) - s.Require().NoError(err) - - match.Winner = WinnerCommitment_ONE - err = s.Repo.UpdateMatch( - s.Ctx, setup.seed.App.IApplicationAddress.String(), match) - s.Require().NoError(err) - - got, err := s.Repo.GetMatch( - s.Ctx, setup.seed.App.IApplicationAddress.String(), - 0, setup.tournAddr.String(), match.IDHash.Hex()) - s.Require().NoError(err) - s.Equal(WinnerCommitment_ONE, got.Winner) - }) -} - func (s *MatchSuite) TestListMatches() { s.Run("EmptyResult", func() { seed := Seed(s.Ctx, s.T(), s.Repo) diff --git a/internal/repository/repotest/node_config_test_cases.go b/internal/repository/repotest/node_config_test_cases.go index b888e6a3e..fe57ccab0 100644 --- a/internal/repository/repotest/node_config_test_cases.go +++ b/internal/repository/repotest/node_config_test_cases.go @@ -5,11 +5,81 @@ package repotest import ( "encoding/json" + "fmt" + "sync" + "github.com/cartesi/rollups-node/internal/config" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" ) +const nodeConfigTestChainID = 31337 + +func (s *NodeConfigSuite) TestConcurrentConfigInitializationPreservesWinner() { + s.Run("opposite submission modes", func() { + const key = "concurrent-init" + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + for _, enabled := range []bool{false, true} { + wg.Go(func() { + <-start + requested := config.PersistentSubmitterConfig{ + DefaultBlock: DefaultBlock_Finalized, ChainID: nodeConfigTestChainID, ClaimSubmissionEnabled: enabled, + } + stored, err := repository.InitializeNodeConfig(s.Ctx, s.Repo, &NodeConfig[config.PersistentSubmitterConfig]{ + Key: key, Value: requested, + }) + if err == nil { + err = stored.Value.CheckRequested(requested) + } + errs <- err + }) + } + close(start) + wg.Wait() + close(errs) + var successes, conflicts int + for err := range errs { + if err == nil { + successes++ + } else { + s.Require().ErrorContains(err, "claim submission mode mismatch") + conflicts++ + } + } + s.Equal(1, successes) + s.Equal(1, conflicts) + winner, err := repository.LoadNodeConfig[config.PersistentSubmitterConfig](s.Ctx, s.Repo, key) + s.Require().NoError(err) + opposite := winner.Value + opposite.ClaimSubmissionEnabled = !opposite.ClaimSubmissionEnabled + retained, err := repository.InitializeNodeConfig(s.Ctx, s.Repo, &NodeConfig[config.PersistentSubmitterConfig]{ + Key: key, Value: opposite, + }) + s.Require().NoError(err) + s.Equal(winner.Value, retained.Value) + s.Equal(winner.CreatedAt, retained.CreatedAt) + s.Equal(winner.UpdatedAt, retained.UpdatedAt) + }) +} + +func (s *NodeConfigSuite) TestConfigInitializationDoesNotRepairInvalidSavedValue() { + for i, raw := range []string{`{}`, `null`, `{"ChainID":0,"DefaultBlock":"FINALIZED"}`} { + s.Run(fmt.Sprint(i), func() { + key := fmt.Sprintf("invalid-config-%d", i) + s.Require().NoError(s.Repo.SaveNodeConfigRaw(s.Ctx, key, []byte(raw))) + _, err := repository.InitializeNodeConfig(s.Ctx, s.Repo, &NodeConfig[config.PersistentChainConfig]{ + Key: key, Value: config.PersistentChainConfig{DefaultBlock: DefaultBlock_Finalized, ChainID: nodeConfigTestChainID}, + }) + s.Require().Error(err) + got, _, _, err := s.Repo.LoadNodeConfigRaw(s.Ctx, key) + s.Require().NoError(err) + s.JSONEq(raw, string(got)) + }) + } +} + type NodeConfigSuite struct { BaseSuite } diff --git a/internal/repository/repotest/output_test_cases.go b/internal/repository/repotest/output_test_cases.go index 996a887bf..fad749250 100644 --- a/internal/repository/repotest/output_test_cases.go +++ b/internal/repository/repotest/output_test_cases.go @@ -416,27 +416,47 @@ func (s *OutputSuite) TestListOutputs() { } func (s *OutputSuite) TestUpdateOutputsExecution() { - s.Run("UpdatesExecutionHash", func() { - seed := Seed(s.Ctx, s.T(), s.Repo) - - s.storeAdvanceResult(seed.App.ID, 0, 0, - [][]byte{[]byte("output-data")}, nil) + const rollbackCheckpoint = uint64(100) + for _, test := range []struct { + name string + previousBlock uint64 + requestedBlock uint64 + expectedBlock uint64 + }{ + {"ForwardCursor", 100, 200, 200}, + {"EqualCursor", 200, 200, 200}, + {"OlderCursor", 200, 100, 200}, + } { + s.Run(test.name, func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("output-data")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, test.previousBlock) + s.Require().NoError(err) - txHash := UniqueHash() - out := &Output{ - InputEpochApplicationID: seed.App.ID, - Index: 0, - ExecutionTransactionHash: &txHash, - } - err := s.Repo.UpdateOutputsExecution( - s.Ctx, seed.App.IApplicationAddress.String(), []*Output{out}, 100) - s.Require().NoError(err) + txHash := UniqueHash() + out := &Output{ + InputEpochApplicationID: seed.App.ID, + Index: 0, + ExecutionTransactionHash: &txHash, + } + err = s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), []*Output{out}, test.requestedBlock) + s.Require().NoError(err) - got, err := s.Repo.GetOutput(s.Ctx, seed.App.IApplicationAddress.String(), 0) - s.Require().NoError(err) - s.Require().NotNil(got.ExecutionTransactionHash) - s.Equal(txHash, *got.ExecutionTransactionHash) - }) + got, err := s.Repo.GetOutput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Require().NotNil(got.ExecutionTransactionHash) + s.Equal(txHash, *got.ExecutionTransactionHash, + "output updates must commit even when the cursor is already ahead") + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(test.expectedBlock, block) + }) + } // Regression guard: all output updates must be transactional. // Verify multiple outputs are updated atomically in a single call. @@ -497,6 +517,9 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { s.storeAdvanceResult(seed.App.ID, 0, 0, [][]byte{[]byte("o0"), []byte("o1")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, rollbackCheckpoint) + s.Require().NoError(err) txHash := UniqueHash() outputs := []*Output{ @@ -517,7 +540,7 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { }, } - err := s.Repo.UpdateOutputsExecution( + err = s.Repo.UpdateOutputsExecution( s.Ctx, seed.App.IApplicationAddress.String(), outputs, 200) s.Require().Error(err) @@ -535,6 +558,9 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { s.Ctx, seed.App.IApplicationAddress.String()) s.Require().NoError(err) s.Equal(uint64(0), count) + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(rollbackCheckpoint, block, "failed output updates must not advance the cursor") }) // Verify that a nil hash on the second output rolls back the first @@ -545,6 +571,9 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { s.storeAdvanceResult(seed.App.ID, 0, 0, [][]byte{[]byte("o0"), []byte("o1")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, rollbackCheckpoint) + s.Require().NoError(err) txHash := UniqueHash() outputs := []*Output{ @@ -560,7 +589,7 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { }, } - err := s.Repo.UpdateOutputsExecution( + err = s.Repo.UpdateOutputsExecution( s.Ctx, seed.App.IApplicationAddress.String(), outputs, 200) s.Require().Error(err) @@ -570,6 +599,9 @@ func (s *OutputSuite) TestUpdateOutputsExecution() { s.Require().NoError(err) s.Nil(got.ExecutionTransactionHash, "output 0 should not have execution hash after rollback") + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(rollbackCheckpoint, block, "invalid output data must not advance the cursor") }) } diff --git a/internal/repository/repotest/state_proof.go b/internal/repository/repotest/state_proof.go new file mode 100644 index 000000000..7d1d40c06 --- /dev/null +++ b/internal/repository/repotest/state_proof.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "encoding/binary" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// KeccakStateProof builds a valid machine proof with accepted-state registers. +// DummyStateProof remains available for non-cryptographic fixtures. +func KeccakStateProof(outputsRoot common.Hash) StateProof { + const ( + iflagsYDataBlockAddress uint64 = 0x300 + htifTohostDataBlockAddress uint64 = 0x320 + txBufferDataBlockAddress = 0x60800000 + acceptedHtifTohost uint64 = 2<<56 | 1<<48 | 1<<32 + ) + var iflagsYDataBlock common.Hash + binary.LittleEndian.PutUint64(iflagsYDataBlock[8:], 1) + var htifTohostDataBlock common.Hash + binary.LittleEndian.PutUint64(htifTohostDataBlock[16:], acceptedHtifTohost) + blocks := map[uint64]common.Hash{ + iflagsYDataBlockAddress: iflagsYDataBlock, + htifTohostDataBlockAddress: htifTohostDataBlock, + txBufferDataBlockAddress: outputsRoot, + } + machineHash, proofs := buildKeccakStateProofs(blocks) + return StateProof{ + MachineHash: machineHash, + IflagsYDataBlock: iflagsYDataBlock, + IflagsYProof: proofs[iflagsYDataBlockAddress], + HtifTohostDataBlock: htifTohostDataBlock, + HtifTohostProof: proofs[htifTohostDataBlockAddress], + TxBufferDataBlock: outputsRoot, + TxBufferProof: proofs[txBufferDataBlockAddress], + } +} + +func buildKeccakStateProofs(blocks map[uint64]common.Hash) (common.Hash, map[uint64][][32]byte) { + const log2DataBlockSize = 5 + defaultHashes := make([]common.Hash, StateProofSiblingCount+1) + defaultHashes[0] = crypto.Keccak256Hash(make([]byte, 1<> log2DataBlockSize + indexes[address] = index + current[index] = crypto.Keccak256Hash(block[:]) + siblings[address] = make([][32]byte, 0, StateProofSiblingCount) + } + for level := 0; level < StateProofSiblingCount; level++ { + for address, originalIndex := range indexes { + index := originalIndex >> level + sibling, ok := current[index^1] + if !ok { + sibling = defaultHashes[level] + } + siblings[address] = append(siblings[address], sibling) + } + parents := make(map[uint64]struct{}, len(current)) + for index := range current { + parents[index>>1] = struct{}{} + } + next := make(map[uint64]common.Hash, len(parents)) + for parent := range parents { + left, ok := current[parent<<1] + if !ok { + left = defaultHashes[level] + } + right, ok := current[parent<<1|1] + if !ok { + right = defaultHashes[level] + } + next[parent] = crypto.Keccak256Hash(left[:], right[:]) + } + current = next + } + return current[0], siblings +} diff --git a/internal/repository/repotest/state_proof_test.go b/internal/repository/repotest/state_proof_test.go new file mode 100644 index 000000000..9783a817b --- /dev/null +++ b/internal/repository/repotest/state_proof_test.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "encoding/binary" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestKeccakStateProof(t *testing.T) { + outputsRoot := common.HexToHash("0xa1") + proof := KeccakStateProof(outputsRoot) + require.True(t, proof.IsComplete()) + require.Equal(t, outputsRoot, proof.TxBufferDataBlock) + require.Equal(t, uint64(1), binary.LittleEndian.Uint64(proof.IflagsYDataBlock[8:16])) + require.Equal(t, uint64(2)<<56|uint64(1)<<48|uint64(1)<<32, binary.LittleEndian.Uint64(proof.HtifTohostDataBlock[16:24])) + for _, leaf := range []struct { + name string + address uint64 + data common.Hash + siblings [][32]byte + }{ + {name: "iflags.Y", address: 0x300, data: proof.IflagsYDataBlock, siblings: proof.IflagsYProof}, + {name: "htif.tohost", address: 0x320, data: proof.HtifTohostDataBlock, siblings: proof.HtifTohostProof}, + {name: "outputs root", address: 0x60800000, data: proof.TxBufferDataBlock, siblings: proof.TxBufferProof}, + } { + t.Run(leaf.name, func(t *testing.T) { + hash := crypto.Keccak256Hash(leaf.data[:]) + index := leaf.address >> 5 + for _, sibling := range leaf.siblings { + if index&1 == 0 { + hash = crypto.Keccak256Hash(hash[:], sibling[:]) + } else { + hash = crypto.Keccak256Hash(sibling[:], hash[:]) + } + index >>= 1 + } + require.Equal(t, proof.MachineHash, hash) + }) + } +} diff --git a/internal/repository/repotest/tournament_test_cases.go b/internal/repository/repotest/tournament_test_cases.go index 8dcddcfbb..24e60f9bc 100644 --- a/internal/repository/repotest/tournament_test_cases.go +++ b/internal/repository/repotest/tournament_test_cases.go @@ -39,7 +39,6 @@ func (s *TournamentSuite) TestCreateTournament() { s.Ctx, seed.App.IApplicationAddress.String(), first)) replay := *first - replay.MaxLevel++ err := s.Repo.CreateTournament( s.Ctx, seed.App.IApplicationAddress.String(), &replay) s.Require().NoError(err) @@ -51,6 +50,21 @@ func (s *TournamentSuite) TestCreateTournament() { s.Equal(first.MaxLevel, got.MaxLevel, "an exact replay must not overwrite the first observation") }) + s.Run("ConflictingDescriptorIsAnError", func() { + seed := s.seedWithEpoch() + first := NewTournamentBuilder(seed.App.ID).Build() + s.Require().NoError(s.Repo.CreateTournament(s.Ctx, seed.App.Name, first)) + + conflicting := *first + conflicting.MaxLevel++ + s.Require().Error(s.Repo.CreateTournament(s.Ctx, seed.App.Name, &conflicting)) + + got, err := s.Repo.GetTournament(s.Ctx, seed.App.Name, first.Address.String()) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(first.MaxLevel, got.MaxLevel) + }) + s.Run("DifferentRootForSameEpochIsAnError", func() { seed := s.seedWithEpoch() first := NewTournamentBuilder(seed.App.ID). @@ -92,30 +106,6 @@ func (s *TournamentSuite) TestGetTournament() { }) } -func (s *TournamentSuite) TestUpdateTournament() { - s.Run("UpdatesFields", func() { - seed := s.seedWithEpoch() - tournament := NewTournamentBuilder(seed.App.ID). - WithEpochIndex(0).Build() - - err := s.Repo.CreateTournament( - s.Ctx, seed.App.IApplicationAddress.String(), tournament) - s.Require().NoError(err) - - winnerHash := UniqueHash() - tournament.WinnerCommitment = &winnerHash - err = s.Repo.UpdateTournament( - s.Ctx, seed.App.IApplicationAddress.String(), tournament) - s.Require().NoError(err) - - got, err := s.Repo.GetTournament( - s.Ctx, seed.App.IApplicationAddress.String(), tournament.Address.String()) - s.Require().NoError(err) - s.Require().NotNil(got.WinnerCommitment) - s.Equal(winnerHash, *got.WinnerCommitment) - }) -} - func (s *TournamentSuite) TestListTournaments() { s.Run("EmptyResult", func() { seed := s.seedWithEpoch() diff --git a/internal/validator/commitment_proof_test.go b/internal/validator/commitment_proof_test.go new file mode 100644 index 000000000..31f4b58ae --- /dev/null +++ b/internal/validator/commitment_proof_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "errors" + "io" + "log/slog" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" +) + +func TestCommitmentProofFailureClassification(t *testing.T) { + machineHash := common.HexToHash("0x1234") + dbErr := errors.New("status write failed") + for _, tc := range []struct { + name string + tree *merkle.Tree + status model.ApplicationStatus + errorText string + cause error + writeError error + }{ + { + name: "internal tree invariant", tree: &merkle.Tree{Height: uint32(model.Log2EpochComputationHashLeafCount)}, + status: model.ApplicationStatus_Failed, errorText: "internal invariant violated", cause: merkle.ErrInvariant, + }, + { + name: "invariant with status write failure", tree: &merkle.Tree{Height: uint32(model.Log2EpochComputationHashLeafCount)}, + status: model.ApplicationStatus_Failed, errorText: "internal invariant violated", cause: merkle.ErrInvariant, writeError: dbErr, + }, + { + name: "internal child height mismatch", + tree: &merkle.Tree{ + Height: uint32(model.Log2EpochComputationHashLeafCount), + Subtrees: &merkle.InnerNode{ + LHS: merkle.TreeLeaf(machineHash), RHS: merkle.TreeLeaf(machineHash), + }, + }, + status: model.ApplicationStatus_Failed, errorText: "index out of bounds", cause: merkle.ErrBadInput, + }, + { + name: "stored height mismatch", tree: merkle.TreeLeaf(machineHash).Iterated(model.Log2EpochComputationHashLeafCount - 1), + status: model.ApplicationStatus_Corrupted, errorText: "commitment tree height", + }, + { + name: "stored final hash mismatch", tree: merkle.TreeLeaf(common.Hash{}).Iterated(model.Log2EpochComputationHashLeafCount), + status: model.ApplicationStatus_Corrupted, errorText: "does not match machine hash", + }, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newMockrepo() + app := &model.Application{ID: 42, Name: "proof-app", Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, tc.status, mock.Anything). + Return(tc.writeError).Once() + + commitment, proof, err := s.proveCommitment(t.Context(), app, epoch, tc.tree) + + require.ErrorContains(t, err, tc.errorText) + require.Nil(t, commitment) + require.Nil(t, proof) + if tc.cause != nil { + require.ErrorIs(t, err, tc.cause) + } + if tc.writeError != nil { + require.ErrorIs(t, err, tc.writeError) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } else { + require.Equal(t, tc.status, app.Status) + } + repo.AssertExpectations(t) + }) + } +} + +func TestCommitmentProofMatchesEpoch(t *testing.T) { + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + machineHash := common.HexToHash("0x1234") + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + tree := merkle.TreeLeaf(machineHash).Iterated(model.Log2EpochComputationHashLeafCount) + app := &model.Application{Status: model.ApplicationStatus_OK} + + commitment, proof, err := s.proveCommitment(t.Context(), app, epoch, tree) + + require.NoError(t, err) + require.Equal(t, tree.GetRootHash(), *commitment) + require.Equal(t, machineHash, proof.Node) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/validator/publication_test.go b/internal/validator/publication_test.go new file mode 100644 index 000000000..506e73b1a --- /dev/null +++ b/internal/validator/publication_test.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "bytes" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestClaimPublicationOutcome(t *testing.T) { + for _, test := range []struct { + name string + storeErr error + obsolete bool + }{ + {name: "published"}, + {name: "foreclosed during publication", storeErr: fmt.Errorf("state changed: %w", repository.ErrEpochForeclosed), obsolete: true}, + {name: "unexpected state", storeErr: repository.ErrNoUpdate}, + {name: "database failure", storeErr: errors.New("database write failed")}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockrepo() + var output bytes.Buffer + postContext := merkle.CreatePostContext() + s := &Service{ + repository: repo, pristinePostContext: postContext, pristineRootHash: postContext[merkle.TREE_DEPTH], + } + s.Logger = slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + // Keep the application snapshot stale. The repository outcome, not + // this object's foreclosure field, must decide whether work is obsolete. + app := &Application{ID: 7, Name: "publication-app", ConsensusType: Consensus_PRT, + Status: ApplicationStatus_OK, TemplateHash: s.pristineRootHash} + epochs := []*Epoch{ + {ApplicationID: app.ID, Index: 0, VirtualIndex: 0, LastBlock: 9, Status: EpochStatus_InputsProcessed, + MachineHash: &s.pristineRootHash, TxBufferDataBlock: &s.pristineRootHash}, + {ApplicationID: app.ID, Index: 1, VirtualIndex: 1, FirstBlock: 10, LastBlock: 19, Status: EpochStatus_InputsProcessed, + MachineHash: &s.pristineRootHash, TxBufferDataBlock: &s.pristineRootHash}, + } + appAddress := app.IApplicationAddress.String() + repo.On("ListEpochs", mock.Anything, appAddress, mock.Anything, repository.Pagination{}, false). + Return(epochs, uint64(len(epochs)), nil).Once() + wantContinued := test.obsolete || test.storeErr == nil + for i, epoch := range epochs { + if i > 0 && !wantContinued { + break + } + repo.On("ListOutputs", mock.Anything, appAddress, repository.OutputFilter{EpochIndex: &epoch.Index}, + repository.Pagination{}, false).Return([]*Output{}, uint64(0), nil).Once() + repo.On("GetLastInput", mock.Anything, appAddress, epoch.Index).Return((*Input)(nil), nil).Once() + storeErr := test.storeErr + if i > 0 { + storeErr = nil + repo.On("GetEpochByVirtualIndex", mock.Anything, appAddress, epoch.VirtualIndex-1).Return(epochs[0], nil).Twice() + } + repo.On("StoreClaimAndProofs", mock.Anything, epoch, mock.Anything).Return(storeErr).Once() + } + + err := s.validateApplication(t.Context(), app) + + if wantContinued { + require.NoError(t, err) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 2) + } else { + require.ErrorIs(t, err, test.storeErr) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 1) + } + const obsoleteMessage = "Epoch was foreclosed before claim publication; discarding obsolete claim and proofs" + if test.obsolete { + require.Contains(t, output.String(), "level=INFO msg=\""+obsoleteMessage+"\"") + } else { + require.NotContains(t, output.String(), obsoleteMessage) + } + require.Equal(t, ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/validator/shutdown_test.go b/internal/validator/shutdown_test.go new file mode 100644 index 000000000..2937b2ebe --- /dev/null +++ b/internal/validator/shutdown_test.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/service" +) + +func TestValidatorTickShutdownErrors(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, operation := range []string{"applications", "epochs", "outputs", "claim write"} { + for _, stopping := range []bool{false, true} { + for _, test := range []struct { + name string + cause error + cancellationOnly bool + }{ + {"cancellation", context.Canceled, true}, + {"wrapped cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined cancellations", errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), true}, + {"database query failure", dbErr, false}, + {"deadline", context.DeadlineExceeded, false}, + {"mixed deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"mixed database failure", errors.Join(context.Canceled, dbErr), false}, + {"nested database failure", fmt.Errorf("query: %w", errors.Join(context.Canceled, dbErr)), false}, + } { + t.Run(fmt.Sprintf("%s/stopping=%t/%s", operation, stopping, test.name), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s, logs := validatorShutdownFixture(ctx, t, operation, test.cause, func(mock.Arguments) { + if stopping { + cancel() + } + }) + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if stopping && test.cancellationOnly { + require.NoError(t, err) + require.NotContains(t, logs.String(), "level=ERROR") + require.NotContains(t, logs.String(), "level=WARN") + } else { + require.ErrorIs(t, err, test.cause, "preserve all causes, not just one joined error") + } + }) + } + } + } +} + +func TestValidatorClaimWriteShutdownLogs(t *testing.T) { + dbErr := errors.New("database write failed") + for _, test := range []struct { + name string + cause error + wantLog bool + }{ + {"canceled write", fmt.Errorf("write: %w", context.Canceled), false}, + {"completed write", nil, false}, + {"database failure", dbErr, true}, + {"mixed failure", errors.Join(context.Canceled, dbErr), true}, + {"deadline", context.DeadlineExceeded, true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s, logs := validatorShutdownFixture(ctx, t, "claim write", test.cause, func(mock.Arguments) { cancel() }) + require.ErrorIs(t, s.Serve(ctx), context.Canceled) + if test.wantLog { + require.Contains(t, logs.String(), "level=ERROR msg=Tick") + if errors.Is(test.cause, dbErr) { + require.Contains(t, logs.String(), dbErr.Error()) + } else { + require.Contains(t, logs.String(), test.cause.Error()) + } + } else { + require.NotContains(t, logs.String(), "level=ERROR") + } + }) + } +} + +// Cancel from the failing operation, after Tick has started. No status-write +// expectation is installed: an I/O failure must not change application health. +func validatorShutdownFixture( + ctx context.Context, t *testing.T, operation string, cause error, hook func(mock.Arguments), +) (*Service, *bytes.Buffer) { + t.Helper() + repo := newMockrepo() + postContext := merkle.CreatePostContext() + s := &Service{repository: repo, pristinePostContext: postContext, pristineRootHash: postContext[merkle.TREE_DEPTH]} + logs := new(bytes.Buffer) + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: "validator", Logger: slog.New(slog.NewTextHandler(logs, nil))}, + }, s)) + app := &model.Application{ID: 1, Name: "validator-app", Status: model.ApplicationStatus_OK} + t.Cleanup(func() { + repo.AssertExpectations(t) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + }) + apps := repo.On("ListApplications", ctx, mock.Anything, mock.Anything, false).Once() + if operation == "applications" { + apps.Run(hook).Return([]*model.Application(nil), uint64(0), cause) + return s, logs + } + apps.Return([]*model.Application{app}, uint64(1), nil) + epochs := repo.On("ListEpochs", ctx, app.IApplicationAddress.Hex(), mock.Anything, mock.Anything, false).Once() + if operation == "epochs" { + epochs.Run(hook).Return([]*model.Epoch(nil), uint64(0), cause) + return s, logs + } + epoch := &model.Epoch{Status: model.EpochStatus_InputsProcessed, + MachineHash: new(common.HexToHash("0x123")), TxBufferDataBlock: &s.pristineRootHash} + epochs.Return([]*model.Epoch{epoch}, uint64(1), nil) + outputs := repo.On("ListOutputs", ctx, app.IApplicationAddress.Hex(), mock.Anything, mock.Anything, false).Once() + if operation == "outputs" { + outputs.Run(hook).Return([]*model.Output(nil), uint64(0), cause) + return s, logs + } + outputs.Return([]*model.Output(nil), uint64(0), nil) + repo.On("GetLastInput", ctx, app.IApplicationAddress.Hex(), epoch.Index). + Return(&model.Input{MachineHash: epoch.MachineHash, TxBufferDataBlock: epoch.TxBufferDataBlock}, nil).Once() + repo.On("StoreClaimAndProofs", ctx, epoch, mock.Anything).Run(hook).Return(cause).Once() + return s, logs +} + +func TestValidatorTickShutdownRetainsEarlierError(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(new(bytes.Buffer), nil)) + apps := []*model.Application{ + {IApplicationAddress: common.HexToAddress("0x1")}, + {IApplicationAddress: common.HexToAddress("0x2")}, + {IApplicationAddress: common.HexToAddress("0x3")}, + } + repo.On("ListApplications", ctx, mock.Anything, mock.Anything, false).Return(apps, uint64(len(apps)), nil).Once() + firstErr := errors.New("first application database read failed") + repo.On("ListEpochs", ctx, apps[0].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false). + Return([]*model.Epoch(nil), uint64(0), firstErr).Once() + repo.On("ListEpochs", ctx, apps[1].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false). + Run(func(mock.Arguments) { cancel() }).Return([]*model.Epoch(nil), uint64(0), context.Canceled).Once() + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + require.ErrorIs(t, err, firstErr) + repo.AssertNotCalled(t, "ListEpochs", ctx, apps[2].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false) + repo.AssertExpectations(t) +} + +func TestValidatorTickDoneContext(t *testing.T) { + for _, deadline := range []bool{false, true} { + t.Run(fmt.Sprintf("deadline=%t", deadline), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + if deadline { + cancel() + ctx, cancel = context.WithDeadline(t.Context(), time.Time{}) + } + defer cancel() + cancel() + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(new(bytes.Buffer), nil)) + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if deadline { + require.ErrorIs(t, err, context.DeadlineExceeded) + } else { + require.NoError(t, err) + } + require.Empty(t, repo.Calls, "a stopped tick must not query the database") + }) + } +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 753867ac2..296b29a9d 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -16,6 +16,7 @@ import ( "github.com/cartesi/rollups-node/internal/appstatus" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/merkle" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" @@ -74,30 +75,28 @@ func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, erro // Tick executes the Validator main logic of producing claims and/or proofs // for processed epochs of all running applications. -func (s *Service) Tick(ctx context.Context) (bool, error) { +func (s *Service) Tick(ctx context.Context) (reschedule bool, err error) { + defer func() { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "error", err) + err = nil + } + }() + if err := ctx.Err(); err != nil { + return false, err + } apps, _, err := getAllRunningApplications(ctx, s.repository) if err != nil { - // During shutdown the parent context is canceled and every in- - // flight DB query returns context.Canceled. Suppress only the - // graceful-shutdown case; deadline-exceeded (real failure) still - // propagates. Mirrors internal/prt/service.go's Tick pattern. - if ctx.Err() != nil && errors.Is(err, context.Canceled) { - s.Logger.Warn("Tick interrupted by shutdown", "error", err) - return false, nil - } return false, fmt.Errorf("failed to get running applications. %w", err) } // validate each application errs := []error{} for idx := range apps { + if err := ctx.Err(); err != nil { + return false, errors.Join(append(errs, err)...) + } if err := s.validateApplication(ctx, apps[idx]); err != nil { - // Same shutdown-cancellation suppression as above, per-app. - if ctx.Err() != nil && errors.Is(err, context.Canceled) { - s.Logger.Warn("Tick interrupted by shutdown", - "application", apps[idx].IApplicationAddress, "error", err) - continue - } errs = append(errs, err) } } @@ -105,15 +104,23 @@ func (s *Service) Tick(ctx context.Context) (bool, error) { } type ValidatorRepository interface { - ListApplications(ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool) ([]*Application, uint64, error) + ListApplications( + ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool, + ) ([]*Application, uint64, error) UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error - ListOutputs(ctx context.Context, nameOrAddress string, f repository.OutputFilter, p repository.Pagination, descending bool) ([]*Output, uint64, error) + ListOutputs( + ctx context.Context, nameOrAddress string, f repository.OutputFilter, p repository.Pagination, descending bool, + ) ([]*Output, uint64, error) GetOutput(ctx context.Context, nameOrAddress string, outputIndex uint64) (*Output, error) - ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool) ([]*Epoch, uint64, error) + ListEpochs( + ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool, + ) ([]*Epoch, uint64, error) GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) // FIXME migrate to list GetEpochByVirtualIndex(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) StoreClaimAndProofs(ctx context.Context, epoch *Epoch, outputs []*Output) error - ListStateHashes(ctx context.Context, nameOrAddress string, f repository.StateHashFilter, p repository.Pagination, descending bool) ([]*StateHash, uint64, error) + ListStateHashes( + ctx context.Context, nameOrAddress string, f repository.StateHashFilter, p repository.Pagination, descending bool, + ) ([]*StateHash, uint64, error) } func getAllRunningApplications(ctx context.Context, er ValidatorRepository) ([]*Application, uint64, error) { @@ -180,13 +187,8 @@ func (s *Service) validateApplication(ctx context.Context, app *Application) err merkleRoot, outputs, err := s.computeMerkleTreeAndProofs(ctx, app, epoch) if err != nil { - // Don't log shutdown-cancellation at ERR — every in-flight DB - // query returns context.Canceled and Tick's outer suppression - // requires the service context to be done as well. - // DeadlineExceeded is a real failure and must still be logged. - if ctx.Err() == nil || !errors.Is(err, context.Canceled) { - s.Logger.Error("failed to create claim and proofs.", "error", err) - } + // Tick owns operational error reporting. The returned error already + // identifies the failed operation, application, and epoch. return err } @@ -308,6 +310,11 @@ func (s *Service) validateApplication(ctx context.Context, app *Application) err // store the epoch and proofs in the database err = s.repository.StoreClaimAndProofs(ctx, epoch, outputs) if err != nil { + if errors.Is(err, repository.ErrEpochForeclosed) { + s.Logger.Info("Epoch was foreclosed before claim publication; discarding obsolete claim and proofs", + "application", appAddress, "epoch_index", epoch.Index) + continue + } return fmt.Errorf( "failed to store claim and proofs for epoch %v of application %v. %w", epoch.Index, appAddress, err, @@ -392,6 +399,18 @@ func (s *Service) buildCommitment(ctx context.Context, app *Application, epoch * return nil, nil, s.setApplicationCorrupted(ctx, app, "failed to build commitment for epoch %d of application %s with error: %v", epoch.Index, app.Name, err) } + return s.proveCommitment(ctx, app, epoch, epochCommitmentTree) +} + +// proveCommitment checks the completed tree and proves its final machine state. +// Invalid stored dimensions or hashes are corruption. A broken internal tree +// invariant is a computation failure, not evidence that persisted data is wrong. +func (s *Service) proveCommitment( + ctx context.Context, + app *Application, + epoch *Epoch, + epochCommitmentTree *merkle.Tree, +) (*common.Hash, *merkle.Proof, error) { // The commitment geometry is fixed: 2²⁴ inputs × 2²⁴ entries ⇒ height 48. const expectedHeight = Log2EpochComputationHashLeafCount if uint64(epochCommitmentTree.Height) != expectedHeight { @@ -403,8 +422,11 @@ func (s *Service) buildCommitment(ctx context.Context, app *Application, epoch * commitment := epochCommitmentTree.GetRootHash() proof, err := epochCommitmentTree.ProveLast() if err != nil { - return nil, nil, s.setApplicationCorrupted(ctx, app, - "failed to retrieve commitment proof for epoch %d of application %s with error: %v", epoch.Index, app.Name, err) + // ProveLast derives its own valid index. Any traversal failure in this + // newly built tree is an internal error, even an out-of-range child index. + failure := fmt.Errorf("failed to retrieve commitment proof for epoch %d of application %s: %w", epoch.Index, app.Name, err) + statusErr := appstatus.SetFailed(ctx, s.Logger, s.repository, app, failure.Error()) + return nil, nil, errors.Join(failure, statusErr) } // PRT reconstructs the root children from (epoch.MachineHash, proof). // The tree's last leaf must therefore be the epoch's final machine hash. @@ -418,7 +440,6 @@ func (s *Service) buildCommitment(ctx context.Context, app *Application, epoch * "epoch", epoch.Index, "commitment", commitment.String()) return &commitment, proof, nil - } // computeMerkleTreeAndProofs calculates the claim and proofs for an epoch. It returns diff --git a/pkg/contracts/dataavailability/dataavailability.go b/pkg/contracts/dataavailability/dataavailability.go deleted file mode 100644 index 14d684b26..000000000 --- a/pkg/contracts/dataavailability/dataavailability.go +++ /dev/null @@ -1,223 +0,0 @@ -// Code generated - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package dataavailability - -import ( - "errors" - "math/big" - "strings" - - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = errors.New - _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind - _ = common.Big1 - _ = types.BloomLookup - _ = event.NewSubscription - _ = abi.ConvertType -) - -// DataAvailabilityMetaData contains all meta data concerning the DataAvailability contract. -var DataAvailabilityMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"InputBox\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"InputBoxAndEspresso\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"fromBlock\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"namespaceId\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"}]", -} - -// DataAvailabilityABI is the input ABI used to generate the binding from. -// Deprecated: Use DataAvailabilityMetaData.ABI instead. -var DataAvailabilityABI = DataAvailabilityMetaData.ABI - -// DataAvailability is an auto generated Go binding around an Ethereum contract. -type DataAvailability struct { - DataAvailabilityCaller // Read-only binding to the contract - DataAvailabilityTransactor // Write-only binding to the contract - DataAvailabilityFilterer // Log filterer for contract events -} - -// DataAvailabilityCaller is an auto generated read-only Go binding around an Ethereum contract. -type DataAvailabilityCaller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// DataAvailabilityTransactor is an auto generated write-only Go binding around an Ethereum contract. -type DataAvailabilityTransactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// DataAvailabilityFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type DataAvailabilityFilterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// DataAvailabilitySession is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type DataAvailabilitySession struct { - Contract *DataAvailability // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// DataAvailabilityCallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type DataAvailabilityCallerSession struct { - Contract *DataAvailabilityCaller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// DataAvailabilityTransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type DataAvailabilityTransactorSession struct { - Contract *DataAvailabilityTransactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// DataAvailabilityRaw is an auto generated low-level Go binding around an Ethereum contract. -type DataAvailabilityRaw struct { - Contract *DataAvailability // Generic contract binding to access the raw methods on -} - -// DataAvailabilityCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type DataAvailabilityCallerRaw struct { - Contract *DataAvailabilityCaller // Generic read-only contract binding to access the raw methods on -} - -// DataAvailabilityTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type DataAvailabilityTransactorRaw struct { - Contract *DataAvailabilityTransactor // Generic write-only contract binding to access the raw methods on -} - -// NewDataAvailability creates a new instance of DataAvailability, bound to a specific deployed contract. -func NewDataAvailability(address common.Address, backend bind.ContractBackend) (*DataAvailability, error) { - contract, err := bindDataAvailability(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &DataAvailability{DataAvailabilityCaller: DataAvailabilityCaller{contract: contract}, DataAvailabilityTransactor: DataAvailabilityTransactor{contract: contract}, DataAvailabilityFilterer: DataAvailabilityFilterer{contract: contract}}, nil -} - -// NewDataAvailabilityCaller creates a new read-only instance of DataAvailability, bound to a specific deployed contract. -func NewDataAvailabilityCaller(address common.Address, caller bind.ContractCaller) (*DataAvailabilityCaller, error) { - contract, err := bindDataAvailability(address, caller, nil, nil) - if err != nil { - return nil, err - } - return &DataAvailabilityCaller{contract: contract}, nil -} - -// NewDataAvailabilityTransactor creates a new write-only instance of DataAvailability, bound to a specific deployed contract. -func NewDataAvailabilityTransactor(address common.Address, transactor bind.ContractTransactor) (*DataAvailabilityTransactor, error) { - contract, err := bindDataAvailability(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &DataAvailabilityTransactor{contract: contract}, nil -} - -// NewDataAvailabilityFilterer creates a new log filterer instance of DataAvailability, bound to a specific deployed contract. -func NewDataAvailabilityFilterer(address common.Address, filterer bind.ContractFilterer) (*DataAvailabilityFilterer, error) { - contract, err := bindDataAvailability(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &DataAvailabilityFilterer{contract: contract}, nil -} - -// bindDataAvailability binds a generic wrapper to an already deployed contract. -func bindDataAvailability(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := DataAvailabilityMetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_DataAvailability *DataAvailabilityRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _DataAvailability.Contract.DataAvailabilityCaller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_DataAvailability *DataAvailabilityRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _DataAvailability.Contract.DataAvailabilityTransactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_DataAvailability *DataAvailabilityRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _DataAvailability.Contract.DataAvailabilityTransactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_DataAvailability *DataAvailabilityCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _DataAvailability.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_DataAvailability *DataAvailabilityTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _DataAvailability.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_DataAvailability *DataAvailabilityTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _DataAvailability.Contract.contract.Transact(opts, method, params...) -} - -// InputBox is a paid mutator transaction binding the contract method 0xb12c9ede. -// -// Solidity: function InputBox(address inputBox) returns() -func (_DataAvailability *DataAvailabilityTransactor) InputBox(opts *bind.TransactOpts, inputBox common.Address) (*types.Transaction, error) { - return _DataAvailability.contract.Transact(opts, "InputBox", inputBox) -} - -// InputBox is a paid mutator transaction binding the contract method 0xb12c9ede. -// -// Solidity: function InputBox(address inputBox) returns() -func (_DataAvailability *DataAvailabilitySession) InputBox(inputBox common.Address) (*types.Transaction, error) { - return _DataAvailability.Contract.InputBox(&_DataAvailability.TransactOpts, inputBox) -} - -// InputBox is a paid mutator transaction binding the contract method 0xb12c9ede. -// -// Solidity: function InputBox(address inputBox) returns() -func (_DataAvailability *DataAvailabilityTransactorSession) InputBox(inputBox common.Address) (*types.Transaction, error) { - return _DataAvailability.Contract.InputBox(&_DataAvailability.TransactOpts, inputBox) -} - -// InputBoxAndEspresso is a paid mutator transaction binding the contract method 0x8579fd0c. -// -// Solidity: function InputBoxAndEspresso(address inputBox, uint256 fromBlock, uint32 namespaceId) returns() -func (_DataAvailability *DataAvailabilityTransactor) InputBoxAndEspresso(opts *bind.TransactOpts, inputBox common.Address, fromBlock *big.Int, namespaceId uint32) (*types.Transaction, error) { - return _DataAvailability.contract.Transact(opts, "InputBoxAndEspresso", inputBox, fromBlock, namespaceId) -} - -// InputBoxAndEspresso is a paid mutator transaction binding the contract method 0x8579fd0c. -// -// Solidity: function InputBoxAndEspresso(address inputBox, uint256 fromBlock, uint32 namespaceId) returns() -func (_DataAvailability *DataAvailabilitySession) InputBoxAndEspresso(inputBox common.Address, fromBlock *big.Int, namespaceId uint32) (*types.Transaction, error) { - return _DataAvailability.Contract.InputBoxAndEspresso(&_DataAvailability.TransactOpts, inputBox, fromBlock, namespaceId) -} - -// InputBoxAndEspresso is a paid mutator transaction binding the contract method 0x8579fd0c. -// -// Solidity: function InputBoxAndEspresso(address inputBox, uint256 fromBlock, uint32 namespaceId) returns() -func (_DataAvailability *DataAvailabilityTransactorSession) InputBoxAndEspresso(inputBox common.Address, fromBlock *big.Int, namespaceId uint32) (*types.Transaction, error) { - return _DataAvailability.Contract.InputBoxAndEspresso(&_DataAvailability.TransactOpts, inputBox, fromBlock, namespaceId) -} diff --git a/pkg/contracts/generate/main.go b/pkg/contracts/generate/main.go index a7ae882d7..17e0e48e4 100644 --- a/pkg/contracts/generate/main.go +++ b/pkg/contracts/generate/main.go @@ -3,9 +3,9 @@ // This binary generates the Go bindings for the Cartesi Rollups contracts. // This binary should be called with `go generate` in the parent dir. -// First, it downloads the Cartesi Rollups npm package containing the contracts. -// Then, it generates the bindings using abi-gen. -// Finally, it stores the bindings in the current directory. +// The Makefile extracts the release artifacts before this generator runs. +// It reads their ABIs and generates the Go bindings with bind.Bind. +// It stores the bindings in the current directory. package main import ( @@ -73,10 +73,6 @@ var bindings = []contractBinding{ jsonPath: rollupsContractsPath + "Outputs.sol/Outputs.json", typeName: "Outputs", }, - { - jsonPath: rollupsContractsPath + "DataAvailability.sol/DataAvailability.json", - typeName: "DataAvailability", - }, { jsonPath: rollupsContractsPath + "IUsdWithdrawalOutputBuilder.sol/IUsdWithdrawalOutputBuilder.json", typeName: "IUsdWithdrawalOutputBuilder", @@ -86,8 +82,8 @@ var bindings = []contractBinding{ typeName: "IERC20Metadata", }, { - jsonPath: rollupsContractsPath + "IERC20Portal.sol/IERC20Portal.json", - typeName: "IERC20Portal", + jsonPath: rollupsContractsPath + "IErc20Portal.sol/IErc20Portal.json", + typeName: "IErc20Portal", }, { jsonPath: rollupsContractsPath + "draft-IERC6093.sol/IERC20Errors.json", @@ -97,6 +93,10 @@ var bindings = []contractBinding{ jsonPath: rollupsPrtContractsPath + "prt/contracts/out/ITournament.sol/ITournament.json", typeName: "ITournament", }, + { + jsonPath: rollupsPrtContractsPath + "prt/contracts/out/IMultiLevelTournamentFactory.sol/IMultiLevelTournamentFactory.json", + typeName: "IMultiLevelTournamentFactory", + }, { jsonPath: rollupsPrtContractsPath + "cartesi-rollups/contracts/out/IDaveConsensus.sol/IDaveConsensus.json", typeName: "IDaveConsensus", @@ -181,14 +181,20 @@ func generateBinding(b contractBinding, content []byte) { checkErr("removing dir", err) } - const dirMode = 0700 + // Generated source is shared by developers in the same group. Set the + // final modes explicitly so the caller's umask does not remove group write. + const dirMode = 0775 err = os.Mkdir(pkg, dirMode) checkErr("creating dir", err) + err = os.Chmod(pkg, dirMode) + checkErr("setting dir permissions", err) - const fileMode = 0600 + const fileMode = 0664 filePath := pkg + "/" + pkg + ".go" err = os.WriteFile(filePath, []byte(code), fileMode) checkErr("write binding file", err) + err = os.Chmod(filePath, fileMode) + checkErr("setting binding permissions", err) log.Print("generated binding for ", filePath) } diff --git a/pkg/contracts/generate/main_test.go b/pkg/contracts/generate/main_test.go new file mode 100644 index 000000000..2cb9c7a27 --- /dev/null +++ b/pkg/contracts/generate/main_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build unix + +package main + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateBinding_SharedWorktreePermissions(t *testing.T) { + t.Chdir(t.TempDir()) + // Keep this test serial: both Chdir and Umask change process-wide state. + // The usual umask removes group write during creation. Generation must + // restore it so another user in the same group can regenerate the package. + const sourceCreationMask = 0022 + previousMask := syscall.Umask(sourceCreationMask) + t.Cleanup(func() { syscall.Umask(previousMask) }) + const artifact = `{ + "abi": [{ + "type": "function", + "name": "value", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view" + }] + }` + + for _, name := range []string{"first generation", "regeneration"} { + t.Run(name, func(t *testing.T) { + generateBinding(contractBinding{typeName: "TestContract"}, []byte(artifact)) + + directory, err := os.Stat("testcontract") + require.NoError(t, err) + require.True(t, directory.IsDir()) + const sharedDirectoryBits os.FileMode = 0775 + require.Equal(t, sharedDirectoryBits, directory.Mode().Perm()&sharedDirectoryBits, + "group members must be able to regenerate the publicly readable package") + + binding, err := os.Stat(filepath.Join("testcontract", "testcontract.go")) + require.NoError(t, err) + require.True(t, binding.Mode().IsRegular()) + const sharedSourceBits os.FileMode = 0664 + require.Equal(t, sharedSourceBits, binding.Mode().Perm()&sharedSourceBits, + "group members must be able to edit the publicly readable Go source") + const executeBits os.FileMode = 0111 + require.Zero(t, binding.Mode().Perm()&executeBits, "generated Go source must not be executable") + }) + } +} diff --git a/pkg/contracts/iapplication/iapplication.go b/pkg/contracts/iapplication/iapplication.go index dab53f9a5..66a00a8fe 100644 --- a/pkg/contracts/iapplication/iapplication.go +++ b/pkg/contracts/iapplication/iapplication.go @@ -52,7 +52,7 @@ type WithdrawalConfig struct { // IApplicationMetaData contains all meta data concerning the IApplication contract. var IApplicationMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"executeOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"foreclose\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getAccountsDriveMerkleRoot\",\"inputs\":[],\"outputs\":[{\"name\":\"wasAccountsDriveMerkleRootProved\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAccountsDriveStartIndex\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDataAvailability\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getGuardian\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2LeavesPerAccount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2MaxNumOfAccounts\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfExecutedOutputs\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfWithdrawals\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getOutputsMerkleRootValidator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTemplateHash\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalConfig\",\"inputs\":[],\"outputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalOutputBuilder\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isForeclosed\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"migrateToOutputsMerkleRootValidator\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"proveAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"validateAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateAccountMerkleRoot\",\"inputs\":[{\"name\":\"accountMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutputHash\",\"inputs\":[{\"name\":\"outputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasOutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wereAccountFundsWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdraw\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AccountsDriveMerkleRootProved\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Foreclosure\",\"inputs\":[],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputsMerkleRootValidatorChanged\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Withdrawal\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountFundsAlreadyWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccountTooShort\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minAccountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootAlreadyProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootNotProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"Foreclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientFunds\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountRootSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRootProofSize\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleRoot\",\"inputs\":[{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputHashesSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRoot\",\"inputs\":[{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"NotForeclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotGuardian\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"OutputNotExecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"OutputNotReexecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"executeOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"foreclose\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getAccountsDriveMerkleRoot\",\"inputs\":[],\"outputs\":[{\"name\":\"wasAccountsDriveMerkleRootProved\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAccountsDriveStartIndex\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getGuardian\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2LeavesPerAccount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2MaxNumOfAccounts\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfExecutedOutputs\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfIssuedRefunds\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfWithdrawals\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getOutputsMerkleRootValidator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getRefundOutputBuilder\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIRefundOutputBuilder\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTemplateHash\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalConfig\",\"inputs\":[],\"outputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalOutputBuilder\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isForeclosed\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"issueRefund\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"migrateToOutputsMerkleRootValidator\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"proveAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"validateAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateAccountMerkleRoot\",\"inputs\":[{\"name\":\"accountMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateInput\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputSender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputPayload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateInputHash\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutputHash\",\"inputs\":[{\"name\":\"outputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasOutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasRefundForInputIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wereAccountFundsWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdraw\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AccountsDriveMerkleRootProved\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Foreclosure\",\"inputs\":[],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputsMerkleRootValidatorChanged\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RefundIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Withdrawal\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountFundsAlreadyWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootAlreadyProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootNotProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CannotRefundFinalizedInput\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"Foreclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IllFormedInput\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientFunds\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountRootSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAccountSize\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"accountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRootProofSize\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidInputHash\",\"inputs\":[{\"name\":\"storedInputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"invalidInputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidInputIndex\",\"inputs\":[{\"name\":\"invalidInputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"numOfInputs\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleRoot\",\"inputs\":[{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputHashesSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRoot\",\"inputs\":[{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"NotDeploymentBlock\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotForeclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotGuardian\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"OutputNotExecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"OutputNotReexecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"RefundAlreadyIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"TargetHasNoCode\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"UnknownInputSender\",\"inputs\":[{\"name\":\"inputSender\",\"type\":\"address\",\"internalType\":\"address\"}]}]", } // IApplicationABI is the input ABI used to generate the binding from. @@ -277,37 +277,6 @@ func (_IApplication *IApplicationCallerSession) GetAccountsDriveStartIndex() (ui return _IApplication.Contract.GetAccountsDriveStartIndex(&_IApplication.CallOpts) } -// GetDataAvailability is a free data retrieval call binding the contract method 0xf02478de. -// -// Solidity: function getDataAvailability() view returns(bytes) -func (_IApplication *IApplicationCaller) GetDataAvailability(opts *bind.CallOpts) ([]byte, error) { - var out []interface{} - err := _IApplication.contract.Call(opts, &out, "getDataAvailability") - - if err != nil { - return *new([]byte), err - } - - out0 := *abi.ConvertType(out[0], new([]byte)).(*[]byte) - - return out0, err - -} - -// GetDataAvailability is a free data retrieval call binding the contract method 0xf02478de. -// -// Solidity: function getDataAvailability() view returns(bytes) -func (_IApplication *IApplicationSession) GetDataAvailability() ([]byte, error) { - return _IApplication.Contract.GetDataAvailability(&_IApplication.CallOpts) -} - -// GetDataAvailability is a free data retrieval call binding the contract method 0xf02478de. -// -// Solidity: function getDataAvailability() view returns(bytes) -func (_IApplication *IApplicationCallerSession) GetDataAvailability() ([]byte, error) { - return _IApplication.Contract.GetDataAvailability(&_IApplication.CallOpts) -} - // GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. // // Solidity: function getDeploymentBlockNumber() view returns(uint256) @@ -370,6 +339,37 @@ func (_IApplication *IApplicationCallerSession) GetGuardian() (common.Address, e return _IApplication.Contract.GetGuardian(&_IApplication.CallOpts) } +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationCaller) GetInputBox(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getInputBox") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationSession) GetInputBox() (common.Address, error) { + return _IApplication.Contract.GetInputBox(&_IApplication.CallOpts) +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationCallerSession) GetInputBox() (common.Address, error) { + return _IApplication.Contract.GetInputBox(&_IApplication.CallOpts) +} + // GetLog2LeavesPerAccount is a free data retrieval call binding the contract method 0x28a0e3c5. // // Solidity: function getLog2LeavesPerAccount() view returns(uint8) @@ -463,6 +463,37 @@ func (_IApplication *IApplicationCallerSession) GetNumberOfExecutedOutputs() (*b return _IApplication.Contract.GetNumberOfExecutedOutputs(&_IApplication.CallOpts) } +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationCaller) GetNumberOfIssuedRefunds(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getNumberOfIssuedRefunds") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationSession) GetNumberOfIssuedRefunds() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfIssuedRefunds(&_IApplication.CallOpts) +} + +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationCallerSession) GetNumberOfIssuedRefunds() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfIssuedRefunds(&_IApplication.CallOpts) +} + // GetNumberOfWithdrawals is a free data retrieval call binding the contract method 0x0e70381b. // // Solidity: function getNumberOfWithdrawals() view returns(uint256) @@ -525,6 +556,37 @@ func (_IApplication *IApplicationCallerSession) GetOutputsMerkleRootValidator() return _IApplication.Contract.GetOutputsMerkleRootValidator(&_IApplication.CallOpts) } +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationCaller) GetRefundOutputBuilder(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getRefundOutputBuilder") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationSession) GetRefundOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetRefundOutputBuilder(&_IApplication.CallOpts) +} + +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationCallerSession) GetRefundOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetRefundOutputBuilder(&_IApplication.CallOpts) +} + // GetTemplateHash is a free data retrieval call binding the contract method 0x61b12c66. // // Solidity: function getTemplateHash() view returns(bytes32) @@ -738,6 +800,85 @@ func (_IApplication *IApplicationCallerSession) ValidateAccountMerkleRoot(accoun return _IApplication.Contract.ValidateAccountMerkleRoot(&_IApplication.CallOpts, accountMerkleRoot, proof) } +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationCaller) ValidateInput(opts *bind.CallOpts, inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateInput", inputIndex, input) + + outstruct := new(struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.BlockNumber = *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.InputSender = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + outstruct.InputPayload = *abi.ConvertType(out[2], new([]byte)).(*[]byte) + + return *outstruct, err + +} + +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationSession) ValidateInput(inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + return _IApplication.Contract.ValidateInput(&_IApplication.CallOpts, inputIndex, input) +} + +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationCallerSession) ValidateInput(inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + return _IApplication.Contract.ValidateInput(&_IApplication.CallOpts, inputIndex, input) +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationCaller) ValidateInputHash(opts *bind.CallOpts, inputIndex *big.Int, inputHash [32]byte) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateInputHash", inputIndex, inputHash) + + if err != nil { + return err + } + + return err + +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationSession) ValidateInputHash(inputIndex *big.Int, inputHash [32]byte) error { + return _IApplication.Contract.ValidateInputHash(&_IApplication.CallOpts, inputIndex, inputHash) +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationCallerSession) ValidateInputHash(inputIndex *big.Int, inputHash [32]byte) error { + return _IApplication.Contract.ValidateInputHash(&_IApplication.CallOpts, inputIndex, inputHash) +} + // ValidateOutput is a free data retrieval call binding the contract method 0xe88d39c0. // // Solidity: function validateOutput(bytes output, (uint64,bytes32[]) proof) view returns() @@ -887,6 +1028,37 @@ func (_IApplication *IApplicationCallerSession) WasOutputExecuted(outputIndex *b return _IApplication.Contract.WasOutputExecuted(&_IApplication.CallOpts, outputIndex) } +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationCaller) WasRefundForInputIssued(opts *bind.CallOpts, inputIndex *big.Int) (bool, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "wasRefundForInputIssued", inputIndex) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationSession) WasRefundForInputIssued(inputIndex *big.Int) (bool, error) { + return _IApplication.Contract.WasRefundForInputIssued(&_IApplication.CallOpts, inputIndex) +} + +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationCallerSession) WasRefundForInputIssued(inputIndex *big.Int) (bool, error) { + return _IApplication.Contract.WasRefundForInputIssued(&_IApplication.CallOpts, inputIndex) +} + // WereAccountFundsWithdrawn is a free data retrieval call binding the contract method 0x8272a6aa. // // Solidity: function wereAccountFundsWithdrawn(uint256 accountIndex) view returns(bool) @@ -960,6 +1132,27 @@ func (_IApplication *IApplicationTransactorSession) Foreclose() (*types.Transact return _IApplication.Contract.Foreclose(&_IApplication.TransactOpts) } +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationTransactor) IssueRefund(opts *bind.TransactOpts, inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "issueRefund", inputIndex, input) +} + +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationSession) IssueRefund(inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.Contract.IssueRefund(&_IApplication.TransactOpts, inputIndex, input) +} + +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationTransactorSession) IssueRefund(inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.Contract.IssueRefund(&_IApplication.TransactOpts, inputIndex, input) +} + // MigrateToOutputsMerkleRootValidator is a paid mutator transaction binding the contract method 0xbf8abff8. // // Solidity: function migrateToOutputsMerkleRootValidator(address newOutputsMerkleRootValidator) returns() @@ -1408,10 +1601,15 @@ type IApplicationOutputExecuted struct { // FilterOutputExecuted is a free log retrieval operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. // -// Solidity: event OutputExecuted(uint64 outputIndex, bytes output) -func (_IApplication *IApplicationFilterer) FilterOutputExecuted(opts *bind.FilterOpts) (*IApplicationOutputExecutedIterator, error) { +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) +func (_IApplication *IApplicationFilterer) FilterOutputExecuted(opts *bind.FilterOpts, outputIndex []uint64) (*IApplicationOutputExecutedIterator, error) { + + var outputIndexRule []interface{} + for _, outputIndexItem := range outputIndex { + outputIndexRule = append(outputIndexRule, outputIndexItem) + } - logs, sub, err := _IApplication.contract.FilterLogs(opts, "OutputExecuted") + logs, sub, err := _IApplication.contract.FilterLogs(opts, "OutputExecuted", outputIndexRule) if err != nil { return nil, err } @@ -1420,10 +1618,15 @@ func (_IApplication *IApplicationFilterer) FilterOutputExecuted(opts *bind.Filte // WatchOutputExecuted is a free log subscription operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. // -// Solidity: event OutputExecuted(uint64 outputIndex, bytes output) -func (_IApplication *IApplicationFilterer) WatchOutputExecuted(opts *bind.WatchOpts, sink chan<- *IApplicationOutputExecuted) (event.Subscription, error) { +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) +func (_IApplication *IApplicationFilterer) WatchOutputExecuted(opts *bind.WatchOpts, sink chan<- *IApplicationOutputExecuted, outputIndex []uint64) (event.Subscription, error) { + + var outputIndexRule []interface{} + for _, outputIndexItem := range outputIndex { + outputIndexRule = append(outputIndexRule, outputIndexItem) + } - logs, sub, err := _IApplication.contract.WatchLogs(opts, "OutputExecuted") + logs, sub, err := _IApplication.contract.WatchLogs(opts, "OutputExecuted", outputIndexRule) if err != nil { return nil, err } @@ -1457,7 +1660,7 @@ func (_IApplication *IApplicationFilterer) WatchOutputExecuted(opts *bind.WatchO // ParseOutputExecuted is a log parse operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. // -// Solidity: event OutputExecuted(uint64 outputIndex, bytes output) +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) func (_IApplication *IApplicationFilterer) ParseOutputExecuted(log types.Log) (*IApplicationOutputExecuted, error) { event := new(IApplicationOutputExecuted) if err := _IApplication.contract.UnpackLog(event, "OutputExecuted", log); err != nil { @@ -1601,6 +1804,152 @@ func (_IApplication *IApplicationFilterer) ParseOutputsMerkleRootValidatorChange return event, nil } +// IApplicationRefundIssuedIterator is returned from FilterRefundIssued and is used to iterate over the raw logs and unpacked data for RefundIssued events raised by the IApplication contract. +type IApplicationRefundIssuedIterator struct { + Event *IApplicationRefundIssued // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationRefundIssuedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationRefundIssued) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationRefundIssued) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationRefundIssuedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationRefundIssuedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationRefundIssued represents a RefundIssued event raised by the IApplication contract. +type IApplicationRefundIssued struct { + InputIndex *big.Int + Input []byte + Output []byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRefundIssued is a free log retrieval operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) FilterRefundIssued(opts *bind.FilterOpts, inputIndex []*big.Int) (*IApplicationRefundIssuedIterator, error) { + + var inputIndexRule []interface{} + for _, inputIndexItem := range inputIndex { + inputIndexRule = append(inputIndexRule, inputIndexItem) + } + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "RefundIssued", inputIndexRule) + if err != nil { + return nil, err + } + return &IApplicationRefundIssuedIterator{contract: _IApplication.contract, event: "RefundIssued", logs: logs, sub: sub}, nil +} + +// WatchRefundIssued is a free log subscription operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) WatchRefundIssued(opts *bind.WatchOpts, sink chan<- *IApplicationRefundIssued, inputIndex []*big.Int) (event.Subscription, error) { + + var inputIndexRule []interface{} + for _, inputIndexItem := range inputIndex { + inputIndexRule = append(inputIndexRule, inputIndexItem) + } + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "RefundIssued", inputIndexRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationRefundIssued) + if err := _IApplication.contract.UnpackLog(event, "RefundIssued", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRefundIssued is a log parse operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) ParseRefundIssued(log types.Log) (*IApplicationRefundIssued, error) { + event := new(IApplicationRefundIssued) + if err := _IApplication.contract.UnpackLog(event, "RefundIssued", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // IApplicationWithdrawalIterator is returned from FilterWithdrawal and is used to iterate over the raw logs and unpacked data for Withdrawal events raised by the IApplication contract. type IApplicationWithdrawalIterator struct { Event *IApplicationWithdrawal // Event containing the contract specifics and raw log @@ -1678,10 +2027,15 @@ type IApplicationWithdrawal struct { // FilterWithdrawal is a free log retrieval operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. // -// Solidity: event Withdrawal(uint64 accountIndex, bytes account, bytes output) -func (_IApplication *IApplicationFilterer) FilterWithdrawal(opts *bind.FilterOpts) (*IApplicationWithdrawalIterator, error) { +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) +func (_IApplication *IApplicationFilterer) FilterWithdrawal(opts *bind.FilterOpts, accountIndex []uint64) (*IApplicationWithdrawalIterator, error) { - logs, sub, err := _IApplication.contract.FilterLogs(opts, "Withdrawal") + var accountIndexRule []interface{} + for _, accountIndexItem := range accountIndex { + accountIndexRule = append(accountIndexRule, accountIndexItem) + } + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "Withdrawal", accountIndexRule) if err != nil { return nil, err } @@ -1690,10 +2044,15 @@ func (_IApplication *IApplicationFilterer) FilterWithdrawal(opts *bind.FilterOpt // WatchWithdrawal is a free log subscription operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. // -// Solidity: event Withdrawal(uint64 accountIndex, bytes account, bytes output) -func (_IApplication *IApplicationFilterer) WatchWithdrawal(opts *bind.WatchOpts, sink chan<- *IApplicationWithdrawal) (event.Subscription, error) { +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) +func (_IApplication *IApplicationFilterer) WatchWithdrawal(opts *bind.WatchOpts, sink chan<- *IApplicationWithdrawal, accountIndex []uint64) (event.Subscription, error) { + + var accountIndexRule []interface{} + for _, accountIndexItem := range accountIndex { + accountIndexRule = append(accountIndexRule, accountIndexItem) + } - logs, sub, err := _IApplication.contract.WatchLogs(opts, "Withdrawal") + logs, sub, err := _IApplication.contract.WatchLogs(opts, "Withdrawal", accountIndexRule) if err != nil { return nil, err } @@ -1727,7 +2086,7 @@ func (_IApplication *IApplicationFilterer) WatchWithdrawal(opts *bind.WatchOpts, // ParseWithdrawal is a log parse operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. // -// Solidity: event Withdrawal(uint64 accountIndex, bytes account, bytes output) +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) func (_IApplication *IApplicationFilterer) ParseWithdrawal(log types.Log) (*IApplicationWithdrawal, error) { event := new(IApplicationWithdrawal) if err := _IApplication.contract.UnpackLog(event, "Withdrawal", log); err != nil { diff --git a/pkg/contracts/iapplicationfactory/iapplicationfactory.go b/pkg/contracts/iapplicationfactory/iapplicationfactory.go index dc871c872..1b7899d7f 100644 --- a/pkg/contracts/iapplicationfactory/iapplicationfactory.go +++ b/pkg/contracts/iapplicationfactory/iapplicationfactory.go @@ -40,7 +40,7 @@ type WithdrawalConfig struct { // IApplicationFactoryMetaData contains all meta data concerning the IApplicationFactory contract. var IApplicationFactoryMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"calculateApplicationAddress\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ApplicationCreated\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"calculateApplicationAddress\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ApplicationCreated\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]}]", } // IApplicationFactoryABI is the input ABI used to generate the binding from. @@ -189,12 +189,12 @@ func (_IApplicationFactory *IApplicationFactoryTransactorRaw) Transact(opts *bin return _IApplicationFactory.Contract.contract.Transact(opts, method, params...) } -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddress(opts *bind.CallOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddress(opts *bind.CallOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { var out []interface{} - err := _IApplicationFactory.contract.Call(opts, &out, "calculateApplicationAddress", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) + err := _IApplicationFactory.contract.Call(opts, &out, "calculateApplicationAddress", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) if err != nil { return *new(common.Address), err @@ -206,18 +206,18 @@ func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddre } -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactorySession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { - return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactorySession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { + return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xcdfe5fec. +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. // -// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) -func (_IApplicationFactory *IApplicationFactoryCallerSession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { - return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactoryCallerSession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { + return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } // Version is a free data retrieval call binding the contract method 0x54fd4d50. @@ -280,46 +280,46 @@ func (_IApplicationFactory *IApplicationFactoryCallerSession) Version() (struct return _IApplicationFactory.Contract.Version(&_IApplicationFactory.CallOpts) } -// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { - return _IApplicationFactory.contract.Transact(opts, "newApplication", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.contract.Transact(opts, "newApplication", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) } -// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) -func (_IApplicationFactory *IApplicationFactorySession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactorySession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) } -// NewApplication is a paid mutator transaction binding the contract method 0x23798a9c. +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication0(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.contract.Transact(opts, "newApplication0", outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication0(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.contract.Transact(opts, "newApplication0", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactorySession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactorySession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } -// NewApplication0 is a paid mutator transaction binding the contract method 0x4ba6bf41. +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. // -// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) -func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) } // IApplicationFactoryApplicationCreatedIterator is returned from FilterApplicationCreated and is used to iterate over the raw logs and unpacked data for ApplicationCreated events raised by the IApplicationFactory contract. @@ -394,15 +394,15 @@ type IApplicationFactoryApplicationCreated struct { OutputsMerkleRootValidator common.Address AppOwner common.Address TemplateHash [32]byte - DataAvailability []byte + InputBox common.Address WithdrawalConfig WithdrawalConfig AppContract common.Address Raw types.Log // Blockchain specific contextual infos } -// FilterApplicationCreated is a free log retrieval operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. +// FilterApplicationCreated is a free log retrieval operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) func (_IApplicationFactory *IApplicationFactoryFilterer) FilterApplicationCreated(opts *bind.FilterOpts, outputsMerkleRootValidator []common.Address) (*IApplicationFactoryApplicationCreatedIterator, error) { var outputsMerkleRootValidatorRule []interface{} @@ -417,9 +417,9 @@ func (_IApplicationFactory *IApplicationFactoryFilterer) FilterApplicationCreate return &IApplicationFactoryApplicationCreatedIterator{contract: _IApplicationFactory.contract, event: "ApplicationCreated", logs: logs, sub: sub}, nil } -// WatchApplicationCreated is a free log subscription operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. +// WatchApplicationCreated is a free log subscription operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) func (_IApplicationFactory *IApplicationFactoryFilterer) WatchApplicationCreated(opts *bind.WatchOpts, sink chan<- *IApplicationFactoryApplicationCreated, outputsMerkleRootValidator []common.Address) (event.Subscription, error) { var outputsMerkleRootValidatorRule []interface{} @@ -459,9 +459,9 @@ func (_IApplicationFactory *IApplicationFactoryFilterer) WatchApplicationCreated }), nil } -// ParseApplicationCreated is a log parse operation binding the contract event 0xf57fedb261f4593784de9abb6653acfbaf45e74182818717c6e9b39c344a2a78. +// ParseApplicationCreated is a log parse operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. // -// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) func (_IApplicationFactory *IApplicationFactoryFilterer) ParseApplicationCreated(log types.Log) (*IApplicationFactoryApplicationCreated, error) { event := new(IApplicationFactoryApplicationCreated) if err := _IApplicationFactory.contract.UnpackLog(event, "ApplicationCreated", log); err != nil { diff --git a/pkg/contracts/iauthority/iauthority.go b/pkg/contracts/iauthority/iauthority.go index 3e3388c52..dd4526b14 100644 --- a/pkg/contracts/iauthority/iauthority.go +++ b/pkg/contracts/iauthority/iauthority.go @@ -36,9 +36,22 @@ type IConsensusClaim struct { StagedOutputsMerkleRoot [32]byte } +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + // IAuthorityMetaData contains all meta data concerning the IAuthority contract. var IAuthorityMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProofSize\",\"inputs\":[{\"name\":\"suppliedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", } // IAuthorityABI is the input ABI used to generate the binding from. @@ -557,6 +570,37 @@ func (_IAuthority *IAuthorityCallerSession) Version() (struct { return _IAuthority.Contract.Version(&_IAuthority.CallOpts) } +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthorityCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthoritySession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IAuthority.Contract.WasInputFinalized(&_IAuthority.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthorityCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IAuthority.Contract.WasInputFinalized(&_IAuthority.CallOpts, appContract, inputIndex, blockNumber) +} + // AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. // // Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() @@ -599,25 +643,25 @@ func (_IAuthority *IAuthorityTransactorSession) RenounceOwnership() (*types.Tran return _IAuthority.Contract.RenounceOwnership(&_IAuthority.TransactOpts) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IAuthority *IAuthorityTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IAuthority.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthorityTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IAuthority *IAuthoritySession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthoritySession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IAuthority *IAuthorityTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthorityTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } // TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. diff --git a/pkg/contracts/iconsensus/iconsensus.go b/pkg/contracts/iconsensus/iconsensus.go index 981a7a55e..65686854d 100644 --- a/pkg/contracts/iconsensus/iconsensus.go +++ b/pkg/contracts/iconsensus/iconsensus.go @@ -36,9 +36,22 @@ type IConsensusClaim struct { StagedOutputsMerkleRoot [32]byte } +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + // IConsensusMetaData contains all meta data concerning the IConsensus contract. var IConsensusMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProofSize\",\"inputs\":[{\"name\":\"suppliedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", } // IConsensusABI is the input ABI used to generate the binding from. @@ -526,6 +539,37 @@ func (_IConsensus *IConsensusCallerSession) Version() (struct { return _IConsensus.Contract.Version(&_IConsensus.CallOpts) } +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IConsensus.Contract.WasInputFinalized(&_IConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IConsensus.Contract.WasInputFinalized(&_IConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + // AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. // // Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() @@ -547,25 +591,25 @@ func (_IConsensus *IConsensusTransactorSession) AcceptClaim(appContract common.A return _IConsensus.Contract.AcceptClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IConsensus *IConsensusTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IConsensus.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IConsensus *IConsensusSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IConsensus *IConsensusTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } // IConsensusClaimAcceptedIterator is returned from FilterClaimAccepted and is used to iterate over the raw logs and unpacked data for ClaimAccepted events raised by the IConsensus contract. diff --git a/pkg/contracts/idaveappfactory/idaveappfactory.go b/pkg/contracts/idaveappfactory/idaveappfactory.go index 5590683e0..ae2621e60 100644 --- a/pkg/contracts/idaveappfactory/idaveappfactory.go +++ b/pkg/contracts/idaveappfactory/idaveappfactory.go @@ -40,7 +40,7 @@ type WithdrawalConfig struct { // IDaveAppFactoryMetaData contains all meta data concerning the IDaveAppFactory contract. var IDaveAppFactoryMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"calculateDaveAppAddress\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContractAddress\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"daveConsensusAddress\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newDaveApp\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"internalType\":\"contractIDaveConsensus\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"DaveAppCreated\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIDaveConsensus\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"calculateDaveAppAddress\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryManager\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"sentries\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContractAddress\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"daveConsensusAddress\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newDaveApp\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryManager\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"sentries\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"internalType\":\"contractIDaveConsensus\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"DaveAppCreated\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIDaveConsensus\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"DuplicatedSentryAddress\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]},{\"type\":\"error\",\"name\":\"ZeroSentryAddress\",\"inputs\":[]}]", } // IDaveAppFactoryABI is the input ABI used to generate the binding from. @@ -189,15 +189,15 @@ func (_IDaveAppFactory *IDaveAppFactoryTransactorRaw) Transact(opts *bind.Transa return _IDaveAppFactory.Contract.contract.Transact(opts, method, params...) } -// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bind.CallOpts, templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bind.CallOpts, templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { var out []interface{} - err := _IDaveAppFactory.contract.Call(opts, &out, "calculateDaveAppAddress", templateHash, withdrawalConfig, salt) + err := _IDaveAppFactory.contract.Call(opts, &out, "calculateDaveAppAddress", templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) outstruct := new(struct { AppContractAddress common.Address @@ -214,45 +214,45 @@ func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bin } -// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactorySession) CalculateDaveAppAddress(templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactorySession) CalculateDaveAppAddress(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { - return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, withdrawalConfig, salt) + return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } -// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x4d3b6acb. +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. // -// Solidity: function calculateDaveAppAddress(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) -func (_IDaveAppFactory *IDaveAppFactoryCallerSession) CalculateDaveAppAddress(templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactoryCallerSession) CalculateDaveAppAddress(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { AppContractAddress common.Address DaveConsensusAddress common.Address }, error) { - return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, withdrawalConfig, salt) + return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. // -// Solidity: function newDaveApp(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactoryTransactor) NewDaveApp(opts *bind.TransactOpts, templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.contract.Transact(opts, "newDaveApp", templateHash, withdrawalConfig, salt) +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryTransactor) NewDaveApp(opts *bind.TransactOpts, templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.contract.Transact(opts, "newDaveApp", templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. // -// Solidity: function newDaveApp(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactorySession) NewDaveApp(templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, withdrawalConfig, salt) +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactorySession) NewDaveApp(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } -// NewDaveApp is a paid mutator transaction binding the contract method 0xc119e684. +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. // -// Solidity: function newDaveApp(bytes32 templateHash, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) -func (_IDaveAppFactory *IDaveAppFactoryTransactorSession) NewDaveApp(templateHash [32]byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, withdrawalConfig, salt) +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryTransactorSession) NewDaveApp(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) } // IDaveAppFactoryDaveAppCreatedIterator is returned from FilterDaveAppCreated and is used to iterate over the raw logs and unpacked data for DaveAppCreated events raised by the IDaveAppFactory contract. diff --git a/pkg/contracts/idaveconsensus/idaveconsensus.go b/pkg/contracts/idaveconsensus/idaveconsensus.go index f920a1fc7..a4b09abb6 100644 --- a/pkg/contracts/idaveconsensus/idaveconsensus.go +++ b/pkg/contracts/idaveconsensus/idaveconsensus.go @@ -29,9 +29,22 @@ var ( _ = abi.ConvertType ) +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + // IDaveConsensusMetaData contains all meta data concerning the IDaveConsensus contract. var IDaveConsensusMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"canSettle\",\"inputs\":[],\"outputs\":[{\"name\":\"isFinished\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"winnerCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApplicationContract\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getCurrentSealedEpoch\",\"inputs\":[],\"outputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"tournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTournamentFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournamentFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"provideMerkleRootOfInput\",\"inputs\":[{\"name\":\"inputIndexWithinEpoch\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"settle\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ConsensusCreation\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIInputBox\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"tournamentFactory\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournamentFactory\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EpochSealed\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"initialMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"tournament\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"received\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IncorrectEpochNumber\",\"inputs\":[{\"name\":\"received\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"actual\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InputHashMismatch\",\"inputs\":[{\"name\":\"fromReceivedInput\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"fromInputBox\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProof\",\"inputs\":[{\"name\":\"settledState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProofSize\",\"inputs\":[{\"name\":\"suppliedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"TournamentNotFinishedYet\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"acceptStagedTournamentResult\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"canAcceptStagedTournamentResult\",\"inputs\":[],\"outputs\":[{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"doAllSentriesAgreeWithStagedTournamentResult\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isClaimStagingPeriodOver\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canStageTournamentResult\",\"inputs\":[],\"outputs\":[{\"name\":\"isFinished\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isTournamentFailed\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"winnerCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"winnerPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApplicationContract\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getCurrentSealedEpoch\",\"inputs\":[],\"outputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"tournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSentries\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryById\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryClaimCount\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryId\",\"inputs\":[{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryManager\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTournamentFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournamentFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"hasSentryClaimedInEpoch\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"provideMerkleRootOfInput\",\"inputs\":[{\"name\":\"inputIndexWithinEpoch\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"rotateSentry\",\"inputs\":[{\"name\":\"currentSentry\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"newSentry\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stageTournamentResult\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"submitSentryClaim\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ConsensusCreation\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIInputBox\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"tournamentFactory\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournamentFactory\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EpochSealed\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"initialMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"tournament\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EpochStaged\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SentryClaim\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SentryRotation\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"oldSentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newSentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"received\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotSentry\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotSentryManager\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"CannotRotateNonSentry\",\"inputs\":[{\"name\":\"nonSentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DuplicatedSentryAddress\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IncorrectEpochNumber\",\"inputs\":[{\"name\":\"received\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"actual\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InputHashMismatch\",\"inputs\":[{\"name\":\"fromReceivedInput\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"fromInputBox\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SentryAlreadyClaimed\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"TournamentNotFinishedYet\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentResultAlreadyStaged\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentResultNotStaged\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ZeroSentryAddress\",\"inputs\":[]}]", } // IDaveConsensusABI is the input ABI used to generate the binding from. @@ -180,54 +193,134 @@ func (_IDaveConsensus *IDaveConsensusTransactorRaw) Transact(opts *bind.Transact return _IDaveConsensus.Contract.contract.Transact(opts, method, params...) } -// CanSettle is a free data retrieval call binding the contract method 0xfaf7ba6a. +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusCaller) CanAcceptStagedTournamentResult(opts *bind.CallOpts) (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "canAcceptStagedTournamentResult") + + outstruct := new(struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.DoAllSentriesAgreeWithStagedTournamentResult = *abi.ConvertType(out[1], new(bool)).(*bool) + outstruct.IsClaimStagingPeriodOver = *abi.ConvertType(out[2], new(bool)).(*bool) + outstruct.EpochNumber = *abi.ConvertType(out[3], new(*big.Int)).(**big.Int) + outstruct.StagedPostEpochMachineStateHash = *abi.ConvertType(out[4], new([32]byte)).(*[32]byte) + outstruct.StagedPostEpochOutputsMerkleRoot = *abi.ConvertType(out[5], new([32]byte)).(*[32]byte) + + return *outstruct, err + +} + +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusSession) CanAcceptStagedTournamentResult() (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + return _IDaveConsensus.Contract.CanAcceptStagedTournamentResult(&_IDaveConsensus.CallOpts) +} + +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusCallerSession) CanAcceptStagedTournamentResult() (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + return _IDaveConsensus.Contract.CanAcceptStagedTournamentResult(&_IDaveConsensus.CallOpts) +} + +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. // -// Solidity: function canSettle() view returns(bool isFinished, uint256 epochNumber, bytes32 winnerCommitment) -func (_IDaveConsensus *IDaveConsensusCaller) CanSettle(opts *bind.CallOpts) (struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusCaller) CanStageTournamentResult(opts *bind.CallOpts) (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte }, error) { var out []interface{} - err := _IDaveConsensus.contract.Call(opts, &out, "canSettle") + err := _IDaveConsensus.contract.Call(opts, &out, "canStageTournamentResult") outstruct := new(struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte }) if err != nil { return *outstruct, err } outstruct.IsFinished = *abi.ConvertType(out[0], new(bool)).(*bool) - outstruct.EpochNumber = *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) - outstruct.WinnerCommitment = *abi.ConvertType(out[2], new([32]byte)).(*[32]byte) + outstruct.IsTournamentFailed = *abi.ConvertType(out[1], new(bool)).(*bool) + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[2], new(bool)).(*bool) + outstruct.EpochNumber = *abi.ConvertType(out[3], new(*big.Int)).(**big.Int) + outstruct.WinnerCommitment = *abi.ConvertType(out[4], new([32]byte)).(*[32]byte) + outstruct.WinnerPostEpochMachineStateHash = *abi.ConvertType(out[5], new([32]byte)).(*[32]byte) return *outstruct, err } -// CanSettle is a free data retrieval call binding the contract method 0xfaf7ba6a. +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. // -// Solidity: function canSettle() view returns(bool isFinished, uint256 epochNumber, bytes32 winnerCommitment) -func (_IDaveConsensus *IDaveConsensusSession) CanSettle() (struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusSession) CanStageTournamentResult() (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte }, error) { - return _IDaveConsensus.Contract.CanSettle(&_IDaveConsensus.CallOpts) + return _IDaveConsensus.Contract.CanStageTournamentResult(&_IDaveConsensus.CallOpts) } -// CanSettle is a free data retrieval call binding the contract method 0xfaf7ba6a. +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. // -// Solidity: function canSettle() view returns(bool isFinished, uint256 epochNumber, bytes32 winnerCommitment) -func (_IDaveConsensus *IDaveConsensusCallerSession) CanSettle() (struct { - IsFinished bool - EpochNumber *big.Int - WinnerCommitment [32]byte +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusCallerSession) CanStageTournamentResult() (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte }, error) { - return _IDaveConsensus.Contract.CanSettle(&_IDaveConsensus.CallOpts) + return _IDaveConsensus.Contract.CanStageTournamentResult(&_IDaveConsensus.CallOpts) } // GetApplicationContract is a free data retrieval call binding the contract method 0xc050be00. @@ -261,23 +354,62 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) GetApplicationContract() (co return _IDaveConsensus.Contract.GetApplicationContract(&_IDaveConsensus.CallOpts) } +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getClaimStagingPeriod") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IDaveConsensus.Contract.GetClaimStagingPeriod(&_IDaveConsensus.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IDaveConsensus.Contract.GetClaimStagingPeriod(&_IDaveConsensus.CallOpts) +} + // GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. // -// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament) +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) func (_IDaveConsensus *IDaveConsensusCaller) GetCurrentSealedEpoch(opts *bind.CallOpts) (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte }, error) { var out []interface{} err := _IDaveConsensus.contract.Call(opts, &out, "getCurrentSealedEpoch") outstruct := new(struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte }) if err != nil { return *outstruct, err @@ -287,6 +419,10 @@ func (_IDaveConsensus *IDaveConsensusCaller) GetCurrentSealedEpoch(opts *bind.Ca outstruct.InputIndexLowerBound = *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) outstruct.InputIndexUpperBound = *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) outstruct.Tournament = *abi.ConvertType(out[3], new(common.Address)).(*common.Address) + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[4], new(bool)).(*bool) + outstruct.StagingBlockNumber = *abi.ConvertType(out[5], new(*big.Int)).(**big.Int) + outstruct.StagedPostEpochMachineStateHash = *abi.ConvertType(out[6], new([32]byte)).(*[32]byte) + outstruct.StagedPostEpochOutputsMerkleRoot = *abi.ConvertType(out[7], new([32]byte)).(*[32]byte) return *outstruct, err @@ -294,24 +430,32 @@ func (_IDaveConsensus *IDaveConsensusCaller) GetCurrentSealedEpoch(opts *bind.Ca // GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. // -// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament) +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) func (_IDaveConsensus *IDaveConsensusSession) GetCurrentSealedEpoch() (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte }, error) { return _IDaveConsensus.Contract.GetCurrentSealedEpoch(&_IDaveConsensus.CallOpts) } // GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. // -// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament) +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) func (_IDaveConsensus *IDaveConsensusCallerSession) GetCurrentSealedEpoch() (struct { - EpochNumber *big.Int - InputIndexLowerBound *big.Int - InputIndexUpperBound *big.Int - Tournament common.Address + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte }, error) { return _IDaveConsensus.Contract.GetCurrentSealedEpoch(&_IDaveConsensus.CallOpts) } @@ -409,6 +553,161 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) GetLastFinalizedMachineMerkl return _IDaveConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IDaveConsensus.CallOpts, appContract) } +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetNumberOfSentries(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getNumberOfSentries") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetNumberOfSentries() (*big.Int, error) { + return _IDaveConsensus.Contract.GetNumberOfSentries(&_IDaveConsensus.CallOpts) +} + +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetNumberOfSentries() (*big.Int, error) { + return _IDaveConsensus.Contract.GetNumberOfSentries(&_IDaveConsensus.CallOpts) +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryById(opts *bind.CallOpts, sentryId *big.Int) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryById", sentryId) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryById(sentryId *big.Int) (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryById(&_IDaveConsensus.CallOpts, sentryId) +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryById(sentryId *big.Int) (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryById(&_IDaveConsensus.CallOpts, sentryId) +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryClaimCount(opts *bind.CallOpts, epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryClaimCount", epochNumber, postEpochMachineStateHash) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryClaimCount(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryClaimCount(&_IDaveConsensus.CallOpts, epochNumber, postEpochMachineStateHash) +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryClaimCount(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryClaimCount(&_IDaveConsensus.CallOpts, epochNumber, postEpochMachineStateHash) +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryId(opts *bind.CallOpts, sentry common.Address) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryId", sentry) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryId(sentry common.Address) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryId(&_IDaveConsensus.CallOpts, sentry) +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryId(sentry common.Address) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryId(&_IDaveConsensus.CallOpts, sentry) +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryManager(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryManager") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryManager() (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryManager(&_IDaveConsensus.CallOpts) +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryManager() (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryManager(&_IDaveConsensus.CallOpts) +} + // GetTournamentFactory is a free data retrieval call binding the contract method 0x813a1aaf. // // Solidity: function getTournamentFactory() view returns(address) @@ -440,6 +739,37 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) GetTournamentFactory() (comm return _IDaveConsensus.Contract.GetTournamentFactory(&_IDaveConsensus.CallOpts) } +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCaller) HasSentryClaimedInEpoch(opts *bind.CallOpts, epochNumber *big.Int, sentryId *big.Int) (bool, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "hasSentryClaimedInEpoch", epochNumber, sentryId) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusSession) HasSentryClaimedInEpoch(epochNumber *big.Int, sentryId *big.Int) (bool, error) { + return _IDaveConsensus.Contract.HasSentryClaimedInEpoch(&_IDaveConsensus.CallOpts, epochNumber, sentryId) +} + +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCallerSession) HasSentryClaimedInEpoch(epochNumber *big.Int, sentryId *big.Int) (bool, error) { + return _IDaveConsensus.Contract.HasSentryClaimedInEpoch(&_IDaveConsensus.CallOpts, epochNumber, sentryId) +} + // IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. // // Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) @@ -533,25 +863,119 @@ func (_IDaveConsensus *IDaveConsensusCallerSession) SupportsInterface(interfaceI return _IDaveConsensus.Contract.SupportsInterface(&_IDaveConsensus.CallOpts, interfaceId) } -// Settle is a paid mutator transaction binding the contract method 0x8bca2e0c. +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IDaveConsensus.Contract.WasInputFinalized(&_IDaveConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IDaveConsensus.Contract.WasInputFinalized(&_IDaveConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) AcceptStagedTournamentResult(opts *bind.TransactOpts, epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "acceptStagedTournamentResult", epochNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusSession) AcceptStagedTournamentResult(epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.Contract.AcceptStagedTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) AcceptStagedTournamentResult(epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.Contract.AcceptStagedTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) RotateSentry(opts *bind.TransactOpts, currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "rotateSentry", currentSentry, newSentry) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusSession) RotateSentry(currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.Contract.RotateSentry(&_IDaveConsensus.TransactOpts, currentSentry, newSentry) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) RotateSentry(currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.Contract.RotateSentry(&_IDaveConsensus.TransactOpts, currentSentry, newSentry) +} + +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. +// +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) StageTournamentResult(opts *bind.TransactOpts, epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "stageTournamentResult", epochNumber, proof) +} + +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. // -// Solidity: function settle(uint256 epochNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IDaveConsensus *IDaveConsensusTransactor) Settle(opts *bind.TransactOpts, epochNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IDaveConsensus.contract.Transact(opts, "settle", epochNumber, outputsMerkleRoot, proof) +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusSession) StageTournamentResult(epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.Contract.StageTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber, proof) } -// Settle is a paid mutator transaction binding the contract method 0x8bca2e0c. +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. // -// Solidity: function settle(uint256 epochNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IDaveConsensus *IDaveConsensusSession) Settle(epochNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IDaveConsensus.Contract.Settle(&_IDaveConsensus.TransactOpts, epochNumber, outputsMerkleRoot, proof) +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) StageTournamentResult(epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.Contract.StageTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber, proof) } -// Settle is a paid mutator transaction binding the contract method 0x8bca2e0c. +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. // -// Solidity: function settle(uint256 epochNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IDaveConsensus *IDaveConsensusTransactorSession) Settle(epochNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IDaveConsensus.Contract.Settle(&_IDaveConsensus.TransactOpts, epochNumber, outputsMerkleRoot, proof) +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) SubmitSentryClaim(opts *bind.TransactOpts, epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "submitSentryClaim", epochNumber, postEpochMachineStateHash) +} + +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. +// +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusSession) SubmitSentryClaim(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.Contract.SubmitSentryClaim(&_IDaveConsensus.TransactOpts, epochNumber, postEpochMachineStateHash) +} + +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. +// +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) SubmitSentryClaim(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.Contract.SubmitSentryClaim(&_IDaveConsensus.TransactOpts, epochNumber, postEpochMachineStateHash) } // IDaveConsensusConsensusCreationIterator is returned from FilterConsensusCreation and is used to iterate over the raw logs and unpacked data for ConsensusCreation events raised by the IDaveConsensus contract. @@ -770,10 +1194,15 @@ type IDaveConsensusEpochSealed struct { // FilterEpochSealed is a free log retrieval operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. // -// Solidity: event EpochSealed(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) -func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochSealed(opts *bind.FilterOpts) (*IDaveConsensusEpochSealedIterator, error) { +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochSealed(opts *bind.FilterOpts, epochNumber []*big.Int) (*IDaveConsensusEpochSealedIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } - logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "EpochSealed") + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "EpochSealed", epochNumberRule) if err != nil { return nil, err } @@ -782,10 +1211,15 @@ func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochSealed(opts *bind.Filt // WatchEpochSealed is a free log subscription operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. // -// Solidity: event EpochSealed(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) -func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochSealed(opts *bind.WatchOpts, sink chan<- *IDaveConsensusEpochSealed) (event.Subscription, error) { +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochSealed(opts *bind.WatchOpts, sink chan<- *IDaveConsensusEpochSealed, epochNumber []*big.Int) (event.Subscription, error) { - logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "EpochSealed") + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "EpochSealed", epochNumberRule) if err != nil { return nil, err } @@ -819,7 +1253,7 @@ func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochSealed(opts *bind.Watch // ParseEpochSealed is a log parse operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. // -// Solidity: event EpochSealed(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) func (_IDaveConsensus *IDaveConsensusFilterer) ParseEpochSealed(log types.Log) (*IDaveConsensusEpochSealed, error) { event := new(IDaveConsensusEpochSealed) if err := _IDaveConsensus.contract.UnpackLog(event, "EpochSealed", log); err != nil { @@ -828,3 +1262,474 @@ func (_IDaveConsensus *IDaveConsensusFilterer) ParseEpochSealed(log types.Log) ( event.Raw = log return event, nil } + +// IDaveConsensusEpochStagedIterator is returned from FilterEpochStaged and is used to iterate over the raw logs and unpacked data for EpochStaged events raised by the IDaveConsensus contract. +type IDaveConsensusEpochStagedIterator struct { + Event *IDaveConsensusEpochStaged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusEpochStagedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusEpochStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusEpochStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusEpochStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusEpochStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusEpochStaged represents a EpochStaged event raised by the IDaveConsensus contract. +type IDaveConsensusEpochStaged struct { + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterEpochStaged is a free log retrieval operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochStaged(opts *bind.FilterOpts, epochNumber []*big.Int) (*IDaveConsensusEpochStagedIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "EpochStaged", epochNumberRule) + if err != nil { + return nil, err + } + return &IDaveConsensusEpochStagedIterator{contract: _IDaveConsensus.contract, event: "EpochStaged", logs: logs, sub: sub}, nil +} + +// WatchEpochStaged is a free log subscription operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochStaged(opts *bind.WatchOpts, sink chan<- *IDaveConsensusEpochStaged, epochNumber []*big.Int) (event.Subscription, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "EpochStaged", epochNumberRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusEpochStaged) + if err := _IDaveConsensus.contract.UnpackLog(event, "EpochStaged", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseEpochStaged is a log parse operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseEpochStaged(log types.Log) (*IDaveConsensusEpochStaged, error) { + event := new(IDaveConsensusEpochStaged) + if err := _IDaveConsensus.contract.UnpackLog(event, "EpochStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IDaveConsensusSentryClaimIterator is returned from FilterSentryClaim and is used to iterate over the raw logs and unpacked data for SentryClaim events raised by the IDaveConsensus contract. +type IDaveConsensusSentryClaimIterator struct { + Event *IDaveConsensusSentryClaim // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusSentryClaimIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryClaim) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryClaim) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusSentryClaimIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusSentryClaimIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusSentryClaim represents a SentryClaim event raised by the IDaveConsensus contract. +type IDaveConsensusSentryClaim struct { + EpochNumber *big.Int + SentryId *big.Int + Sentry common.Address + PostEpochMachineStateHash [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSentryClaim is a free log retrieval operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterSentryClaim(opts *bind.FilterOpts, epochNumber []*big.Int, sentryId []*big.Int, sentry []common.Address) (*IDaveConsensusSentryClaimIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var sentryRule []interface{} + for _, sentryItem := range sentry { + sentryRule = append(sentryRule, sentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "SentryClaim", epochNumberRule, sentryIdRule, sentryRule) + if err != nil { + return nil, err + } + return &IDaveConsensusSentryClaimIterator{contract: _IDaveConsensus.contract, event: "SentryClaim", logs: logs, sub: sub}, nil +} + +// WatchSentryClaim is a free log subscription operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchSentryClaim(opts *bind.WatchOpts, sink chan<- *IDaveConsensusSentryClaim, epochNumber []*big.Int, sentryId []*big.Int, sentry []common.Address) (event.Subscription, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var sentryRule []interface{} + for _, sentryItem := range sentry { + sentryRule = append(sentryRule, sentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "SentryClaim", epochNumberRule, sentryIdRule, sentryRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusSentryClaim) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryClaim", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSentryClaim is a log parse operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseSentryClaim(log types.Log) (*IDaveConsensusSentryClaim, error) { + event := new(IDaveConsensusSentryClaim) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryClaim", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IDaveConsensusSentryRotationIterator is returned from FilterSentryRotation and is used to iterate over the raw logs and unpacked data for SentryRotation events raised by the IDaveConsensus contract. +type IDaveConsensusSentryRotationIterator struct { + Event *IDaveConsensusSentryRotation // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusSentryRotationIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryRotation) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryRotation) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusSentryRotationIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusSentryRotationIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusSentryRotation represents a SentryRotation event raised by the IDaveConsensus contract. +type IDaveConsensusSentryRotation struct { + SentryId *big.Int + OldSentry common.Address + NewSentry common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSentryRotation is a free log retrieval operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterSentryRotation(opts *bind.FilterOpts, sentryId []*big.Int, oldSentry []common.Address, newSentry []common.Address) (*IDaveConsensusSentryRotationIterator, error) { + + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var oldSentryRule []interface{} + for _, oldSentryItem := range oldSentry { + oldSentryRule = append(oldSentryRule, oldSentryItem) + } + var newSentryRule []interface{} + for _, newSentryItem := range newSentry { + newSentryRule = append(newSentryRule, newSentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "SentryRotation", sentryIdRule, oldSentryRule, newSentryRule) + if err != nil { + return nil, err + } + return &IDaveConsensusSentryRotationIterator{contract: _IDaveConsensus.contract, event: "SentryRotation", logs: logs, sub: sub}, nil +} + +// WatchSentryRotation is a free log subscription operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchSentryRotation(opts *bind.WatchOpts, sink chan<- *IDaveConsensusSentryRotation, sentryId []*big.Int, oldSentry []common.Address, newSentry []common.Address) (event.Subscription, error) { + + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var oldSentryRule []interface{} + for _, oldSentryItem := range oldSentry { + oldSentryRule = append(oldSentryRule, oldSentryItem) + } + var newSentryRule []interface{} + for _, newSentryItem := range newSentry { + newSentryRule = append(newSentryRule, newSentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "SentryRotation", sentryIdRule, oldSentryRule, newSentryRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusSentryRotation) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryRotation", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSentryRotation is a log parse operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseSentryRotation(log types.Log) (*IDaveConsensusSentryRotation, error) { + event := new(IDaveConsensusSentryRotation) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryRotation", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/ierc20portal/ierc20portal.go b/pkg/contracts/ierc20portal/ierc20portal.go index 0c7796da6..e9158e856 100644 --- a/pkg/contracts/ierc20portal/ierc20portal.go +++ b/pkg/contracts/ierc20portal/ierc20portal.go @@ -29,113 +29,113 @@ var ( _ = abi.ConvertType ) -// IERC20PortalMetaData contains all meta data concerning the IERC20Portal contract. -var IERC20PortalMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"depositERC20Tokens\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"contractIERC20\"},{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"execLayerData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"ERC20TransferFailed\",\"inputs\":[]}]", +// IErc20PortalMetaData contains all meta data concerning the IErc20Portal contract. +var IErc20PortalMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"depositErc20Tokens\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"contractIERC20\"},{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"execLayerData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"Erc20TransferDecreasedApplicationBalance\",\"inputs\":[{\"name\":\"balanceBefore\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balanceAfter\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"Erc20TransferFailed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"Erc20TransferValueIsNotBalanceDelta\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balanceDelta\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]}]", } -// IERC20PortalABI is the input ABI used to generate the binding from. -// Deprecated: Use IERC20PortalMetaData.ABI instead. -var IERC20PortalABI = IERC20PortalMetaData.ABI +// IErc20PortalABI is the input ABI used to generate the binding from. +// Deprecated: Use IErc20PortalMetaData.ABI instead. +var IErc20PortalABI = IErc20PortalMetaData.ABI -// IERC20Portal is an auto generated Go binding around an Ethereum contract. -type IERC20Portal struct { - IERC20PortalCaller // Read-only binding to the contract - IERC20PortalTransactor // Write-only binding to the contract - IERC20PortalFilterer // Log filterer for contract events +// IErc20Portal is an auto generated Go binding around an Ethereum contract. +type IErc20Portal struct { + IErc20PortalCaller // Read-only binding to the contract + IErc20PortalTransactor // Write-only binding to the contract + IErc20PortalFilterer // Log filterer for contract events } -// IERC20PortalCaller is an auto generated read-only Go binding around an Ethereum contract. -type IERC20PortalCaller struct { +// IErc20PortalCaller is an auto generated read-only Go binding around an Ethereum contract. +type IErc20PortalCaller struct { contract *bind.BoundContract // Generic contract wrapper for the low level calls } -// IERC20PortalTransactor is an auto generated write-only Go binding around an Ethereum contract. -type IERC20PortalTransactor struct { +// IErc20PortalTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IErc20PortalTransactor struct { contract *bind.BoundContract // Generic contract wrapper for the low level calls } -// IERC20PortalFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type IERC20PortalFilterer struct { +// IErc20PortalFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IErc20PortalFilterer struct { contract *bind.BoundContract // Generic contract wrapper for the low level calls } -// IERC20PortalSession is an auto generated Go binding around an Ethereum contract, +// IErc20PortalSession is an auto generated Go binding around an Ethereum contract, // with pre-set call and transact options. -type IERC20PortalSession struct { - Contract *IERC20Portal // Generic contract binding to set the session for +type IErc20PortalSession struct { + Contract *IErc20Portal // Generic contract binding to set the session for CallOpts bind.CallOpts // Call options to use throughout this session TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session } -// IERC20PortalCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// IErc20PortalCallerSession is an auto generated read-only Go binding around an Ethereum contract, // with pre-set call options. -type IERC20PortalCallerSession struct { - Contract *IERC20PortalCaller // Generic contract caller binding to set the session for +type IErc20PortalCallerSession struct { + Contract *IErc20PortalCaller // Generic contract caller binding to set the session for CallOpts bind.CallOpts // Call options to use throughout this session } -// IERC20PortalTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// IErc20PortalTransactorSession is an auto generated write-only Go binding around an Ethereum contract, // with pre-set transact options. -type IERC20PortalTransactorSession struct { - Contract *IERC20PortalTransactor // Generic contract transactor binding to set the session for +type IErc20PortalTransactorSession struct { + Contract *IErc20PortalTransactor // Generic contract transactor binding to set the session for TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session } -// IERC20PortalRaw is an auto generated low-level Go binding around an Ethereum contract. -type IERC20PortalRaw struct { - Contract *IERC20Portal // Generic contract binding to access the raw methods on +// IErc20PortalRaw is an auto generated low-level Go binding around an Ethereum contract. +type IErc20PortalRaw struct { + Contract *IErc20Portal // Generic contract binding to access the raw methods on } -// IERC20PortalCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type IERC20PortalCallerRaw struct { - Contract *IERC20PortalCaller // Generic read-only contract binding to access the raw methods on +// IErc20PortalCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IErc20PortalCallerRaw struct { + Contract *IErc20PortalCaller // Generic read-only contract binding to access the raw methods on } -// IERC20PortalTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type IERC20PortalTransactorRaw struct { - Contract *IERC20PortalTransactor // Generic write-only contract binding to access the raw methods on +// IErc20PortalTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IErc20PortalTransactorRaw struct { + Contract *IErc20PortalTransactor // Generic write-only contract binding to access the raw methods on } -// NewIERC20Portal creates a new instance of IERC20Portal, bound to a specific deployed contract. -func NewIERC20Portal(address common.Address, backend bind.ContractBackend) (*IERC20Portal, error) { - contract, err := bindIERC20Portal(address, backend, backend, backend) +// NewIErc20Portal creates a new instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20Portal(address common.Address, backend bind.ContractBackend) (*IErc20Portal, error) { + contract, err := bindIErc20Portal(address, backend, backend, backend) if err != nil { return nil, err } - return &IERC20Portal{IERC20PortalCaller: IERC20PortalCaller{contract: contract}, IERC20PortalTransactor: IERC20PortalTransactor{contract: contract}, IERC20PortalFilterer: IERC20PortalFilterer{contract: contract}}, nil + return &IErc20Portal{IErc20PortalCaller: IErc20PortalCaller{contract: contract}, IErc20PortalTransactor: IErc20PortalTransactor{contract: contract}, IErc20PortalFilterer: IErc20PortalFilterer{contract: contract}}, nil } -// NewIERC20PortalCaller creates a new read-only instance of IERC20Portal, bound to a specific deployed contract. -func NewIERC20PortalCaller(address common.Address, caller bind.ContractCaller) (*IERC20PortalCaller, error) { - contract, err := bindIERC20Portal(address, caller, nil, nil) +// NewIErc20PortalCaller creates a new read-only instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalCaller(address common.Address, caller bind.ContractCaller) (*IErc20PortalCaller, error) { + contract, err := bindIErc20Portal(address, caller, nil, nil) if err != nil { return nil, err } - return &IERC20PortalCaller{contract: contract}, nil + return &IErc20PortalCaller{contract: contract}, nil } -// NewIERC20PortalTransactor creates a new write-only instance of IERC20Portal, bound to a specific deployed contract. -func NewIERC20PortalTransactor(address common.Address, transactor bind.ContractTransactor) (*IERC20PortalTransactor, error) { - contract, err := bindIERC20Portal(address, nil, transactor, nil) +// NewIErc20PortalTransactor creates a new write-only instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalTransactor(address common.Address, transactor bind.ContractTransactor) (*IErc20PortalTransactor, error) { + contract, err := bindIErc20Portal(address, nil, transactor, nil) if err != nil { return nil, err } - return &IERC20PortalTransactor{contract: contract}, nil + return &IErc20PortalTransactor{contract: contract}, nil } -// NewIERC20PortalFilterer creates a new log filterer instance of IERC20Portal, bound to a specific deployed contract. -func NewIERC20PortalFilterer(address common.Address, filterer bind.ContractFilterer) (*IERC20PortalFilterer, error) { - contract, err := bindIERC20Portal(address, nil, nil, filterer) +// NewIErc20PortalFilterer creates a new log filterer instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalFilterer(address common.Address, filterer bind.ContractFilterer) (*IErc20PortalFilterer, error) { + contract, err := bindIErc20Portal(address, nil, nil, filterer) if err != nil { return nil, err } - return &IERC20PortalFilterer{contract: contract}, nil + return &IErc20PortalFilterer{contract: contract}, nil } -// bindIERC20Portal binds a generic wrapper to an already deployed contract. -func bindIERC20Portal(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := IERC20PortalMetaData.GetAbi() +// bindIErc20Portal binds a generic wrapper to an already deployed contract. +func bindIErc20Portal(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IErc20PortalMetaData.GetAbi() if err != nil { return nil, err } @@ -146,75 +146,44 @@ func bindIERC20Portal(address common.Address, caller bind.ContractCaller, transa // sets the output to result. The result type might be a single field for simple // returns, a slice of interfaces for anonymous returns and a struct for named // returns. -func (_IERC20Portal *IERC20PortalRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _IERC20Portal.Contract.IERC20PortalCaller.contract.Call(opts, result, method, params...) +func (_IErc20Portal *IErc20PortalRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IErc20Portal.Contract.IErc20PortalCaller.contract.Call(opts, result, method, params...) } // Transfer initiates a plain transaction to move funds to the contract, calling // its default method if one is available. -func (_IERC20Portal *IERC20PortalRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _IERC20Portal.Contract.IERC20PortalTransactor.contract.Transfer(opts) +func (_IErc20Portal *IErc20PortalRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IErc20Portal.Contract.IErc20PortalTransactor.contract.Transfer(opts) } // Transact invokes the (paid) contract method with params as input values. -func (_IERC20Portal *IERC20PortalRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _IERC20Portal.Contract.IERC20PortalTransactor.contract.Transact(opts, method, params...) +func (_IErc20Portal *IErc20PortalRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IErc20Portal.Contract.IErc20PortalTransactor.contract.Transact(opts, method, params...) } // Call invokes the (constant) contract method with params as input values and // sets the output to result. The result type might be a single field for simple // returns, a slice of interfaces for anonymous returns and a struct for named // returns. -func (_IERC20Portal *IERC20PortalCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _IERC20Portal.Contract.contract.Call(opts, result, method, params...) +func (_IErc20Portal *IErc20PortalCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IErc20Portal.Contract.contract.Call(opts, result, method, params...) } // Transfer initiates a plain transaction to move funds to the contract, calling // its default method if one is available. -func (_IERC20Portal *IERC20PortalTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _IERC20Portal.Contract.contract.Transfer(opts) +func (_IErc20Portal *IErc20PortalTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IErc20Portal.Contract.contract.Transfer(opts) } // Transact invokes the (paid) contract method with params as input values. -func (_IERC20Portal *IERC20PortalTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _IERC20Portal.Contract.contract.Transact(opts, method, params...) -} - -// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. -// -// Solidity: function getInputBox() view returns(address) -func (_IERC20Portal *IERC20PortalCaller) GetInputBox(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _IERC20Portal.contract.Call(opts, &out, "getInputBox") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. -// -// Solidity: function getInputBox() view returns(address) -func (_IERC20Portal *IERC20PortalSession) GetInputBox() (common.Address, error) { - return _IERC20Portal.Contract.GetInputBox(&_IERC20Portal.CallOpts) -} - -// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. -// -// Solidity: function getInputBox() view returns(address) -func (_IERC20Portal *IERC20PortalCallerSession) GetInputBox() (common.Address, error) { - return _IERC20Portal.Contract.GetInputBox(&_IERC20Portal.CallOpts) +func (_IErc20Portal *IErc20PortalTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IErc20Portal.Contract.contract.Transact(opts, method, params...) } // Version is a free data retrieval call binding the contract method 0x54fd4d50. // // Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) -func (_IERC20Portal *IERC20PortalCaller) Version(opts *bind.CallOpts) (struct { +func (_IErc20Portal *IErc20PortalCaller) Version(opts *bind.CallOpts) (struct { Major uint64 Minor uint64 Patch uint64 @@ -222,7 +191,7 @@ func (_IERC20Portal *IERC20PortalCaller) Version(opts *bind.CallOpts) (struct { BuildMetadata string }, error) { var out []interface{} - err := _IERC20Portal.contract.Call(opts, &out, "version") + err := _IErc20Portal.contract.Call(opts, &out, "version") outstruct := new(struct { Major uint64 @@ -248,46 +217,46 @@ func (_IERC20Portal *IERC20PortalCaller) Version(opts *bind.CallOpts) (struct { // Version is a free data retrieval call binding the contract method 0x54fd4d50. // // Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) -func (_IERC20Portal *IERC20PortalSession) Version() (struct { +func (_IErc20Portal *IErc20PortalSession) Version() (struct { Major uint64 Minor uint64 Patch uint64 PreRelease string BuildMetadata string }, error) { - return _IERC20Portal.Contract.Version(&_IERC20Portal.CallOpts) + return _IErc20Portal.Contract.Version(&_IErc20Portal.CallOpts) } // Version is a free data retrieval call binding the contract method 0x54fd4d50. // // Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) -func (_IERC20Portal *IERC20PortalCallerSession) Version() (struct { +func (_IErc20Portal *IErc20PortalCallerSession) Version() (struct { Major uint64 Minor uint64 Patch uint64 PreRelease string BuildMetadata string }, error) { - return _IERC20Portal.Contract.Version(&_IERC20Portal.CallOpts) + return _IErc20Portal.Contract.Version(&_IErc20Portal.CallOpts) } -// DepositERC20Tokens is a paid mutator transaction binding the contract method 0x95854b81. +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. // -// Solidity: function depositERC20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() -func (_IERC20Portal *IERC20PortalTransactor) DepositERC20Tokens(opts *bind.TransactOpts, token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { - return _IERC20Portal.contract.Transact(opts, "depositERC20Tokens", token, appContract, value, execLayerData) +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalTransactor) DepositErc20Tokens(opts *bind.TransactOpts, token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.contract.Transact(opts, "depositErc20Tokens", token, appContract, value, execLayerData) } -// DepositERC20Tokens is a paid mutator transaction binding the contract method 0x95854b81. +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. // -// Solidity: function depositERC20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() -func (_IERC20Portal *IERC20PortalSession) DepositERC20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { - return _IERC20Portal.Contract.DepositERC20Tokens(&_IERC20Portal.TransactOpts, token, appContract, value, execLayerData) +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalSession) DepositErc20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.Contract.DepositErc20Tokens(&_IErc20Portal.TransactOpts, token, appContract, value, execLayerData) } -// DepositERC20Tokens is a paid mutator transaction binding the contract method 0x95854b81. +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. // -// Solidity: function depositERC20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() -func (_IERC20Portal *IERC20PortalTransactorSession) DepositERC20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { - return _IERC20Portal.Contract.DepositERC20Tokens(&_IERC20Portal.TransactOpts, token, appContract, value, execLayerData) +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalTransactorSession) DepositErc20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.Contract.DepositErc20Tokens(&_IErc20Portal.TransactOpts, token, appContract, value, execLayerData) } diff --git a/pkg/contracts/iinputbox/iinputbox.go b/pkg/contracts/iinputbox/iinputbox.go index db7999b13..e4b700084 100644 --- a/pkg/contracts/iinputbox/iinputbox.go +++ b/pkg/contracts/iinputbox/iinputbox.go @@ -31,7 +31,7 @@ var ( // IInputBoxMetaData contains all meta data concerning the IInputBox contract. var IInputBoxMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"addInput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputHash\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfInputs\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"InputAdded\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputTooLarge\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxInputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"addInput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputHash\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfInputs\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"InputAdded\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InputTooLarge\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxInputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", } // IInputBoxABI is the input ABI used to generate the binding from. diff --git a/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go b/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go new file mode 100644 index 000000000..f439b43fc --- /dev/null +++ b/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go @@ -0,0 +1,459 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package imultileveltournamentfactory + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// TournamentParameters is an auto generated low-level Go binding around an user-defined struct. +type TournamentParameters struct { + Levels uint64 + Log2step uint64 + Height uint64 + ResponseBudget uint64 + MaxAllowance uint64 +} + +// IMultiLevelTournamentFactoryMetaData contains all meta data concerning the IMultiLevelTournamentFactory contract. +var IMultiLevelTournamentFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"instantiate\",\"inputs\":[{\"name\":\"initialState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"instantiateInner\",\"inputs\":[{\"name\":\"_initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_contestedCommitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"_contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_contestedCommitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"_contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"_startCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"_level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"_provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stateTransition\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIStateTransition\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentLevelCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentParameters\",\"inputs\":[{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structTournamentParameters\",\"components\":[{\"name\":\"levels\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"log2step\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"responseBudget\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"maxAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"TournamentCreated\",\"inputs\":[{\"name\":\"tournament\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournament\"}],\"anonymous\":false}]", +} + +// IMultiLevelTournamentFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use IMultiLevelTournamentFactoryMetaData.ABI instead. +var IMultiLevelTournamentFactoryABI = IMultiLevelTournamentFactoryMetaData.ABI + +// IMultiLevelTournamentFactory is an auto generated Go binding around an Ethereum contract. +type IMultiLevelTournamentFactory struct { + IMultiLevelTournamentFactoryCaller // Read-only binding to the contract + IMultiLevelTournamentFactoryTransactor // Write-only binding to the contract + IMultiLevelTournamentFactoryFilterer // Log filterer for contract events +} + +// IMultiLevelTournamentFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IMultiLevelTournamentFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IMultiLevelTournamentFactorySession struct { + Contract *IMultiLevelTournamentFactory // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IMultiLevelTournamentFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IMultiLevelTournamentFactoryCallerSession struct { + Contract *IMultiLevelTournamentFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IMultiLevelTournamentFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IMultiLevelTournamentFactoryTransactorSession struct { + Contract *IMultiLevelTournamentFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IMultiLevelTournamentFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryRaw struct { + Contract *IMultiLevelTournamentFactory // Generic contract binding to access the raw methods on +} + +// IMultiLevelTournamentFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryCallerRaw struct { + Contract *IMultiLevelTournamentFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// IMultiLevelTournamentFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryTransactorRaw struct { + Contract *IMultiLevelTournamentFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIMultiLevelTournamentFactory creates a new instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactory(address common.Address, backend bind.ContractBackend) (*IMultiLevelTournamentFactory, error) { + contract, err := bindIMultiLevelTournamentFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactory{IMultiLevelTournamentFactoryCaller: IMultiLevelTournamentFactoryCaller{contract: contract}, IMultiLevelTournamentFactoryTransactor: IMultiLevelTournamentFactoryTransactor{contract: contract}, IMultiLevelTournamentFactoryFilterer: IMultiLevelTournamentFactoryFilterer{contract: contract}}, nil +} + +// NewIMultiLevelTournamentFactoryCaller creates a new read-only instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryCaller(address common.Address, caller bind.ContractCaller) (*IMultiLevelTournamentFactoryCaller, error) { + contract, err := bindIMultiLevelTournamentFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryCaller{contract: contract}, nil +} + +// NewIMultiLevelTournamentFactoryTransactor creates a new write-only instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*IMultiLevelTournamentFactoryTransactor, error) { + contract, err := bindIMultiLevelTournamentFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryTransactor{contract: contract}, nil +} + +// NewIMultiLevelTournamentFactoryFilterer creates a new log filterer instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*IMultiLevelTournamentFactoryFilterer, error) { + contract, err := bindIMultiLevelTournamentFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryFilterer{contract: contract}, nil +} + +// bindIMultiLevelTournamentFactory binds a generic wrapper to an already deployed contract. +func bindIMultiLevelTournamentFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IMultiLevelTournamentFactoryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IMultiLevelTournamentFactory.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.contract.Transact(opts, method, params...) +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) StateTransition(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "stateTransition") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) StateTransition() (common.Address, error) { + return _IMultiLevelTournamentFactory.Contract.StateTransition(&_IMultiLevelTournamentFactory.CallOpts) +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) StateTransition() (common.Address, error) { + return _IMultiLevelTournamentFactory.Contract.StateTransition(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "tournamentLevelCount") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) TournamentLevelCount() (uint64, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentLevelCount(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) TournamentLevelCount() (uint64, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentLevelCount(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) TournamentParameters(opts *bind.CallOpts, level uint64) (TournamentParameters, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "tournamentParameters", level) + + if err != nil { + return *new(TournamentParameters), err + } + + out0 := *abi.ConvertType(out[0], new(TournamentParameters)).(*TournamentParameters) + + return out0, err + +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) TournamentParameters(level uint64) (TournamentParameters, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentParameters(&_IMultiLevelTournamentFactory.CallOpts, level) +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) TournamentParameters(level uint64) (TournamentParameters, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentParameters(&_IMultiLevelTournamentFactory.CallOpts, level) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactor) Instantiate(opts *bind.TransactOpts, initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.contract.Transact(opts, "instantiate", initialState, provider) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) Instantiate(initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.Instantiate(&_IMultiLevelTournamentFactory.TransactOpts, initialState, provider) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorSession) Instantiate(initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.Instantiate(&_IMultiLevelTournamentFactory.TransactOpts, initialState, provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactor) InstantiateInner(opts *bind.TransactOpts, _initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.contract.Transact(opts, "instantiateInner", _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) InstantiateInner(_initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.InstantiateInner(&_IMultiLevelTournamentFactory.TransactOpts, _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorSession) InstantiateInner(_initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.InstantiateInner(&_IMultiLevelTournamentFactory.TransactOpts, _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// IMultiLevelTournamentFactoryTournamentCreatedIterator is returned from FilterTournamentCreated and is used to iterate over the raw logs and unpacked data for TournamentCreated events raised by the IMultiLevelTournamentFactory contract. +type IMultiLevelTournamentFactoryTournamentCreatedIterator struct { + Event *IMultiLevelTournamentFactoryTournamentCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IMultiLevelTournamentFactoryTournamentCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IMultiLevelTournamentFactoryTournamentCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IMultiLevelTournamentFactoryTournamentCreated represents a TournamentCreated event raised by the IMultiLevelTournamentFactory contract. +type IMultiLevelTournamentFactoryTournamentCreated struct { + Tournament common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTournamentCreated is a free log retrieval operation binding the contract event 0x66b8e5c50b708b71a6f155375a4613dd12971db692c5931e7cb0701ee7add6ac. +// +// Solidity: event TournamentCreated(address tournament) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) FilterTournamentCreated(opts *bind.FilterOpts) (*IMultiLevelTournamentFactoryTournamentCreatedIterator, error) { + + logs, sub, err := _IMultiLevelTournamentFactory.contract.FilterLogs(opts, "TournamentCreated") + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryTournamentCreatedIterator{contract: _IMultiLevelTournamentFactory.contract, event: "TournamentCreated", logs: logs, sub: sub}, nil +} + +// WatchTournamentCreated is a free log subscription operation binding the contract event 0x66b8e5c50b708b71a6f155375a4613dd12971db692c5931e7cb0701ee7add6ac. +// +// Solidity: event TournamentCreated(address tournament) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) WatchTournamentCreated(opts *bind.WatchOpts, sink chan<- *IMultiLevelTournamentFactoryTournamentCreated) (event.Subscription, error) { + + logs, sub, err := _IMultiLevelTournamentFactory.contract.WatchLogs(opts, "TournamentCreated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IMultiLevelTournamentFactoryTournamentCreated) + if err := _IMultiLevelTournamentFactory.contract.UnpackLog(event, "TournamentCreated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTournamentCreated is a log parse operation binding the contract event 0x66b8e5c50b708b71a6f155375a4613dd12971db692c5931e7cb0701ee7add6ac. +// +// Solidity: event TournamentCreated(address tournament) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) ParseTournamentCreated(log types.Log) (*IMultiLevelTournamentFactoryTournamentCreated, error) { + event := new(IMultiLevelTournamentFactoryTournamentCreated) + if err := _IMultiLevelTournamentFactory.contract.UnpackLog(event, "TournamentCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iquorum/iquorum.go b/pkg/contracts/iquorum/iquorum.go index 3dda3e5d4..e01b7adf9 100644 --- a/pkg/contracts/iquorum/iquorum.go +++ b/pkg/contracts/iquorum/iquorum.go @@ -36,9 +36,22 @@ type IConsensusClaim struct { StagedOutputsMerkleRoot [32]byte } +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + // IQuorumMetaData contains all meta data concerning the IQuorum contract. var IQuorumMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidators\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorById\",\"inputs\":[{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorId\",\"inputs\":[{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotValidator\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRootProofSize\",\"inputs\":[{\"name\":\"suppliedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedProofSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidators\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorById\",\"inputs\":[{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorId\",\"inputs\":[{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotValidator\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", } // IQuorumABI is the input ABI used to generate the binding from. @@ -743,6 +756,37 @@ func (_IQuorum *IQuorumCallerSession) Version() (struct { return _IQuorum.Contract.Version(&_IQuorum.CallOpts) } +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IQuorum.Contract.WasInputFinalized(&_IQuorum.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IQuorum.Contract.WasInputFinalized(&_IQuorum.CallOpts, appContract, inputIndex, blockNumber) +} + // AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. // // Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() @@ -764,25 +808,25 @@ func (_IQuorum *IQuorumTransactorSession) AcceptClaim(appContract common.Address return _IQuorum.Contract.AcceptClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IQuorum *IQuorumTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IQuorum.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IQuorum *IQuorumSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } -// SubmitClaim is a paid mutator transaction binding the contract method 0x9a00db83. +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. // -// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32[] proof) returns() -func (_IQuorum *IQuorumTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, outputsMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { - return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, outputsMerkleRoot, proof) +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) } // IQuorumClaimAcceptedIterator is returned from FilterClaimAccepted and is used to iterate over the raw logs and unpacked data for ClaimAccepted events raised by the IQuorum contract. diff --git a/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go b/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go index 543289bfe..1aef2ddc5 100644 --- a/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go +++ b/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go @@ -40,7 +40,7 @@ type WithdrawalConfig struct { // ISelfHostedApplicationFactoryMetaData contains all meta data concerning the ISelfHostedApplicationFactory contract. var ISelfHostedApplicationFactoryMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"calculateAddresses\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"deployContracts\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"dataAvailability\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getApplicationFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplicationFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAuthorityFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthorityFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", + ABI: "[{\"type\":\"function\",\"name\":\"calculateAddresses\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"deployContracts\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getApplicationFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplicationFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAuthorityFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthorityFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", } // ISelfHostedApplicationFactoryABI is the input ABI used to generate the binding from. @@ -189,12 +189,12 @@ func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorRaw return _ISelfHostedApplicationFactory.Contract.contract.Transact(opts, method, params...) } -// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) CalculateAddresses(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) CalculateAddresses(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { var out []interface{} - err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "calculateAddresses", authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) + err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "calculateAddresses", authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) if err != nil { return *new(common.Address), *new(common.Address), err @@ -207,18 +207,18 @@ func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) Calcu } -// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { - return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } -// CalculateAddresses is a free data retrieval call binding the contract method 0x651b044f. +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. // -// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { - return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } // GetApplicationFactory is a free data retrieval call binding the contract method 0xe63d50ff. @@ -343,23 +343,23 @@ func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession return _ISelfHostedApplicationFactory.Contract.Version(&_ISelfHostedApplicationFactory.CallOpts) } -// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactor) DeployContracts(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.contract.Transact(opts, "deployContracts", authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactor) DeployContracts(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.contract.Transact(opts, "deployContracts", authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } -// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } -// DeployContracts is a paid mutator transaction binding the contract method 0x0f0dd7a7. +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. // -// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, address appOwner, bytes32 templateHash, bytes dataAvailability, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) -func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorSession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, appOwner common.Address, templateHash [32]byte, dataAvailability []byte, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { - return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, appOwner, templateHash, dataAvailability, withdrawalConfig, salt) +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorSession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) } diff --git a/pkg/contracts/itournament/itournament.go b/pkg/contracts/itournament/itournament.go index c26f6fe5a..9dfabe224 100644 --- a/pkg/contracts/itournament/itournament.go +++ b/pkg/contracts/itournament/itournament.go @@ -29,41 +29,75 @@ var ( _ = abi.ConvertType ) -// ClockState is an auto generated low-level Go binding around an user-defined struct. -type ClockState struct { - Allowance uint64 +// ITournamentBisectingMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentBisectingMatchView struct { + RevealingParent [32]byte + WaitingLeft [32]byte + WaitingRight [32]byte + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + CurrentHeight uint64 + Responder uint8 +} + +// ITournamentCommitmentStandingView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentCommitmentStandingView struct { + Joined bool + FinalState [32]byte + Claimer common.Address + ClockRunning bool + ClockDeadline uint64 + ClockAllowance uint64 +} + +// ITournamentInnerResultView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentInnerResultView struct { + Disposition uint8 + ParentCommitment [32]byte + PausedAllowance uint64 +} + +// ITournamentReadyToSealMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentReadyToSealMatchView struct { + RevealingParent [32]byte + WaitingLeft [32]byte + WaitingRight [32]byte + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + Responder uint8 +} + +// ITournamentSealedMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentSealedMatchView struct { + AgreeState [32]byte + DivergencePosition *big.Int + DivergenceCycle *big.Int + FinalStateOne [32]byte + FinalStateTwo [32]byte +} + +// ITournamentTournamentDescriptor is an auto generated low-level Go binding around an user-defined struct. +type ITournamentTournamentDescriptor struct { + InitialHash [32]byte + BaseCycle *big.Int + Log2Stride uint64 + Height uint64 + Level uint64 + Kind uint8 StartInstant uint64 + Allowance uint64 } -// CommitmentArguments is an auto generated low-level Go binding around an user-defined struct. -type CommitmentArguments struct { - InitialHash [32]byte - StartCycle *big.Int - Log2step uint64 - Height uint64 -} - -// ITournamentNestedDispute is an auto generated low-level Go binding around an user-defined struct. -type ITournamentNestedDispute struct { - ContestedCommitmentOne [32]byte - ContestedFinalStateOne [32]byte - ContestedCommitmentTwo [32]byte - ContestedFinalStateTwo [32]byte -} - -// ITournamentTournamentArguments is an auto generated low-level Go binding around an user-defined struct. -type ITournamentTournamentArguments struct { - CommitmentArgs CommitmentArguments - Level uint64 - Levels uint64 - StartInstant uint64 - Allowance uint64 - MaxAllowance uint64 - MatchEffort uint64 - Provider common.Address - NestedDispute ITournamentNestedDispute - StateTransition common.Address - TournamentFactory common.Address +// ITournamentTournamentStandingView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentTournamentStandingView struct { + Standing uint8 + AcceptsJoins bool + HasCandidate bool + Candidate [32]byte + FinalState [32]byte + ParentCommitment [32]byte + FinishedAt uint64 + WinnerExpiresAt uint64 } // MatchId is an auto generated low-level Go binding around an user-defined struct. @@ -72,19 +106,9 @@ type MatchId struct { CommitmentTwo [32]byte } -// MatchState is an auto generated low-level Go binding around an user-defined struct. -type MatchState struct { - OtherParent [32]byte - LeftNode [32]byte - RightNode [32]byte - RunningLeafPosition *big.Int - CurrentHeight uint64 - IsInit bool -} - // ITournamentMetaData contains all meta data concerning the ITournament contract. var ITournamentMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"advanceMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newLeftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newRightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"arbitrationResult\",\"inputs\":[],\"outputs\":[{\"name\":\"finished\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"winnerCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"bondValue\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canBeEliminated\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canWinMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"eliminateInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"eliminateMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getCommitment\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[{\"name\":\"clock\",\"type\":\"tuple\",\"internalType\":\"structClock.State\",\"components\":[{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}]},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getCommitmentJoinedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structMatch.State\",\"components\":[{\"name\":\"otherParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"runningLeafPosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentHeight\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"isInit\",\"type\":\"bool\",\"internalType\":\"bool\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchAdvancedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchCreatedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchCycle\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchDeletedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNewInnerTournamentCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"innerTournamentWinner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structClock.State\",\"components\":[{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isClosed\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isFinished\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"joinTournament\",\"inputs\":[{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"sealInnerMatchAndCreateInnerTournament\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"sealLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"timeFinished\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentArguments\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentArguments\",\"components\":[{\"name\":\"commitmentArgs\",\"type\":\"tuple\",\"internalType\":\"structCommitment.Arguments\",\"components\":[{\"name\":\"initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"startCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2step\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"levels\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"maxAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"matchEffort\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"},{\"name\":\"nestedDispute\",\"type\":\"tuple\",\"internalType\":\"structITournament.NestedDispute\",\"components\":[{\"name\":\"contestedCommitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"contestedCommitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"name\":\"stateTransition\",\"type\":\"address\",\"internalType\":\"contractIStateTransition\"},{\"name\":\"tournamentFactory\",\"type\":\"address\",\"internalType\":\"address\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentLevelConstants\",\"inputs\":[],\"outputs\":[{\"name\":\"maxLevel\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"log2step\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tryRecoveringBond\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"proofs\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"CommitmentJoined\",\"inputs\":[{\"name\":\"commitment\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"finalStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchAdvanced\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"otherParent\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchCreated\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"leftOfTwo\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchDeleted\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"reason\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.MatchDeletionReason\"},{\"name\":\"winnerCommitment\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.WinnerCommitment\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"NewInnerTournament\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"childTournament\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PartialBondRefund\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"success\",\"type\":\"bool\",\"indexed\":true,\"internalType\":\"bool\"},{\"name\":\"ret\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AtLeastOneClockHasNotTimedOut\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CannotAdvanceTimedOutClock\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentCannotBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentMustBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ClockAlreadyInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ClockNotInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CommitmentProofWrongSize\",\"inputs\":[{\"name\":\"treeHeight\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"siblingsLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"CommitmentStateMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"computed\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"IncorrectAgreeState\",\"inputs\":[{\"name\":\"initialState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InitializedClockCannotHaveZeroAllowance\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientBond\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidChildrenNodes\",\"inputs\":[{\"name\":\"expectedParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"leftChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"InvalidContestedFinalState\",\"inputs\":[{\"name\":\"contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InvalidTournamentWinner\",\"inputs\":[{\"name\":\"winner\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"InvalidWinnerCommitment\",\"inputs\":[{\"name\":\"winnerCommitment\",\"type\":\"uint8\",\"internalType\":\"enumITournament.WinnerCommitment\"}]},{\"type\":\"error\",\"name\":\"MatchCannotBeAdvanced\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchIsNotSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NeitherClockHasTimedOut\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NodeDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"PausedClockCannotTimeout\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyDetected\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonRootTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentFailedNoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsClosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongChildren\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"left\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"right\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"WrongFinalState\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"computedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"committedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"WrongNodesForStep\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongTournamentWinner\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"winner\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"advanceMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newLeftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newRightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"bisectingMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.BisectingMatchView\",\"components\":[{\"name\":\"revealingParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingLeft\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingRight\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"segmentStartCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentHeight\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"responder\",\"type\":\"uint8\",\"internalType\":\"enumITournament.CommitmentSide\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"bondRecovery\",\"inputs\":[],\"outputs\":[{\"name\":\"disposition\",\"type\":\"uint8\",\"internalType\":\"enumITournament.BondDisposition\"},{\"name\":\"claimer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payment\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"bondValue\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"classifyMatchTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"outcome\",\"type\":\"uint8\",\"internalType\":\"enumITournament.MatchTimeoutOutcome\"},{\"name\":\"deferredCharge\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"commitmentStanding\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.CommitmentStandingView\",\"components\":[{\"name\":\"joined\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"claimer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"clockRunning\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"clockDeadline\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"clockAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"eliminateInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"eliminateMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getCommitmentJoinedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLeafMatchSealedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchAdvancedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchCreatedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchDeletedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNewInnerTournamentCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"innerResult\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.InnerResultView\",\"components\":[{\"name\":\"disposition\",\"type\":\"uint8\",\"internalType\":\"enumITournament.InnerTournamentDisposition\"},{\"name\":\"parentCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"pausedAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"joinTournament\",\"inputs\":[{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"readyToSealMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.ReadyToSealMatchView\",\"components\":[{\"name\":\"revealingParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingLeft\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingRight\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"segmentStartCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"responder\",\"type\":\"uint8\",\"internalType\":\"enumITournament.CommitmentSide\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"sealInnerMatchAndCreateInnerTournament\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"sealLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"sealedMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.SealedMatchView\",\"components\":[{\"name\":\"agreeState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"divergencePosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"divergenceCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"finalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"finalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentDescriptor\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentDescriptor\",\"components\":[{\"name\":\"initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"baseCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2Stride\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentKind\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentStanding\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentStandingView\",\"components\":[{\"name\":\"standing\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentStanding\"},{\"name\":\"acceptsJoins\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"hasCandidate\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"candidate\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"parentCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"finishedAt\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"winnerExpiresAt\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tryRecoveringBond\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"proofs\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"BondRecovered\",\"inputs\":[{\"name\":\"commitment\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"claimer\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"payment\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"burned\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"CommitmentJoined\",\"inputs\":[{\"name\":\"commitment\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"finalStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"LeafMatchSealed\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchAdvanced\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"otherParent\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchCreated\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"leftOfTwo\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchDeleted\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"reason\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.MatchDeletionReason\"},{\"name\":\"winnerCommitment\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.WinnerCommitment\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"NewInnerTournament\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"childTournament\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PartialBondRefund\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"success\",\"type\":\"bool\",\"indexed\":true,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"CannotAdvanceTimedOutClock\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentCannotBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentMustBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ClockAlreadyInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CommitmentProofWrongSize\",\"inputs\":[{\"name\":\"treeHeight\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"siblingsLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"CommitmentStateMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"computed\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"IncorrectAgreeState\",\"inputs\":[{\"name\":\"initialState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InsufficientBond\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidChildrenNodes\",\"inputs\":[{\"name\":\"expectedParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"leftChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"InvalidContestedFinalState\",\"inputs\":[{\"name\":\"contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"MatchCannotBeAdvanced\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeEliminatedByTimeout\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeWonByTimeout\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchIsNotSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyDetected\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonRootTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentFailedNoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsClosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongChildren\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"left\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"right\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"WrongFinalState\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"computedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"committedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"WrongNodesForStep\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongTournamentWinner\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"winner\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}]", } // ITournamentABI is the input ABI used to generate the binding from. @@ -233,192 +257,211 @@ func (_ITournament *ITournamentTransactorRaw) Transact(opts *bind.TransactOpts, return _ITournament.Contract.contract.Transact(opts, method, params...) } -// ArbitrationResult is a free data retrieval call binding the contract method 0xcb2773db. +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. // -// Solidity: function arbitrationResult() view returns(bool finished, bytes32 winnerCommitment, bytes32 finalState) -func (_ITournament *ITournamentCaller) ArbitrationResult(opts *bind.CallOpts) (struct { - Finished bool - WinnerCommitment [32]byte - FinalState [32]byte +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentCaller) BisectingMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView }, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "arbitrationResult") + err := _ITournament.contract.Call(opts, &out, "bisectingMatch", matchIdHash) outstruct := new(struct { - Finished bool - WinnerCommitment [32]byte - FinalState [32]byte + ActualPhase uint8 + Value ITournamentBisectingMatchView }) if err != nil { return *outstruct, err } - outstruct.Finished = *abi.ConvertType(out[0], new(bool)).(*bool) - outstruct.WinnerCommitment = *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) - outstruct.FinalState = *abi.ConvertType(out[2], new([32]byte)).(*[32]byte) + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentBisectingMatchView)).(*ITournamentBisectingMatchView) return *outstruct, err } -// ArbitrationResult is a free data retrieval call binding the contract method 0xcb2773db. +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. // -// Solidity: function arbitrationResult() view returns(bool finished, bytes32 winnerCommitment, bytes32 finalState) -func (_ITournament *ITournamentSession) ArbitrationResult() (struct { - Finished bool - WinnerCommitment [32]byte - FinalState [32]byte +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentSession) BisectingMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView }, error) { - return _ITournament.Contract.ArbitrationResult(&_ITournament.CallOpts) + return _ITournament.Contract.BisectingMatch(&_ITournament.CallOpts, matchIdHash) } -// ArbitrationResult is a free data retrieval call binding the contract method 0xcb2773db. +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. // -// Solidity: function arbitrationResult() view returns(bool finished, bytes32 winnerCommitment, bytes32 finalState) -func (_ITournament *ITournamentCallerSession) ArbitrationResult() (struct { - Finished bool - WinnerCommitment [32]byte - FinalState [32]byte +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentCallerSession) BisectingMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView }, error) { - return _ITournament.Contract.ArbitrationResult(&_ITournament.CallOpts) + return _ITournament.Contract.BisectingMatch(&_ITournament.CallOpts, matchIdHash) } -// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. // -// Solidity: function bondValue() view returns(uint256) -func (_ITournament *ITournamentCaller) BondValue(opts *bind.CallOpts) (*big.Int, error) { +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentCaller) BondRecovery(opts *bind.CallOpts) (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "bondValue") + err := _ITournament.contract.Call(opts, &out, "bondRecovery") + outstruct := new(struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int + }) if err != nil { - return *new(*big.Int), err + return *outstruct, err } - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.Disposition = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Claimer = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + outstruct.Payment = *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) - return out0, err + return *outstruct, err } -// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. // -// Solidity: function bondValue() view returns(uint256) -func (_ITournament *ITournamentSession) BondValue() (*big.Int, error) { - return _ITournament.Contract.BondValue(&_ITournament.CallOpts) +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentSession) BondRecovery() (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { + return _ITournament.Contract.BondRecovery(&_ITournament.CallOpts) } -// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. // -// Solidity: function bondValue() view returns(uint256) -func (_ITournament *ITournamentCallerSession) BondValue() (*big.Int, error) { - return _ITournament.Contract.BondValue(&_ITournament.CallOpts) +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentCallerSession) BondRecovery() (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { + return _ITournament.Contract.BondRecovery(&_ITournament.CallOpts) } -// CanBeEliminated is a free data retrieval call binding the contract method 0x95dd0e94. +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. // -// Solidity: function canBeEliminated() view returns(bool) -func (_ITournament *ITournamentCaller) CanBeEliminated(opts *bind.CallOpts) (bool, error) { +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentCaller) BondValue(opts *bind.CallOpts) (*big.Int, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "canBeEliminated") + err := _ITournament.contract.Call(opts, &out, "bondValue") if err != nil { - return *new(bool), err + return *new(*big.Int), err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) return out0, err } -// CanBeEliminated is a free data retrieval call binding the contract method 0x95dd0e94. +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. // -// Solidity: function canBeEliminated() view returns(bool) -func (_ITournament *ITournamentSession) CanBeEliminated() (bool, error) { - return _ITournament.Contract.CanBeEliminated(&_ITournament.CallOpts) +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentSession) BondValue() (*big.Int, error) { + return _ITournament.Contract.BondValue(&_ITournament.CallOpts) } -// CanBeEliminated is a free data retrieval call binding the contract method 0x95dd0e94. +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. // -// Solidity: function canBeEliminated() view returns(bool) -func (_ITournament *ITournamentCallerSession) CanBeEliminated() (bool, error) { - return _ITournament.Contract.CanBeEliminated(&_ITournament.CallOpts) +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentCallerSession) BondValue() (*big.Int, error) { + return _ITournament.Contract.BondValue(&_ITournament.CallOpts) } -// CanWinMatchByTimeout is a free data retrieval call binding the contract method 0x6a1a140d. +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. // -// Solidity: function canWinMatchByTimeout((bytes32,bytes32) matchId) view returns(bool) -func (_ITournament *ITournamentCaller) CanWinMatchByTimeout(opts *bind.CallOpts, matchId MatchId) (bool, error) { +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentCaller) ClassifyMatchTimeout(opts *bind.CallOpts, matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "canWinMatchByTimeout", matchId) + err := _ITournament.contract.Call(opts, &out, "classifyMatchTimeout", matchId) + outstruct := new(struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 + }) if err != nil { - return *new(bool), err + return *outstruct, err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Outcome = *abi.ConvertType(out[1], new(uint8)).(*uint8) + outstruct.DeferredCharge = *abi.ConvertType(out[2], new(uint64)).(*uint64) - return out0, err + return *outstruct, err } -// CanWinMatchByTimeout is a free data retrieval call binding the contract method 0x6a1a140d. +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. // -// Solidity: function canWinMatchByTimeout((bytes32,bytes32) matchId) view returns(bool) -func (_ITournament *ITournamentSession) CanWinMatchByTimeout(matchId MatchId) (bool, error) { - return _ITournament.Contract.CanWinMatchByTimeout(&_ITournament.CallOpts, matchId) +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentSession) ClassifyMatchTimeout(matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { + return _ITournament.Contract.ClassifyMatchTimeout(&_ITournament.CallOpts, matchId) } -// CanWinMatchByTimeout is a free data retrieval call binding the contract method 0x6a1a140d. +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. // -// Solidity: function canWinMatchByTimeout((bytes32,bytes32) matchId) view returns(bool) -func (_ITournament *ITournamentCallerSession) CanWinMatchByTimeout(matchId MatchId) (bool, error) { - return _ITournament.Contract.CanWinMatchByTimeout(&_ITournament.CallOpts, matchId) +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentCallerSession) ClassifyMatchTimeout(matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { + return _ITournament.Contract.ClassifyMatchTimeout(&_ITournament.CallOpts, matchId) } -// GetCommitment is a free data retrieval call binding the contract method 0x7795820c. +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. // -// Solidity: function getCommitment(bytes32 commitmentRoot) view returns((uint64,uint64) clock, bytes32 finalState) -func (_ITournament *ITournamentCaller) GetCommitment(opts *bind.CallOpts, commitmentRoot [32]byte) (struct { - Clock ClockState - FinalState [32]byte -}, error) { +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentCaller) CommitmentStanding(opts *bind.CallOpts, commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "getCommitment", commitmentRoot) + err := _ITournament.contract.Call(opts, &out, "commitmentStanding", commitmentRoot) - outstruct := new(struct { - Clock ClockState - FinalState [32]byte - }) if err != nil { - return *outstruct, err + return *new(ITournamentCommitmentStandingView), err } - outstruct.Clock = *abi.ConvertType(out[0], new(ClockState)).(*ClockState) - outstruct.FinalState = *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) + out0 := *abi.ConvertType(out[0], new(ITournamentCommitmentStandingView)).(*ITournamentCommitmentStandingView) - return *outstruct, err + return out0, err } -// GetCommitment is a free data retrieval call binding the contract method 0x7795820c. +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. // -// Solidity: function getCommitment(bytes32 commitmentRoot) view returns((uint64,uint64) clock, bytes32 finalState) -func (_ITournament *ITournamentSession) GetCommitment(commitmentRoot [32]byte) (struct { - Clock ClockState - FinalState [32]byte -}, error) { - return _ITournament.Contract.GetCommitment(&_ITournament.CallOpts, commitmentRoot) +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentSession) CommitmentStanding(commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { + return _ITournament.Contract.CommitmentStanding(&_ITournament.CallOpts, commitmentRoot) } -// GetCommitment is a free data retrieval call binding the contract method 0x7795820c. +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. // -// Solidity: function getCommitment(bytes32 commitmentRoot) view returns((uint64,uint64) clock, bytes32 finalState) -func (_ITournament *ITournamentCallerSession) GetCommitment(commitmentRoot [32]byte) (struct { - Clock ClockState - FinalState [32]byte -}, error) { - return _ITournament.Contract.GetCommitment(&_ITournament.CallOpts, commitmentRoot) +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) CommitmentStanding(commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { + return _ITournament.Contract.CommitmentStanding(&_ITournament.CallOpts, commitmentRoot) } // GetCommitmentJoinedCount is a free data retrieval call binding the contract method 0x2c243a1e. @@ -452,35 +495,35 @@ func (_ITournament *ITournamentCallerSession) GetCommitmentJoinedCount() (*big.I return _ITournament.Contract.GetCommitmentJoinedCount(&_ITournament.CallOpts) } -// GetMatch is a free data retrieval call binding the contract method 0xfcc6077d. +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. // -// Solidity: function getMatch(bytes32 matchIdHash) view returns((bytes32,bytes32,bytes32,uint256,uint64,bool)) -func (_ITournament *ITournamentCaller) GetMatch(opts *bind.CallOpts, matchIdHash [32]byte) (MatchState, error) { +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentCaller) GetLeafMatchSealedCount(opts *bind.CallOpts) (*big.Int, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "getMatch", matchIdHash) + err := _ITournament.contract.Call(opts, &out, "getLeafMatchSealedCount") if err != nil { - return *new(MatchState), err + return *new(*big.Int), err } - out0 := *abi.ConvertType(out[0], new(MatchState)).(*MatchState) + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) return out0, err } -// GetMatch is a free data retrieval call binding the contract method 0xfcc6077d. +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. // -// Solidity: function getMatch(bytes32 matchIdHash) view returns((bytes32,bytes32,bytes32,uint256,uint64,bool)) -func (_ITournament *ITournamentSession) GetMatch(matchIdHash [32]byte) (MatchState, error) { - return _ITournament.Contract.GetMatch(&_ITournament.CallOpts, matchIdHash) +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentSession) GetLeafMatchSealedCount() (*big.Int, error) { + return _ITournament.Contract.GetLeafMatchSealedCount(&_ITournament.CallOpts) } -// GetMatch is a free data retrieval call binding the contract method 0xfcc6077d. +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. // -// Solidity: function getMatch(bytes32 matchIdHash) view returns((bytes32,bytes32,bytes32,uint256,uint64,bool)) -func (_ITournament *ITournamentCallerSession) GetMatch(matchIdHash [32]byte) (MatchState, error) { - return _ITournament.Contract.GetMatch(&_ITournament.CallOpts, matchIdHash) +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentCallerSession) GetLeafMatchSealedCount() (*big.Int, error) { + return _ITournament.Contract.GetLeafMatchSealedCount(&_ITournament.CallOpts) } // GetMatchAdvancedCount is a free data retrieval call binding the contract method 0xf8cb3bd0. @@ -545,37 +588,6 @@ func (_ITournament *ITournamentCallerSession) GetMatchCreatedCount() (*big.Int, return _ITournament.Contract.GetMatchCreatedCount(&_ITournament.CallOpts) } -// GetMatchCycle is a free data retrieval call binding the contract method 0x8acc802d. -// -// Solidity: function getMatchCycle(bytes32 matchIdHash) view returns(uint256) -func (_ITournament *ITournamentCaller) GetMatchCycle(opts *bind.CallOpts, matchIdHash [32]byte) (*big.Int, error) { - var out []interface{} - err := _ITournament.contract.Call(opts, &out, "getMatchCycle", matchIdHash) - - if err != nil { - return *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) - - return out0, err - -} - -// GetMatchCycle is a free data retrieval call binding the contract method 0x8acc802d. -// -// Solidity: function getMatchCycle(bytes32 matchIdHash) view returns(uint256) -func (_ITournament *ITournamentSession) GetMatchCycle(matchIdHash [32]byte) (*big.Int, error) { - return _ITournament.Contract.GetMatchCycle(&_ITournament.CallOpts, matchIdHash) -} - -// GetMatchCycle is a free data retrieval call binding the contract method 0x8acc802d. -// -// Solidity: function getMatchCycle(bytes32 matchIdHash) view returns(uint256) -func (_ITournament *ITournamentCallerSession) GetMatchCycle(matchIdHash [32]byte) (*big.Int, error) { - return _ITournament.Contract.GetMatchCycle(&_ITournament.CallOpts, matchIdHash) -} - // GetMatchDeletedCount is a free data retrieval call binding the contract method 0xd3976945. // // Solidity: function getMatchDeletedCount() view returns(uint256) @@ -638,218 +650,187 @@ func (_ITournament *ITournamentCallerSession) GetNewInnerTournamentCount() (*big return _ITournament.Contract.GetNewInnerTournamentCount(&_ITournament.CallOpts) } -// InnerTournamentWinner is a free data retrieval call binding the contract method 0x5145236f. -// -// Solidity: function innerTournamentWinner() view returns(bool, bytes32, bytes32, (uint64,uint64)) -func (_ITournament *ITournamentCaller) InnerTournamentWinner(opts *bind.CallOpts) (bool, [32]byte, [32]byte, ClockState, error) { - var out []interface{} - err := _ITournament.contract.Call(opts, &out, "innerTournamentWinner") - - if err != nil { - return *new(bool), *new([32]byte), *new([32]byte), *new(ClockState), err - } - - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - out1 := *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) - out2 := *abi.ConvertType(out[2], new([32]byte)).(*[32]byte) - out3 := *abi.ConvertType(out[3], new(ClockState)).(*ClockState) - - return out0, out1, out2, out3, err - -} - -// InnerTournamentWinner is a free data retrieval call binding the contract method 0x5145236f. -// -// Solidity: function innerTournamentWinner() view returns(bool, bytes32, bytes32, (uint64,uint64)) -func (_ITournament *ITournamentSession) InnerTournamentWinner() (bool, [32]byte, [32]byte, ClockState, error) { - return _ITournament.Contract.InnerTournamentWinner(&_ITournament.CallOpts) -} - -// InnerTournamentWinner is a free data retrieval call binding the contract method 0x5145236f. -// -// Solidity: function innerTournamentWinner() view returns(bool, bytes32, bytes32, (uint64,uint64)) -func (_ITournament *ITournamentCallerSession) InnerTournamentWinner() (bool, [32]byte, [32]byte, ClockState, error) { - return _ITournament.Contract.InnerTournamentWinner(&_ITournament.CallOpts) -} - -// IsClosed is a free data retrieval call binding the contract method 0xc2b6b58c. +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. // -// Solidity: function isClosed() view returns(bool) -func (_ITournament *ITournamentCaller) IsClosed(opts *bind.CallOpts) (bool, error) { +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentCaller) InnerResult(opts *bind.CallOpts) (ITournamentInnerResultView, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "isClosed") + err := _ITournament.contract.Call(opts, &out, "innerResult") if err != nil { - return *new(bool), err + return *new(ITournamentInnerResultView), err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + out0 := *abi.ConvertType(out[0], new(ITournamentInnerResultView)).(*ITournamentInnerResultView) return out0, err } -// IsClosed is a free data retrieval call binding the contract method 0xc2b6b58c. +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. // -// Solidity: function isClosed() view returns(bool) -func (_ITournament *ITournamentSession) IsClosed() (bool, error) { - return _ITournament.Contract.IsClosed(&_ITournament.CallOpts) +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentSession) InnerResult() (ITournamentInnerResultView, error) { + return _ITournament.Contract.InnerResult(&_ITournament.CallOpts) } -// IsClosed is a free data retrieval call binding the contract method 0xc2b6b58c. +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. // -// Solidity: function isClosed() view returns(bool) -func (_ITournament *ITournamentCallerSession) IsClosed() (bool, error) { - return _ITournament.Contract.IsClosed(&_ITournament.CallOpts) +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentCallerSession) InnerResult() (ITournamentInnerResultView, error) { + return _ITournament.Contract.InnerResult(&_ITournament.CallOpts) } -// IsFinished is a free data retrieval call binding the contract method 0x7b352962. +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. // -// Solidity: function isFinished() view returns(bool) -func (_ITournament *ITournamentCaller) IsFinished(opts *bind.CallOpts) (bool, error) { +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentCaller) ReadyToSealMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "isFinished") + err := _ITournament.contract.Call(opts, &out, "readyToSealMatch", matchIdHash) + outstruct := new(struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView + }) if err != nil { - return *new(bool), err + return *outstruct, err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentReadyToSealMatchView)).(*ITournamentReadyToSealMatchView) - return out0, err + return *outstruct, err } -// IsFinished is a free data retrieval call binding the contract method 0x7b352962. +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. // -// Solidity: function isFinished() view returns(bool) -func (_ITournament *ITournamentSession) IsFinished() (bool, error) { - return _ITournament.Contract.IsFinished(&_ITournament.CallOpts) +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentSession) ReadyToSealMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { + return _ITournament.Contract.ReadyToSealMatch(&_ITournament.CallOpts, matchIdHash) } -// IsFinished is a free data retrieval call binding the contract method 0x7b352962. +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. // -// Solidity: function isFinished() view returns(bool) -func (_ITournament *ITournamentCallerSession) IsFinished() (bool, error) { - return _ITournament.Contract.IsFinished(&_ITournament.CallOpts) +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentCallerSession) ReadyToSealMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { + return _ITournament.Contract.ReadyToSealMatch(&_ITournament.CallOpts, matchIdHash) } -// TimeFinished is a free data retrieval call binding the contract method 0x39cdfaf2. +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. // -// Solidity: function timeFinished() view returns(bool, uint64) -func (_ITournament *ITournamentCaller) TimeFinished(opts *bind.CallOpts) (bool, uint64, error) { +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentCaller) SealedMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "timeFinished") + err := _ITournament.contract.Call(opts, &out, "sealedMatch", matchIdHash) + outstruct := new(struct { + ActualPhase uint8 + Value ITournamentSealedMatchView + }) if err != nil { - return *new(bool), *new(uint64), err + return *outstruct, err } - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - out1 := *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentSealedMatchView)).(*ITournamentSealedMatchView) - return out0, out1, err + return *outstruct, err } -// TimeFinished is a free data retrieval call binding the contract method 0x39cdfaf2. +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. // -// Solidity: function timeFinished() view returns(bool, uint64) -func (_ITournament *ITournamentSession) TimeFinished() (bool, uint64, error) { - return _ITournament.Contract.TimeFinished(&_ITournament.CallOpts) +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentSession) SealedMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { + return _ITournament.Contract.SealedMatch(&_ITournament.CallOpts, matchIdHash) } -// TimeFinished is a free data retrieval call binding the contract method 0x39cdfaf2. +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. // -// Solidity: function timeFinished() view returns(bool, uint64) -func (_ITournament *ITournamentCallerSession) TimeFinished() (bool, uint64, error) { - return _ITournament.Contract.TimeFinished(&_ITournament.CallOpts) +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentCallerSession) SealedMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { + return _ITournament.Contract.SealedMatch(&_ITournament.CallOpts, matchIdHash) } -// TournamentArguments is a free data retrieval call binding the contract method 0x4b3fbb10. +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. // -// Solidity: function tournamentArguments() view returns(((bytes32,uint256,uint64,uint64),uint64,uint64,uint64,uint64,uint64,uint64,address,(bytes32,bytes32,bytes32,bytes32),address,address)) -func (_ITournament *ITournamentCaller) TournamentArguments(opts *bind.CallOpts) (ITournamentTournamentArguments, error) { +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentCaller) TournamentDescriptor(opts *bind.CallOpts) (ITournamentTournamentDescriptor, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "tournamentArguments") + err := _ITournament.contract.Call(opts, &out, "tournamentDescriptor") if err != nil { - return *new(ITournamentTournamentArguments), err + return *new(ITournamentTournamentDescriptor), err } - out0 := *abi.ConvertType(out[0], new(ITournamentTournamentArguments)).(*ITournamentTournamentArguments) + out0 := *abi.ConvertType(out[0], new(ITournamentTournamentDescriptor)).(*ITournamentTournamentDescriptor) return out0, err } -// TournamentArguments is a free data retrieval call binding the contract method 0x4b3fbb10. +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. // -// Solidity: function tournamentArguments() view returns(((bytes32,uint256,uint64,uint64),uint64,uint64,uint64,uint64,uint64,uint64,address,(bytes32,bytes32,bytes32,bytes32),address,address)) -func (_ITournament *ITournamentSession) TournamentArguments() (ITournamentTournamentArguments, error) { - return _ITournament.Contract.TournamentArguments(&_ITournament.CallOpts) +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentSession) TournamentDescriptor() (ITournamentTournamentDescriptor, error) { + return _ITournament.Contract.TournamentDescriptor(&_ITournament.CallOpts) } -// TournamentArguments is a free data retrieval call binding the contract method 0x4b3fbb10. +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. // -// Solidity: function tournamentArguments() view returns(((bytes32,uint256,uint64,uint64),uint64,uint64,uint64,uint64,uint64,uint64,address,(bytes32,bytes32,bytes32,bytes32),address,address)) -func (_ITournament *ITournamentCallerSession) TournamentArguments() (ITournamentTournamentArguments, error) { - return _ITournament.Contract.TournamentArguments(&_ITournament.CallOpts) +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) TournamentDescriptor() (ITournamentTournamentDescriptor, error) { + return _ITournament.Contract.TournamentDescriptor(&_ITournament.CallOpts) } -// TournamentLevelConstants is a free data retrieval call binding the contract method 0xa1af906b. +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. // -// Solidity: function tournamentLevelConstants() view returns(uint64 maxLevel, uint64 level, uint64 log2step, uint64 height) -func (_ITournament *ITournamentCaller) TournamentLevelConstants(opts *bind.CallOpts) (struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 -}, error) { +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentCaller) TournamentStanding(opts *bind.CallOpts) (ITournamentTournamentStandingView, error) { var out []interface{} - err := _ITournament.contract.Call(opts, &out, "tournamentLevelConstants") + err := _ITournament.contract.Call(opts, &out, "tournamentStanding") - outstruct := new(struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 - }) if err != nil { - return *outstruct, err + return *new(ITournamentTournamentStandingView), err } - outstruct.MaxLevel = *abi.ConvertType(out[0], new(uint64)).(*uint64) - outstruct.Level = *abi.ConvertType(out[1], new(uint64)).(*uint64) - outstruct.Log2step = *abi.ConvertType(out[2], new(uint64)).(*uint64) - outstruct.Height = *abi.ConvertType(out[3], new(uint64)).(*uint64) + out0 := *abi.ConvertType(out[0], new(ITournamentTournamentStandingView)).(*ITournamentTournamentStandingView) - return *outstruct, err + return out0, err } -// TournamentLevelConstants is a free data retrieval call binding the contract method 0xa1af906b. +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. // -// Solidity: function tournamentLevelConstants() view returns(uint64 maxLevel, uint64 level, uint64 log2step, uint64 height) -func (_ITournament *ITournamentSession) TournamentLevelConstants() (struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 -}, error) { - return _ITournament.Contract.TournamentLevelConstants(&_ITournament.CallOpts) +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentSession) TournamentStanding() (ITournamentTournamentStandingView, error) { + return _ITournament.Contract.TournamentStanding(&_ITournament.CallOpts) } -// TournamentLevelConstants is a free data retrieval call binding the contract method 0xa1af906b. +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. // -// Solidity: function tournamentLevelConstants() view returns(uint64 maxLevel, uint64 level, uint64 log2step, uint64 height) -func (_ITournament *ITournamentCallerSession) TournamentLevelConstants() (struct { - MaxLevel uint64 - Level uint64 - Log2step uint64 - Height uint64 -}, error) { - return _ITournament.Contract.TournamentLevelConstants(&_ITournament.CallOpts) +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) TournamentStanding() (ITournamentTournamentStandingView, error) { + return _ITournament.Contract.TournamentStanding(&_ITournament.CallOpts) } // AdvanceMatch is a paid mutator transaction binding the contract method 0xfcc85391. @@ -1062,6 +1043,161 @@ func (_ITournament *ITournamentTransactorSession) WinMatchByTimeout(matchId Matc return _ITournament.Contract.WinMatchByTimeout(&_ITournament.TransactOpts, matchId, leftNode, rightNode) } +// ITournamentBondRecoveredIterator is returned from FilterBondRecovered and is used to iterate over the raw logs and unpacked data for BondRecovered events raised by the ITournament contract. +type ITournamentBondRecoveredIterator struct { + Event *ITournamentBondRecovered // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentBondRecoveredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentBondRecovered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentBondRecovered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentBondRecoveredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentBondRecoveredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentBondRecovered represents a BondRecovered event raised by the ITournament contract. +type ITournamentBondRecovered struct { + Commitment [32]byte + Claimer common.Address + Payment *big.Int + Burned *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterBondRecovered is a free log retrieval operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) FilterBondRecovered(opts *bind.FilterOpts, commitment [][32]byte, claimer []common.Address) (*ITournamentBondRecoveredIterator, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } + var claimerRule []interface{} + for _, claimerItem := range claimer { + claimerRule = append(claimerRule, claimerItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "BondRecovered", commitmentRule, claimerRule) + if err != nil { + return nil, err + } + return &ITournamentBondRecoveredIterator{contract: _ITournament.contract, event: "BondRecovered", logs: logs, sub: sub}, nil +} + +// WatchBondRecovered is a free log subscription operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) WatchBondRecovered(opts *bind.WatchOpts, sink chan<- *ITournamentBondRecovered, commitment [][32]byte, claimer []common.Address) (event.Subscription, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } + var claimerRule []interface{} + for _, claimerItem := range claimer { + claimerRule = append(claimerRule, claimerItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "BondRecovered", commitmentRule, claimerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentBondRecovered) + if err := _ITournament.contract.UnpackLog(event, "BondRecovered", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseBondRecovered is a log parse operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) ParseBondRecovered(log types.Log) (*ITournamentBondRecovered, error) { + event := new(ITournamentBondRecovered) + if err := _ITournament.contract.UnpackLog(event, "BondRecovered", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // ITournamentCommitmentJoinedIterator is returned from FilterCommitmentJoined and is used to iterate over the raw logs and unpacked data for CommitmentJoined events raised by the ITournament contract. type ITournamentCommitmentJoinedIterator struct { Event *ITournamentCommitmentJoined // Event containing the contract specifics and raw log @@ -1139,15 +1275,20 @@ type ITournamentCommitmentJoined struct { // FilterCommitmentJoined is a free log retrieval operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. // -// Solidity: event CommitmentJoined(bytes32 commitment, bytes32 finalStateHash, address indexed submitter) -func (_ITournament *ITournamentFilterer) FilterCommitmentJoined(opts *bind.FilterOpts, submitter []common.Address) (*ITournamentCommitmentJoinedIterator, error) { +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) +func (_ITournament *ITournamentFilterer) FilterCommitmentJoined(opts *bind.FilterOpts, commitment [][32]byte, submitter []common.Address) (*ITournamentCommitmentJoinedIterator, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } var submitterRule []interface{} for _, submitterItem := range submitter { submitterRule = append(submitterRule, submitterItem) } - logs, sub, err := _ITournament.contract.FilterLogs(opts, "CommitmentJoined", submitterRule) + logs, sub, err := _ITournament.contract.FilterLogs(opts, "CommitmentJoined", commitmentRule, submitterRule) if err != nil { return nil, err } @@ -1156,15 +1297,20 @@ func (_ITournament *ITournamentFilterer) FilterCommitmentJoined(opts *bind.Filte // WatchCommitmentJoined is a free log subscription operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. // -// Solidity: event CommitmentJoined(bytes32 commitment, bytes32 finalStateHash, address indexed submitter) -func (_ITournament *ITournamentFilterer) WatchCommitmentJoined(opts *bind.WatchOpts, sink chan<- *ITournamentCommitmentJoined, submitter []common.Address) (event.Subscription, error) { +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) +func (_ITournament *ITournamentFilterer) WatchCommitmentJoined(opts *bind.WatchOpts, sink chan<- *ITournamentCommitmentJoined, commitment [][32]byte, submitter []common.Address) (event.Subscription, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } var submitterRule []interface{} for _, submitterItem := range submitter { submitterRule = append(submitterRule, submitterItem) } - logs, sub, err := _ITournament.contract.WatchLogs(opts, "CommitmentJoined", submitterRule) + logs, sub, err := _ITournament.contract.WatchLogs(opts, "CommitmentJoined", commitmentRule, submitterRule) if err != nil { return nil, err } @@ -1198,7 +1344,7 @@ func (_ITournament *ITournamentFilterer) WatchCommitmentJoined(opts *bind.WatchO // ParseCommitmentJoined is a log parse operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. // -// Solidity: event CommitmentJoined(bytes32 commitment, bytes32 finalStateHash, address indexed submitter) +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) func (_ITournament *ITournamentFilterer) ParseCommitmentJoined(log types.Log) (*ITournamentCommitmentJoined, error) { event := new(ITournamentCommitmentJoined) if err := _ITournament.contract.UnpackLog(event, "CommitmentJoined", log); err != nil { @@ -1208,6 +1354,151 @@ func (_ITournament *ITournamentFilterer) ParseCommitmentJoined(log types.Log) (* return event, nil } +// ITournamentLeafMatchSealedIterator is returned from FilterLeafMatchSealed and is used to iterate over the raw logs and unpacked data for LeafMatchSealed events raised by the ITournament contract. +type ITournamentLeafMatchSealedIterator struct { + Event *ITournamentLeafMatchSealed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentLeafMatchSealedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentLeafMatchSealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentLeafMatchSealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentLeafMatchSealedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentLeafMatchSealedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentLeafMatchSealed represents a LeafMatchSealed event raised by the ITournament contract. +type ITournamentLeafMatchSealed struct { + MatchIdHash [32]byte + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterLeafMatchSealed is a free log retrieval operation binding the contract event 0x304d9235f96c68167f2d1c8f6fbdce133d7d35b4706d00f47b40172cf96bd923. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) FilterLeafMatchSealed(opts *bind.FilterOpts, matchIdHash [][32]byte) (*ITournamentLeafMatchSealedIterator, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "LeafMatchSealed", matchIdHashRule) + if err != nil { + return nil, err + } + return &ITournamentLeafMatchSealedIterator{contract: _ITournament.contract, event: "LeafMatchSealed", logs: logs, sub: sub}, nil +} + +// WatchLeafMatchSealed is a free log subscription operation binding the contract event 0x304d9235f96c68167f2d1c8f6fbdce133d7d35b4706d00f47b40172cf96bd923. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) WatchLeafMatchSealed(opts *bind.WatchOpts, sink chan<- *ITournamentLeafMatchSealed, matchIdHash [][32]byte) (event.Subscription, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "LeafMatchSealed", matchIdHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentLeafMatchSealed) + if err := _ITournament.contract.UnpackLog(event, "LeafMatchSealed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseLeafMatchSealed is a log parse operation binding the contract event 0x304d9235f96c68167f2d1c8f6fbdce133d7d35b4706d00f47b40172cf96bd923. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) ParseLeafMatchSealed(log types.Log) (*ITournamentLeafMatchSealed, error) { + event := new(ITournamentLeafMatchSealed) + if err := _ITournament.contract.UnpackLog(event, "LeafMatchSealed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // ITournamentMatchAdvancedIterator is returned from FilterMatchAdvanced and is used to iterate over the raw logs and unpacked data for MatchAdvanced events raised by the ITournament contract. type ITournamentMatchAdvancedIterator struct { Event *ITournamentMatchAdvanced // Event containing the contract specifics and raw log @@ -1277,15 +1568,17 @@ func (it *ITournamentMatchAdvancedIterator) Close() error { // ITournamentMatchAdvanced represents a MatchAdvanced event raised by the ITournament contract. type ITournamentMatchAdvanced struct { - MatchIdHash [32]byte - OtherParent [32]byte - LeftNode [32]byte - Raw types.Log // Blockchain specific contextual infos + MatchIdHash [32]byte + OtherParent [32]byte + LeftNode [32]byte + SegmentStartPosition *big.Int + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos } -// FilterMatchAdvanced is a free log retrieval operation binding the contract event 0xbc14010e647cf07dd4f48df2f806ec59932be73b1b969e6dff6fa55e805a1cbc. +// FilterMatchAdvanced is a free log retrieval operation binding the contract event 0xb6135d4f6f9687ce2f55ce0fd69c7f9d77e003a036ac8e402828abdd973621b1. // -// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode) +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) FilterMatchAdvanced(opts *bind.FilterOpts, matchIdHash [][32]byte) (*ITournamentMatchAdvancedIterator, error) { var matchIdHashRule []interface{} @@ -1300,9 +1593,9 @@ func (_ITournament *ITournamentFilterer) FilterMatchAdvanced(opts *bind.FilterOp return &ITournamentMatchAdvancedIterator{contract: _ITournament.contract, event: "MatchAdvanced", logs: logs, sub: sub}, nil } -// WatchMatchAdvanced is a free log subscription operation binding the contract event 0xbc14010e647cf07dd4f48df2f806ec59932be73b1b969e6dff6fa55e805a1cbc. +// WatchMatchAdvanced is a free log subscription operation binding the contract event 0xb6135d4f6f9687ce2f55ce0fd69c7f9d77e003a036ac8e402828abdd973621b1. // -// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode) +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) WatchMatchAdvanced(opts *bind.WatchOpts, sink chan<- *ITournamentMatchAdvanced, matchIdHash [][32]byte) (event.Subscription, error) { var matchIdHashRule []interface{} @@ -1342,9 +1635,9 @@ func (_ITournament *ITournamentFilterer) WatchMatchAdvanced(opts *bind.WatchOpts }), nil } -// ParseMatchAdvanced is a log parse operation binding the contract event 0xbc14010e647cf07dd4f48df2f806ec59932be73b1b969e6dff6fa55e805a1cbc. +// ParseMatchAdvanced is a log parse operation binding the contract event 0xb6135d4f6f9687ce2f55ce0fd69c7f9d77e003a036ac8e402828abdd973621b1. // -// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode) +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) ParseMatchAdvanced(log types.Log) (*ITournamentMatchAdvanced, error) { event := new(ITournamentMatchAdvanced) if err := _ITournament.contract.UnpackLog(event, "MatchAdvanced", log); err != nil { @@ -1423,16 +1716,17 @@ func (it *ITournamentMatchCreatedIterator) Close() error { // ITournamentMatchCreated represents a MatchCreated event raised by the ITournament contract. type ITournamentMatchCreated struct { - MatchIdHash [32]byte - One [32]byte - Two [32]byte - LeftOfTwo [32]byte - Raw types.Log // Blockchain specific contextual infos + MatchIdHash [32]byte + One [32]byte + Two [32]byte + LeftOfTwo [32]byte + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos } -// FilterMatchCreated is a free log retrieval operation binding the contract event 0xbaea19df0c2b83760acad299eaf042b77e11e0f362ce10d0d4bb24b09fa5296d. +// FilterMatchCreated is a free log retrieval operation binding the contract event 0x6257204c2340779874d2f4e6f94db385ead25b228b1660535f7cbc81b492ac1e. // -// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo) +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) FilterMatchCreated(opts *bind.FilterOpts, matchIdHash [][32]byte, one [][32]byte, two [][32]byte) (*ITournamentMatchCreatedIterator, error) { var matchIdHashRule []interface{} @@ -1455,9 +1749,9 @@ func (_ITournament *ITournamentFilterer) FilterMatchCreated(opts *bind.FilterOpt return &ITournamentMatchCreatedIterator{contract: _ITournament.contract, event: "MatchCreated", logs: logs, sub: sub}, nil } -// WatchMatchCreated is a free log subscription operation binding the contract event 0xbaea19df0c2b83760acad299eaf042b77e11e0f362ce10d0d4bb24b09fa5296d. +// WatchMatchCreated is a free log subscription operation binding the contract event 0x6257204c2340779874d2f4e6f94db385ead25b228b1660535f7cbc81b492ac1e. // -// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo) +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) WatchMatchCreated(opts *bind.WatchOpts, sink chan<- *ITournamentMatchCreated, matchIdHash [][32]byte, one [][32]byte, two [][32]byte) (event.Subscription, error) { var matchIdHashRule []interface{} @@ -1505,9 +1799,9 @@ func (_ITournament *ITournamentFilterer) WatchMatchCreated(opts *bind.WatchOpts, }), nil } -// ParseMatchCreated is a log parse operation binding the contract event 0xbaea19df0c2b83760acad299eaf042b77e11e0f362ce10d0d4bb24b09fa5296d. +// ParseMatchCreated is a log parse operation binding the contract event 0x6257204c2340779874d2f4e6f94db385ead25b228b1660535f7cbc81b492ac1e. // -// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo) +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 eliminableAt) func (_ITournament *ITournamentFilterer) ParseMatchCreated(log types.Log) (*ITournamentMatchCreated, error) { event := new(ITournamentMatchCreated) if err := _ITournament.contract.UnpackLog(event, "MatchCreated", log); err != nil { @@ -1906,13 +2200,12 @@ type ITournamentPartialBondRefund struct { Recipient common.Address Value *big.Int Success bool - Ret []byte Raw types.Log // Blockchain specific contextual infos } -// FilterPartialBondRefund is a free log retrieval operation binding the contract event 0x938a52b87ed1353360e17d203a73343c4e92b6a9e9a0b50d0e38df31fbf14219. +// FilterPartialBondRefund is a free log retrieval operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. // -// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success, bytes ret) +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) func (_ITournament *ITournamentFilterer) FilterPartialBondRefund(opts *bind.FilterOpts, recipient []common.Address, success []bool) (*ITournamentPartialBondRefundIterator, error) { var recipientRule []interface{} @@ -1932,9 +2225,9 @@ func (_ITournament *ITournamentFilterer) FilterPartialBondRefund(opts *bind.Filt return &ITournamentPartialBondRefundIterator{contract: _ITournament.contract, event: "PartialBondRefund", logs: logs, sub: sub}, nil } -// WatchPartialBondRefund is a free log subscription operation binding the contract event 0x938a52b87ed1353360e17d203a73343c4e92b6a9e9a0b50d0e38df31fbf14219. +// WatchPartialBondRefund is a free log subscription operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. // -// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success, bytes ret) +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) func (_ITournament *ITournamentFilterer) WatchPartialBondRefund(opts *bind.WatchOpts, sink chan<- *ITournamentPartialBondRefund, recipient []common.Address, success []bool) (event.Subscription, error) { var recipientRule []interface{} @@ -1979,9 +2272,9 @@ func (_ITournament *ITournamentFilterer) WatchPartialBondRefund(opts *bind.Watch }), nil } -// ParsePartialBondRefund is a log parse operation binding the contract event 0x938a52b87ed1353360e17d203a73343c4e92b6a9e9a0b50d0e38df31fbf14219. +// ParsePartialBondRefund is a log parse operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. // -// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success, bytes ret) +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) func (_ITournament *ITournamentFilterer) ParsePartialBondRefund(log types.Log) (*ITournamentPartialBondRefund, error) { event := new(ITournamentPartialBondRefund) if err := _ITournament.contract.UnpackLog(event, "PartialBondRefund", log); err != nil { diff --git a/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go b/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go index 4093256fa..bf5504b5c 100644 --- a/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go +++ b/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go @@ -31,7 +31,7 @@ var ( // IUsdWithdrawalOutputBuilderMetaData contains all meta data concerning the IUsdWithdrawalOutputBuilder contract. var IUsdWithdrawalOutputBuilderMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"buildWithdrawalOutput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"token\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIERC20\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"AccountTooShort\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minAccountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]}]", + ABI: "[{\"type\":\"function\",\"name\":\"buildWithdrawalOutput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"token\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIERC20\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"InvalidAccountSize\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"accountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]}]", } // IUsdWithdrawalOutputBuilderABI is the input ABI used to generate the binding from. diff --git a/pkg/ethutil/anvil.go b/pkg/ethutil/anvil.go index d07e31177..c8e2a7e81 100644 --- a/pkg/ethutil/anvil.go +++ b/pkg/ethutil/anvil.go @@ -8,14 +8,22 @@ import ( "fmt" "log" - "github.com/cartesi/rollups-node/internal/config" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" ) -func CreateAnvilSnapshotAndDeployApp(ctx context.Context, client *ethclient.Client, factoryAddr common.Address, templateHash common.Hash, dataAvailability []byte, salt string) (common.Address, func(), error) { +const anvilTestEpochLength uint64 = 10 + +func CreateAnvilSnapshotAndDeployApp( + ctx context.Context, + client *ethclient.Client, + factoryAddr common.Address, + templateHash common.Hash, + inputBoxAddress common.Address, + salt string, +) (common.Address, func(), error) { zero := common.Address{} if client == nil { return zero, nil, fmt.Errorf("ethclient Client is nil") @@ -45,21 +53,19 @@ func CreateAnvilSnapshotAndDeployApp(ctx context.Context, client *ethclient.Clie return zero, nil, fmt.Errorf("failed to create TransactOpts: %w", err) } - // build the self hosted deployment struct - selfHostedApplicationFactoryAddress, err := config.GetContractsSelfHostedApplicationFactoryAddress() + parsedSalt, err := ParseSalt(salt) if err != nil { _ = RevertToAnvilSnapshot(client.Client(), snapshotID) - return zero, nil, fmt.Errorf("failed to retrieve self hosted application factory address: %w", err) + return zero, nil, fmt.Errorf("failed to parse salt: %w", err) } deployment := &SelfhostedApplicationDeployment{ - FactoryAddress: selfHostedApplicationFactoryAddress, - ApplicationOwnerAddress: txOpts.From, - AuthorityOwnerAddress: txOpts.From, - TemplateHash: templateHash, - DataAvailability: dataAvailability, - EpochLength: 10, - Salt: [32]byte{}, + FactoryAddress: factoryAddr, + AuthorityOwnerAddress: txOpts.From, + TemplateHash: templateHash, + InputBoxAddress: inputBoxAddress, + EpochLength: anvilTestEpochLength, + Salt: parsedSalt, } applicationAddress, _, err := deployment.Deploy(ctx, client, NewStaticTransactOptsFactory(txOpts)) if err != nil { diff --git a/pkg/ethutil/application.go b/pkg/ethutil/application.go index 3e0e7cc5b..19f13a13f 100644 --- a/pkg/ethutil/application.go +++ b/pkg/ethutil/application.go @@ -4,17 +4,20 @@ package ethutil import ( "context" - "encoding/hex" "fmt" "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) type IApplicationDeployment interface { - Deploy(ctx context.Context, client *ethclient.Client, txOptsFactory TransactOptsFactory) (common.Address, IApplicationDeploymentResult, error) + Deploy(context.Context, *ethclient.Client, TransactOptsFactory) (common.Address, IApplicationDeploymentResult, error) + DeployWithTransaction( + context.Context, *ethclient.Client, TransactOptsFactory, TransactionRunner, + ) (common.Address, IApplicationDeploymentResult, error) GetFactoryAddress() common.Address } type IApplicationDeploymentResult interface{} @@ -23,7 +26,6 @@ type ApplicationDeployment struct { FactoryAddress common.Address `json:"factory"` Consensus common.Address `json:"consensus"` OwnerAddress common.Address `json:"owner"` - DataAvailability []byte `json:"-"` TemplateHash common.Hash `json:"template_hash"` WithdrawalConfig iapplicationfactory.WithdrawalConfig `json:"withdrawal_config"` Salt SaltBytes `json:"salt"` @@ -46,13 +48,13 @@ type ApplicationDeploymentResult struct { func (me *ApplicationDeployment) String() string { result := "" - result += fmt.Sprintf("application deployment:\n") + result += "application deployment:\n" result += fmt.Sprintf("\tapplication owner: %v\n", me.OwnerAddress) result += fmt.Sprintf("\tconsensus address: %v\n", me.Consensus) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) if me.Verbose { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) - result += fmt.Sprintf("\tdata availability: 0x%v\n", hex.EncodeToString(me.DataAvailability)) result += fmt.Sprintf("\tsalt: %v\n", me.Salt) result += fmt.Sprintf("\tepoch length: %v\n", me.EpochLength) if me.ConsensusType != "" { @@ -72,6 +74,18 @@ func (me *ApplicationDeployment) Deploy( ctx context.Context, client *ethclient.Client, txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction uses runner to submit the deployment. A broadcast-only +// runner returns the predicted address and no confirmed deployment result. +// The predicted address does not prove that the deployment transaction succeeded. +func (me *ApplicationDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, + runner TransactionRunner, ) (common.Address, IApplicationDeploymentResult, error) { zero := common.Address{} result := &ApplicationDeploymentResult{} @@ -89,7 +103,15 @@ func (me *ApplicationDeployment) Deploy( } // check if addresses are available (have no code) - applicationAddress, err := factory.CalculateApplicationAddress(nil, me.Consensus, me.OwnerAddress, me.TemplateHash, me.DataAvailability, me.WithdrawalConfig, me.Salt) + applicationAddress, err := factory.CalculateApplicationAddress( + &bind.CallOpts{Context: ctx}, + me.Consensus, + me.OwnerAddress, + me.TemplateHash, + me.InputBoxAddress, + me.WithdrawalConfig, + me.Salt, + ) if err != nil { return zero, nil, err } @@ -99,7 +121,7 @@ func (me *ApplicationDeployment) Deploy( return zero, nil, err } if len(applicationCode) != 0 { - return zero, nil, fmt.Errorf("application with address: %v already exists. Try a different salt.", applicationAddress) + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", applicationAddress) } // deploy the contracts @@ -108,18 +130,23 @@ func (me *ApplicationDeployment) Deploy( return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) } - tx, err := factory.NewApplication0(txOpts, me.Consensus, me.OwnerAddress, me.TemplateHash, me.DataAvailability, me.WithdrawalConfig, me.Salt) + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewApplication0( + opts, + me.Consensus, + me.OwnerAddress, + me.TemplateHash, + me.InputBoxAddress, + me.WithdrawalConfig, + me.Salt, + ) + }, runner) if err != nil { return zero, nil, fmt.Errorf("transaction failed: %w", err) } - receipt, err := bind.WaitMined(ctx, client, tx) - if err != nil { - return zero, nil, fmt.Errorf("failed to wait for transaction mining: %w", err) - } - - if receipt.Status != 1 { - return zero, nil, fmt.Errorf("transaction failed") + if receipt == nil { + return applicationAddress, nil, nil } // Look for the specific event in the receipt logs diff --git a/pkg/ethutil/authority.go b/pkg/ethutil/authority.go index 228d24596..b5139418f 100644 --- a/pkg/ethutil/authority.go +++ b/pkg/ethutil/authority.go @@ -10,6 +10,7 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) @@ -25,7 +26,7 @@ type AuthorityDeployment struct { func (me *AuthorityDeployment) String() string { result := "" - result += fmt.Sprintf("authority deployment:\n") + result += "authority deployment:\n" result += fmt.Sprintf("\tauthority owner: %v\n", me.OwnerAddress) if me.Verbose { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) @@ -40,6 +41,17 @@ func (me *AuthorityDeployment) Deploy( ctx context.Context, client *ethclient.Client, txOpts *bind.TransactOpts, +) (common.Address, error) { + return me.DeployWithTransaction(ctx, client, txOpts, nil) +} + +// DeployWithTransaction returns a predicted address when runner does not wait for +// a receipt. The prediction does not prove that the deployment transaction succeeded. +func (me *AuthorityDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, + runner TransactionRunner, ) (common.Address, error) { zero := common.Address{} factory, err := iauthorityfactory.NewIAuthorityFactory(me.FactoryAddress, client) @@ -48,7 +60,11 @@ func (me *AuthorityDeployment) Deploy( } // check if addresses are available (have no code) - authorityAddress, err := factory.CalculateAuthorityAddress(nil, me.OwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), me.Salt) + epochLength := new(big.Int).SetUint64(me.EpochLength) + claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) + authorityAddress, err := factory.CalculateAuthorityAddress( + &bind.CallOpts{Context: ctx}, me.OwnerAddress, epochLength, claimStagingPeriod, me.Salt, + ) if err != nil { return zero, err } @@ -58,22 +74,19 @@ func (me *AuthorityDeployment) Deploy( return zero, err } if len(authorityCode) != 0 { - return zero, fmt.Errorf("authority with address: %v already exists. Try a different salt.", authorityAddress) + return zero, fmt.Errorf("authority with address %v already exists; use a different salt", authorityAddress) } // deploy the contracts - tx, err := factory.NewAuthority0(txOpts, me.OwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), me.Salt) + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewAuthority0(opts, me.OwnerAddress, epochLength, claimStagingPeriod, me.Salt) + }, runner) if err != nil { return common.Address{}, fmt.Errorf("failed to create new authority: %w", err) } - receipt, err := bind.WaitMined(ctx, client, tx) - if err != nil { - return common.Address{}, fmt.Errorf("failed to mine new authority transaction: %w", err) - } - - if receipt.Status != 1 { - return common.Address{}, fmt.Errorf("transaction failed") + if receipt == nil { + return authorityAddress, nil } // search for the matching event diff --git a/pkg/ethutil/client.go b/pkg/ethutil/client.go index 62d45db5c..89bda63e9 100644 --- a/pkg/ethutil/client.go +++ b/pkg/ethutil/client.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" "github.com/hashicorp/go-retryablehttp" @@ -147,6 +148,18 @@ func newRedactedLogger(logger *slog.Logger, endpoint string) *redactedLeveledLog } func (l *redactedLeveledLogger) Error(msg string, keysAndValues ...any) { + // retryablehttp logs canceled requests before returning their error to the + // caller. Cancellation is not a transport failure. Keep the diagnostic at + // Debug; the caller still receives the error and applies its own policy. + // Classify before redaction, which can turn an error into a plain string. + for i := 0; i+1 < len(keysAndValues); i += 2 { + if keysAndValues[i] == "error" { + if err, ok := keysAndValues[i+1].(error); ok && errutil.IsOnlyCancellation(err) { + l.Debug(msg, keysAndValues...) + return + } + } + } l.logger.Error(l.redactString(msg), l.redactValues(keysAndValues)...) } diff --git a/pkg/ethutil/client_cancellation_test.go b/pkg/ethutil/client_cancellation_test.go new file mode 100644 index 000000000..7ff3195b9 --- /dev/null +++ b/pkg/ethutil/client_cancellation_test.go @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestHTTPFailureLogLevel(t *testing.T) { + transportErr := errors.New("connection reset") + for _, test := range []struct { + name string + err error + level slog.Level + }{ + {name: "cancellation", err: context.Canceled, level: slog.LevelDebug}, + {name: "wrapped cancellation", err: fmt.Errorf("request: %w", context.Canceled), level: slog.LevelDebug}, + {name: "joined cancellations", err: errors.Join(context.Canceled, context.Canceled), level: slog.LevelDebug}, + {name: "deadline", err: context.DeadlineExceeded, level: slog.LevelError}, + {name: "transport failure", err: transportErr, level: slog.LevelError}, + {name: "cancellation text is not a cause", err: errors.New("context canceled"), level: slog.LevelError}, + {name: "mixed failure", err: errors.Join(context.Canceled, transportErr), level: slog.LevelError}, + {name: "mixed deadline", err: errors.Join(context.Canceled, context.DeadlineExceeded), level: slog.LevelError}, + } { + t.Run(test.name, func(t *testing.T) { + const endpoint = "https://rpc.example.test/secret-key" + var logs bytes.Buffer + logger := newRedactedLogger(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})), endpoint) + logger.Error("request failed", "url", endpoint, "error", &url.Error{Op: "Post", URL: endpoint, Err: test.err}) + require.Contains(t, logs.String(), `"level":"`+test.level.String()+`"`) + require.Equal(t, 1, strings.Count(logs.String(), `"msg":"request failed"`)) + require.Contains(t, logs.String(), "https://rpc.example.test") + require.NotContains(t, logs.String(), "secret-key") + if errors.Is(test.err, transportErr) { + require.Contains(t, logs.String(), transportErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs.String(), context.DeadlineExceeded.Error()) + } + }) + } +} + +func TestNewEthClientCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + cancel() + <-r.Context().Done() + })) + defer server.Close() + + var logs bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + // The request context, not the context used to construct the client, is canceled. + client, err := NewEthClient(t.Context(), server.URL, logger, RetryConfig{RequestTimeout: time.Second}) + require.NoError(t, err) + defer client.Close() + _, err = client.ChainID(ctx) + require.ErrorIs(t, err, context.Canceled, "cancellation must still reach the caller") + require.NotContains(t, logs.String(), `"level":"ERROR"`) + require.Contains(t, logs.String(), `"msg":"request failed"`) + require.Contains(t, logs.String(), context.Canceled.Error()) +} diff --git a/pkg/ethutil/data-availability.go b/pkg/ethutil/data-availability.go deleted file mode 100644 index 442c42dff..000000000 --- a/pkg/ethutil/data-availability.go +++ /dev/null @@ -1,104 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) -package ethutil - -import ( - "encoding/hex" - "fmt" - "strings" - - "github.com/cartesi/rollups-node/internal/model" - "github.com/cartesi/rollups-node/pkg/contracts/dataavailability" - "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" -) - -func DefaultDA(client *ethclient.Client, inputBoxAddress common.Address) (common.Address, uint64, []byte, error) { - parsedABI, err := dataavailability.DataAvailabilityMetaData.GetAbi() - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("failed to get data availability ABI: %w", err) - } - - encodedDA, err := parsedABI.Pack("InputBox", inputBoxAddress) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("failed pack input box data availability string with: %w", err) - } - - inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("failed to create input box instance: %w", err) - } - - inputBoxBlock, err := inputBox.GetDeploymentBlockNumber(nil) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("failed to get deployment block number: %w", err) - } - - return inputBoxAddress, inputBoxBlock.Uint64(), encodedDA, nil -} - -func CustomDA(client *ethclient.Client, dataAvailability string) (common.Address, uint64, []byte, error) { - if len(dataAvailability) < 3 || (!strings.HasPrefix(dataAvailability, "0x") && !strings.HasPrefix(dataAvailability, "0X")) { - return common.Address{}, 0, nil, fmt.Errorf("data Availability should be an ABI encoded value") - } - - s := dataAvailability[2:] - encodedDA, err := hex.DecodeString(s) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("error parsing Data Availability value: %w", err) - } - - inputBoxAddress, inputBoxBlock, err := DecodeDA(client, encodedDA) - if err != nil { - return common.Address{}, 0, nil, fmt.Errorf("error decoding Data Availability value: %w", err) - } - - return inputBoxAddress, inputBoxBlock, encodedDA, nil - -} - -func DecodeDA(client *ethclient.Client, encodedDA []byte) (common.Address, uint64, error) { - parsedAbi, err := dataavailability.DataAvailabilityMetaData.GetAbi() - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to get ABI: %w", err) - } - - if len(encodedDA) < model.DATA_AVAILABILITY_SELECTOR_SIZE { - return common.Address{}, 0, fmt.Errorf("invalid Data Availability") - } - - method, err := parsedAbi.MethodById(encodedDA[:model.DATA_AVAILABILITY_SELECTOR_SIZE]) - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to get method by ID: %w", err) - } - - args, err := method.Inputs.Unpack(encodedDA[model.DATA_AVAILABILITY_SELECTOR_SIZE:]) - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to unpack inputs: %w", err) - } - - if len(args) == 0 { - return common.Address{}, 0, fmt.Errorf("invalid Data Availability. Should at least contain InputBox Address") - } - - var inputBoxAddress common.Address - switch addr := args[0].(type) { - case common.Address: - inputBoxAddress = addr - default: - return common.Address{}, 0, fmt.Errorf("first argument in Data Availability is not an address (got %T)", args[0]) - } - - inputbox, err := iinputbox.NewIInputBox(inputBoxAddress, client) - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to create input box instance: %w", err) - } - - inputBoxBlock, err := inputbox.GetDeploymentBlockNumber(nil) - if err != nil { - return common.Address{}, 0, fmt.Errorf("failed to get deployment block number: %w", err) - } - - return inputBoxAddress, inputBoxBlock.Uint64(), nil -} diff --git a/pkg/ethutil/deployment_transaction.go b/pkg/ethutil/deployment_transaction.go new file mode 100644 index 000000000..08a5403d2 --- /dev/null +++ b/pkg/ethutil/deployment_transaction.go @@ -0,0 +1,57 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +// TransactionRunner controls transaction submission and receipt waiting. A nil +// receipt with no error means broadcast-only: the deployment is not yet confirmed. +type TransactionRunner func( + context.Context, + *bind.TransactOpts, + func(*bind.TransactOpts) (*types.Transaction, error), +) (*types.Receipt, error) + +func runDeploymentTransaction( + ctx context.Context, + client *ethclient.Client, + opts *bind.TransactOpts, + build func(*bind.TransactOpts) (*types.Transaction, error), + runner TransactionRunner, +) (*types.Receipt, error) { + var receipt *types.Receipt + var err error + if runner != nil { + receipt, err = runner(ctx, opts, build) + } else { + prepared := *opts + prepared.Context = ctx + prepared.NoSend = true + tx, buildErr := build(&prepared) + if buildErr != nil { + return nil, fmt.Errorf("failed to prepare deployment transaction: %w", buildErr) + } + if err := client.SendTransaction(ctx, tx); err != nil { + return nil, fmt.Errorf("failed to broadcast deployment transaction %s: %w", tx.Hash(), err) + } + receipt, err = bind.WaitMined(ctx, client, tx) + if err != nil { + return nil, fmt.Errorf("failed to wait for deployment transaction %s; outcome is unknown: %w", tx.Hash(), err) + } + } + if err != nil { + return nil, err + } + if receipt != nil && receipt.Status != types.ReceiptStatusSuccessful { + return nil, fmt.Errorf("deployment transaction %s failed", receipt.TxHash) + } + return receipt, nil +} diff --git a/pkg/ethutil/deployment_transaction_test.go b/pkg/ethutil/deployment_transaction_test.go new file mode 100644 index 000000000..abda763c2 --- /dev/null +++ b/pkg/ethutil/deployment_transaction_test.go @@ -0,0 +1,284 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iselfhostedapplicationfactory" +) + +func TestDeploymentsHandleBroadcastOnlyAndFailedReceipts(t *testing.T) { + appAddress := common.HexToAddress("0x01") + consensusAddress := common.HexToAddress("0x02") + factoryAddress := common.HexToAddress("0x03") + app := &ApplicationDeployment{FactoryAddress: factoryAddress} + selfhosted := &SelfhostedApplicationDeployment{FactoryAddress: factoryAddress} + prt := &PRTApplicationDeployment{ApplicationDeployment: ApplicationDeployment{FactoryAddress: factoryAddress}} + authority := &AuthorityDeployment{FactoryAddress: factoryAddress} + quorum := &QuorumDeployment{FactoryAddress: factoryAddress} + + for _, variant := range []struct { + name string + metadata *bind.MetaData + method string + outputs []any + deploy func(context.Context, *ethclient.Client, *bind.TransactOpts, TransactionRunner) (common.Address, error) + }{ + { + name: "application", metadata: iapplicationfactory.IApplicationFactoryMetaData, + method: "calculateApplicationAddress", outputs: []any{appAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := app.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "selfhosted", metadata: iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, + method: "calculateAddresses", outputs: []any{appAddress, consensusAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := selfhosted.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "prt", metadata: idaveappfactory.IDaveAppFactoryMetaData, + method: "calculateDaveAppAddress", outputs: []any{appAddress, consensusAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := prt.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "authority", metadata: iauthorityfactory.IAuthorityFactoryMetaData, + method: "calculateAuthorityAddress", outputs: []any{consensusAddress}, deploy: authority.DeployWithTransaction, + }, + { + name: "quorum", metadata: iquorumfactory.IQuorumFactoryMetaData, + method: "calculateQuorumAddress", outputs: []any{consensusAddress}, deploy: quorum.DeployWithTransaction, + }, + } { + t.Run(variant.name, func(t *testing.T) { + for _, noWait := range []bool{true, false} { + t.Run(fmt.Sprintf("no_wait_%t", noWait), func(t *testing.T) { + parsed, err := variant.metadata.GetAbi() + require.NoError(t, err) + method := parsed.Methods[variant.method] + data, err := method.Outputs.Pack(variant.outputs...) + require.NoError(t, err) + backend := &deploymentCallBackend{responses: map[string]hexutil.Bytes{string(method.ID): data}} + if variant.name == "selfhosted" { + for _, name := range []string{"getApplicationFactory", "getAuthorityFactory"} { + getter := parsed.Methods[name] + getterData, err := getter.Outputs.Pack(factoryAddress) + require.NoError(t, err) + backend.responses[string(getter.ID)] = getterData + } + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + opts := &bind.TransactOpts{ + From: common.HexToAddress("0x04"), Nonce: big.NewInt(0), + GasPrice: big.NewInt(1), GasLimit: 1_000_000, NoSend: true, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + called := false + var hash common.Hash + address, err := variant.deploy(t.Context(), client, opts, + func( + ctx context.Context, txOpts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + called = true + require.Equal(t, t.Context(), ctx) + tx, err := build(txOpts) + require.NoError(t, err) + hash = tx.Hash() + if noWait { + return nil, nil + } + return &types.Receipt{Status: types.ReceiptStatusFailed, TxHash: hash}, nil + }) + require.True(t, called) + if noWait { + require.NoError(t, err) + require.Equal(t, variant.outputs[0], address, "return the primary address computed before broadcast") + } else { + require.Zero(t, address) + require.ErrorContains(t, err, hash.Hex()) + require.ErrorContains(t, err, "failed") + } + }) + } + }) + } +} + +type deploymentCallBackend struct { + responses map[string]hexutil.Bytes +} + +func (b *deploymentCallBackend) Call(_ context.Context, call prtFactoryCall, _ string) (hexutil.Bytes, error) { + if len(call.Input) >= 4 { + if response, found := b.responses[string(call.Input[:4])]; found { + return response, nil + } + } + return nil, fmt.Errorf("unexpected contract call: %x", call.Input) +} + +func (*deploymentCallBackend) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{}, nil +} + +func TestDefaultDeploymentRunnerKeepsHashAndChecksReceipt(t *testing.T) { + tx := types.NewTx(&types.LegacyTx{Nonce: 7, Gas: 12345, GasPrice: big.NewInt(1)}) + for _, test := range []struct { + name string + sendError error + receiptError error + status uint64 + wantError string + }{ + {name: "mined", status: types.ReceiptStatusSuccessful}, + {name: "reverted", status: types.ReceiptStatusFailed, wantError: "failed"}, + {name: "broadcast error", sendError: errors.New("connection closed"), wantError: "failed to broadcast"}, + {name: "wait timeout", receiptError: errors.New("provider unavailable"), wantError: "outcome is unknown"}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &deploymentReceiptBackend{ + sendError: test.sendError, receiptError: test.receiptError, + receipt: &types.Receipt{ + TxHash: tx.Hash(), Status: test.status, BlockNumber: big.NewInt(1), Logs: []*types.Log{}, + }, + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + opts := &bind.TransactOpts{GasLimit: 12345} + receipt, err := runDeploymentTransaction(ctx, client, opts, func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.True(t, prepared.NoSend) + require.Equal(t, opts.GasLimit, prepared.GasLimit) + require.Equal(t, ctx, prepared.Context) + return tx, nil + }, nil) + require.False(t, opts.NoSend, "the caller's options must remain unchanged") + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Nil(t, receipt) + } else { + require.NoError(t, err) + require.Equal(t, tx.Hash(), receipt.TxHash) + } + }) + } +} + +type deploymentReceiptBackend struct { + sendError error + receiptError error + receipt *types.Receipt +} + +func (b *deploymentReceiptBackend) SendRawTransaction(context.Context, hexutil.Bytes) (common.Hash, error) { + return b.receipt.TxHash, b.sendError +} + +func (b *deploymentReceiptBackend) GetTransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) { + return b.receipt, b.receiptError +} + +func TestSelfhostedDefaultPreservesTransactionPreparation(t *testing.T) { + parsed, err := iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData.GetAbi() + require.NoError(t, err) + backend := &selfhostedTransactionBackend{deploymentCallBackend: &deploymentCallBackend{responses: map[string]hexutil.Bytes{}}} + for _, name := range []string{"calculateAddresses", "getApplicationFactory", "getAuthorityFactory"} { + method := parsed.Methods[name] + outputs := []any{common.HexToAddress("0x01")} + if name == "calculateAddresses" { + outputs = append(outputs, common.HexToAddress("0x02")) + } + data, err := method.Outputs.Pack(outputs...) + require.NoError(t, err) + backend.responses[string(method.ID)] = data + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + opts := &bind.TransactOpts{ + From: common.HexToAddress("0x03"), Nonce: big.NewInt(99), GasPrice: big.NewInt(88), Value: big.NewInt(77), GasLimit: 12345, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + deployment := &SelfhostedApplicationDeployment{FactoryAddress: common.HexToAddress("0x04")} + _, _, err = deployment.Deploy(t.Context(), client, NewStaticTransactOptsFactory(opts)) + require.ErrorContains(t, err, "stop after transaction preparation") + require.NotNil(t, backend.tx) + require.Equal(t, uint64(7), backend.tx.Nonce()) + require.Equal(t, big.NewInt(42), backend.tx.GasPrice()) + require.Zero(t, backend.tx.Value().Sign()) + require.Equal(t, opts.GasLimit, backend.tx.Gas()) + require.Equal(t, big.NewInt(99), opts.Nonce, "the original caller options are not changed") +} + +type selfhostedTransactionBackend struct { + *deploymentCallBackend + tx *types.Transaction +} + +func (*selfhostedTransactionBackend) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 7 +} + +func (*selfhostedTransactionBackend) GasPrice(context.Context) hexutil.Uint64 { + return 42 +} + +func (b *selfhostedTransactionBackend) SendRawTransaction(_ context.Context, data hexutil.Bytes) (common.Hash, error) { + b.tx = new(types.Transaction) + if err := b.tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + return b.tx.Hash(), fmt.Errorf("stop after transaction preparation") +} diff --git a/pkg/ethutil/ethutil.go b/pkg/ethutil/ethutil.go index 5d31f7e67..d53d818c1 100644 --- a/pkg/ethutil/ethutil.go +++ b/pkg/ethutil/ethutil.go @@ -280,23 +280,23 @@ func GetConsensusAt( return consensus, nil } -func GetDataAvailability( +func GetInputBox( ctx context.Context, client *ethclient.Client, appAddress common.Address, -) ([]byte, error) { +) (common.Address, error) { if client == nil { - return nil, fmt.Errorf("get dataAvailability: client is nil") + return common.Address{}, fmt.Errorf("get input box: client is nil") } app, err := iapplication.NewIApplication(appAddress, client) if err != nil { - return nil, fmt.Errorf("Failed to instantiate contract: %w", err) + return common.Address{}, fmt.Errorf("failed to instantiate contract: %w", err) } - dataAvailability, err := app.GetDataAvailability(&bind.CallOpts{Context: ctx}) + inputBox, err := app.GetInputBox(&bind.CallOpts{Context: ctx}) if err != nil { - return nil, fmt.Errorf("error retrieving application epoch length: %w", err) + return common.Address{}, fmt.Errorf("error retrieving application input box: %w", err) } - return dataAvailability, nil + return inputBox, nil } func GetEpochLength( diff --git a/pkg/ethutil/ethutil_test.go b/pkg/ethutil/ethutil_test.go index cf9ddebb6..4024f927a 100644 --- a/pkg/ethutil/ethutil_test.go +++ b/pkg/ethutil/ethutil_test.go @@ -10,6 +10,7 @@ import ( "math/big" "os" "sync" + "sync/atomic" "testing" "time" @@ -73,9 +74,15 @@ func (s *EthUtilSuite) SetupTest() { s.inputBoxAddr, err = config.GetContractsInputBoxAddress() s.Require().Nil(err) - _, _, encodedDA, err := DefaultDA(s.client, s.inputBoxAddr) salt := "0000000000000000000000000000000000000000000000000000000000000000" - s.appAddr, s.cleanup, err = CreateAnvilSnapshotAndDeployApp(s.ctx, s.client, s.selfHostedAppFactory, templateHash, encodedDA, salt) + s.appAddr, s.cleanup, err = CreateAnvilSnapshotAndDeployApp( + s.ctx, + s.client, + s.selfHostedAppFactory, + templateHash, + s.inputBoxAddr, + salt, + ) s.Require().Nil(err) } @@ -177,11 +184,11 @@ func TestAddInputAsyncUsesContextForBindingTransaction(t *testing.T) { ) require.ErrorIs(t, err, context.DeadlineExceeded) - require.True(t, backend.estimateGasCalled, "expected AddInputAsync to reach the binding gas-estimation boundary") + require.True(t, backend.estimateGasCalled.Load(), "expected AddInputAsync to reach the binding gas-estimation boundary") } type addInputAsyncContextBackend struct { - estimateGasCalled bool + estimateGasCalled atomic.Bool estimateGasTimeout time.Duration } @@ -209,7 +216,7 @@ func (b *addInputAsyncContextBackend) EstimateGas( ctx context.Context, _ map[string]interface{}, ) (hexutil.Uint64, error) { - b.estimateGasCalled = true + b.estimateGasCalled.Store(true) select { case <-ctx.Done(): return 0, ctx.Err() diff --git a/pkg/ethutil/legacy_fees.go b/pkg/ethutil/legacy_fees.go new file mode 100644 index 000000000..912d7fca6 --- /dev/null +++ b/pkg/ethutil/legacy_fees.go @@ -0,0 +1,45 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +type legacyFeeFactory struct { + TransactOptsFactory + client ethereum.GasPricer +} + +// WithLegacyFees forces legacy transactions with a fresh suggested gas price +// for each transaction. Without this wrapper, the binding selects the fee +// format from the caller's options and the network's latest block. +// Gas estimation, signing, nonce selection, value, and NoSend are unchanged. +func WithLegacyFees(factory TransactOptsFactory, client ethereum.GasPricer) TransactOptsFactory { + return &legacyFeeFactory{TransactOptsFactory: factory, client: client} +} + +func (f *legacyFeeFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts, err := f.TransactOptsFactory.NewTransactOpts(ctx) + if err != nil { + return nil, err + } + price, err := f.client.SuggestGasPrice(ctx) + if err != nil { + return nil, fmt.Errorf("suggest legacy gas price: %w", err) + } + // Zero is valid for a zero-fee network; a missing or negative price is not. + if price == nil || price.Sign() < 0 { + return nil, fmt.Errorf("suggest legacy gas price: invalid price %v", price) + } + prepared := *opts + prepared.GasPrice = price + prepared.GasFeeCap = nil + prepared.GasTipCap = nil + return &prepared, nil +} diff --git a/pkg/ethutil/legacy_fees_test.go b/pkg/ethutil/legacy_fees_test.go new file mode 100644 index 000000000..8f388f469 --- /dev/null +++ b/pkg/ethutil/legacy_fees_test.go @@ -0,0 +1,220 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "math/big" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" +) + +func TestLegacyFeeFactorySignedTransactions(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + force bool + baseFee *big.Int + gasLimit uint64 + wantType uint8 + wantPrices int32 + }{ + {name: "automatic dynamic", baseFee: big.NewInt(10), wantType: types.DynamicFeeTxType}, + {name: "automatic legacy", wantType: types.LegacyTxType, wantPrices: 2}, + {name: "forced legacy estimated", force: true, baseFee: big.NewInt(10), wantType: types.LegacyTxType, wantPrices: 2}, + { + name: "forced legacy manual gas", force: true, baseFee: big.NewInt(10), gasLimit: 123456, + wantType: types.LegacyTxType, wantPrices: 2, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + backend := &legacyFeeRPC{baseFee: test.baseFee} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + key, err := crypto.GenerateKey() + require.NoError(t, err) + chainID := big.NewInt(31337) + opts, err := bind.NewKeyedTransactorWithChainID(key, chainID) + require.NoError(t, err) + opts.Nonce = big.NewInt(7) + opts.Value = big.NewInt(19) + opts.GasLimit = test.gasLimit + opts.NoSend = true + factory := NewStaticTransactOptsFactory(opts) + if test.force { + // A forced format must not leave incompatible dynamic fee fields. + opts.GasFeeCap, opts.GasTipCap = big.NewInt(20), big.NewInt(2) + factory = WithLegacyFees(factory, client) + } + require.Equal(t, opts.From, factory.From()) + inputBox, err := iinputbox.NewIInputBox(common.HexToAddress("0x01"), client) + require.NoError(t, err) + for transaction := int64(1); transaction <= 2; transaction++ { + prepared, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Equal(t, t.Context(), prepared.Context) + tx, err := inputBox.AddInput(prepared, common.HexToAddress("0x02"), []byte{3}) + require.NoError(t, err) + require.Equal(t, test.wantType, tx.Type()) + require.Equal(t, opts.Nonce.Uint64(), tx.Nonce()) + require.Equal(t, opts.Value, tx.Value()) + require.Equal(t, chainID, tx.ChainId()) + from, err := types.Sender(types.LatestSignerForChainID(chainID), tx) + require.NoError(t, err) + require.Equal(t, opts.From, from) + if test.wantType == types.LegacyTxType { + require.Equal(t, big.NewInt(100+transaction), tx.GasPrice(), "fetch a fresh price for each transaction") + } + if test.gasLimit == 0 { + require.EqualValues(t, 75000, tx.Gas()) + } else { + require.Equal(t, test.gasLimit, tx.Gas()) + } + } + require.Nil(t, opts.GasPrice, "do not mutate the source options") + if test.force { + require.Equal(t, big.NewInt(20), opts.GasFeeCap) + require.Equal(t, big.NewInt(2), opts.GasTipCap) + } + require.Equal(t, test.wantPrices, backend.prices.Load()) + if test.gasLimit == 0 { + require.EqualValues(t, 2, backend.estimates.Load()) + } else { + require.Zero(t, backend.estimates.Load()) + } + }) + } +} + +type legacyFeeRPC struct { + baseFee *big.Int + prices atomic.Int32 + estimates atomic.Int32 +} + +func (r *legacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} + +func (r *legacyFeeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(0), BaseFee: r.baseFee}, nil +} + +func (*legacyFeeRPC) MaxPriorityFeePerGas(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(2)), nil +} + +func (*legacyFeeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (r *legacyFeeRPC) EstimateGas(context.Context, map[string]any) (hexutil.Uint64, error) { + r.estimates.Add(1) + return 75000, nil +} + +type legacyGasPricer func(context.Context) (*big.Int, error) + +func (p legacyGasPricer) SuggestGasPrice(ctx context.Context) (*big.Int, error) { return p(ctx) } + +func TestLegacyFeeFactoryErrors(t *testing.T) { + t.Parallel() + cause := errors.New("provider unavailable") + for _, test := range []struct { + name string + price *big.Int + err error + }{ + {name: "RPC error", err: cause}, + {name: "missing price"}, + {name: "negative price", price: big.NewInt(-1)}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + opts := &bind.TransactOpts{GasPrice: big.NewInt(2), GasFeeCap: big.NewInt(3), GasTipCap: big.NewInt(1)} + factory := WithLegacyFees(NewStaticTransactOptsFactory(opts), legacyGasPricer(func(ctx context.Context) (*big.Int, error) { + require.Equal(t, t.Context(), ctx) + return test.price, test.err + })) + prepared, err := factory.NewTransactOpts(t.Context()) + require.Nil(t, prepared) + require.ErrorContains(t, err, "suggest legacy gas price") + if test.err != nil { + require.ErrorIs(t, err, test.err) + } + require.Equal(t, big.NewInt(2), opts.GasPrice) + require.Equal(t, big.NewInt(3), opts.GasFeeCap) + require.Equal(t, big.NewInt(1), opts.GasTipCap) + }) + } +} + +func TestLegacyFeeFactoryAcceptsZeroPrice(t *testing.T) { + t.Parallel() + opts := &bind.TransactOpts{GasPrice: big.NewInt(2), GasFeeCap: big.NewInt(3), GasTipCap: big.NewInt(1)} + factory := WithLegacyFees(NewStaticTransactOptsFactory(opts), legacyGasPricer(func(ctx context.Context) (*big.Int, error) { + require.Equal(t, t.Context(), ctx) + return big.NewInt(0), nil + })) + prepared, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.NotNil(t, prepared) + require.Equal(t, big.NewInt(0), prepared.GasPrice) + require.Nil(t, prepared.GasFeeCap) + require.Nil(t, prepared.GasTipCap) + require.Equal(t, big.NewInt(2), opts.GasPrice) + require.Equal(t, big.NewInt(3), opts.GasFeeCap) + require.Equal(t, big.NewInt(1), opts.GasTipCap) +} + +type failedLegacyOptsFactory struct { + TransactOptsFactory + err error +} + +func (f failedLegacyOptsFactory) NewTransactOpts(context.Context) (*bind.TransactOpts, error) { + return nil, f.err +} + +func TestLegacyFeeFactoryPreservesPreparationErrors(t *testing.T) { + t.Parallel() + cause := errors.New("signer unavailable") + factory := WithLegacyFees(failedLegacyOptsFactory{err: cause}, legacyGasPricer(func(context.Context) (*big.Int, error) { + t.Fatal("do not request a price when signer preparation fails") + return nil, nil + })) + opts, err := factory.NewTransactOpts(t.Context()) + require.Nil(t, opts) + require.ErrorIs(t, err, cause) +} + +func TestLegacyFeeFactoryPreservesCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + factory := WithLegacyFees(NewStaticTransactOptsFactory(&bind.TransactOpts{}), + legacyGasPricer(func(priceCtx context.Context) (*big.Int, error) { + require.Equal(t, ctx, priceCtx) + return nil, priceCtx.Err() + })) + opts, err := factory.NewTransactOpts(ctx) + require.Nil(t, opts) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/pkg/ethutil/prt.go b/pkg/ethutil/prt.go index 128dc17ef..76536ae4c 100644 --- a/pkg/ethutil/prt.go +++ b/pkg/ethutil/prt.go @@ -4,18 +4,23 @@ package ethutil import ( "context" - "encoding/hex" "fmt" + "math/big" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) type PRTApplicationDeployment struct { ApplicationDeployment + + // A zero manager disables rotation. Sentry IDs follow the supplied order. + SentryManager common.Address + Sentries []common.Address } type PRTApplicationDeploymentResult struct { @@ -23,7 +28,6 @@ type PRTApplicationDeploymentResult struct { ApplicationAddress common.Address `json:"application_address"` DaveConsensusAddress common.Address `json:"dave_consensus_address"` - DataAvailability []byte `json:"data_availability"` InputBoxAddress common.Address `json:"inputbox_address"` IInputBoxBlock uint64 `json:"inputbox_block"` @@ -31,11 +35,13 @@ type PRTApplicationDeploymentResult struct { func (me *PRTApplicationDeployment) String() string { result := "" - result += fmt.Sprintf("PRT application deployment:\n") + result += "PRT application deployment:\n" if me.Verbose { result += fmt.Sprintf("\tPRT application factory address: %v\n", me.FactoryAddress) result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) result += fmt.Sprintf("\tsalt: %v\n", me.Salt) + result += fmt.Sprintf("\tsentry manager: %v\n", me.SentryManager) + result += fmt.Sprintf("\tsentries: %v\n", me.Sentries) } return result } @@ -44,68 +50,98 @@ func (me *PRTApplicationDeploymentResult) String() string { result := "" result += fmt.Sprintf("\tapplication address: %v\n", me.ApplicationAddress) result += fmt.Sprintf("\tconsensus address: %v\n", me.DaveConsensusAddress) - result += fmt.Sprintf("\tdata availability: 0x%v\n", hex.EncodeToString(me.DataAvailability)) - result += fmt.Sprintf("\tinputbox address: %v\n", me.InputBoxAddress) - result += fmt.Sprintf("\tinputbox block address: %d\n", me.IInputBoxBlock) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) + result += fmt.Sprintf("\tinput box block: %d\n", me.IInputBoxBlock) return result } -func (me *PRTApplicationDeployment) deployPRT( +func (me *PRTApplicationDeployment) Deploy( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction returns the predicted application address and no confirmed +// deployment result when runner does not wait for a receipt. The prediction does +// not prove that the deployment transaction succeeded. +func (me *PRTApplicationDeployment) DeployWithTransaction( ctx context.Context, client *ethclient.Client, - txOpts *bind.TransactOpts, -) (common.Address, common.Address, error) { + txOptsFactory TransactOptsFactory, + runner TransactionRunner, +) (common.Address, IApplicationDeploymentResult, error) { zero := common.Address{} + result := &PRTApplicationDeploymentResult{Deployment: me} + if err := ValidateSentryAddresses(me.Sentries); err != nil { + return zero, nil, err + } factory, err := idaveappfactory.NewIDaveAppFactory(me.FactoryAddress, client) if err != nil { - return zero, zero, fmt.Errorf("failed to instantiate contract binding: %w", err) + return zero, nil, fmt.Errorf("failed to instantiate contract binding: %w", err) } if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { - return zero, zero, err + return zero, nil, err } if err := CheckWithdrawalOutputBuilderCode(ctx, client, me.WithdrawalConfig); err != nil { - return zero, zero, err + return zero, nil, err } - // idaveappfactory has its own WithdrawalConfig type with identical fields. + claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) daveWC := idaveappfactory.WithdrawalConfig(me.WithdrawalConfig) // check if addresses are available (have no code) - addresses, err := factory.CalculateDaveAppAddress(nil, me.TemplateHash, daveWC, me.Salt) + addresses, err := factory.CalculateDaveAppAddress( + &bind.CallOpts{Context: ctx}, + me.TemplateHash, + claimStagingPeriod, + me.SentryManager, + me.Sentries, + daveWC, + me.Salt, + ) if err != nil { - return zero, zero, err + return zero, nil, err } applicationCode, err := client.CodeAt(ctx, addresses.AppContractAddress, nil) if err != nil { - return zero, zero, err + return zero, nil, err } if len(applicationCode) != 0 { - return zero, zero, fmt.Errorf("application with address: %v already exists. Try a different salt.", addresses.AppContractAddress) + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", addresses.AppContractAddress) } daveConsensusCode, err := client.CodeAt(ctx, addresses.DaveConsensusAddress, nil) if err != nil { - return zero, zero, err + return zero, nil, err } if len(daveConsensusCode) != 0 { - return zero, zero, fmt.Errorf("dave consensus with address: %v already exists. Try a different salt.", addresses.DaveConsensusAddress) + return zero, nil, fmt.Errorf("dave consensus with address %v already exists; use a different salt", addresses.DaveConsensusAddress) } - // deploy the contracts - tx, err := factory.NewDaveApp(txOpts, me.TemplateHash, daveWC, me.Salt) + txOpts, err := txOptsFactory.NewTransactOpts(ctx) if err != nil { - return zero, zero, fmt.Errorf("transaction failed: %w", err) + return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) } - - receipt, err := bind.WaitMined(ctx, client, tx) + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewDaveApp( + opts, + me.TemplateHash, + claimStagingPeriod, + me.SentryManager, + me.Sentries, + daveWC, + me.Salt, + ) + }, runner) if err != nil { - return zero, zero, fmt.Errorf("failed to wait for transaction mining: %w", err) + return zero, nil, fmt.Errorf("failed to deploy Dave application and consensus contracts: %w", err) } - - if receipt.Status != 1 { - return zero, zero, fmt.Errorf("transaction failed") + if receipt == nil { + return addresses.AppContractAddress, nil, nil } // Look for the specific event in the receipt logs @@ -115,49 +151,40 @@ func (me *PRTApplicationDeployment) deployPRT( if err != nil { continue // Skip logs that don't match } - return event.AppContract, event.DaveConsensus, nil + result.ApplicationAddress = event.AppContract + result.DaveConsensusAddress = event.DaveConsensus + break } - return zero, zero, fmt.Errorf("failed to find DaveAppCreated event in receipt logs") -} - -func (me *PRTApplicationDeployment) Deploy( - ctx context.Context, - client *ethclient.Client, - txOptsFactory TransactOptsFactory, -) (common.Address, IApplicationDeploymentResult, error) { - zero := common.Address{} - result := &PRTApplicationDeploymentResult{} - result.Deployment = me - - var err error - txOpts, err := txOptsFactory.NewTransactOpts(ctx) - if err != nil { - return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) - } - - appAddress, consensusAddress, err := me.deployPRT(ctx, client, txOpts) - if err != nil { - return zero, nil, fmt.Errorf("failed to deploy Dave Application and consensus contracts: %w", err) + if result.ApplicationAddress == zero { + return zero, nil, fmt.Errorf("failed to find DaveAppCreated event in receipt logs") } - - result.ApplicationAddress = appAddress - result.DaveConsensusAddress = consensusAddress - + appAddress := result.ApplicationAddress application, err := iapplication.NewIApplication(appAddress, client) if err != nil { return zero, nil, fmt.Errorf("failed to instantiate application: %w", err) } - da, err := application.GetDataAvailability(nil) + inputBoxAddress, err := application.GetInputBox(&bind.CallOpts{Context: ctx}) if err != nil { - return zero, nil, fmt.Errorf("failed to retrieve data availability: %w", err) + return zero, nil, fmt.Errorf("failed to retrieve input box: %w", err) + } + if me.InputBoxAddress != (common.Address{}) && inputBoxAddress != me.InputBoxAddress { + return zero, nil, fmt.Errorf( + "deployed application uses input box %v instead of %v", + inputBoxAddress, + me.InputBoxAddress, + ) } - result.DataAvailability = da + result.InputBoxAddress = inputBoxAddress - result.InputBoxAddress, result.IInputBoxBlock, err = DecodeDA(client, da) + inputBoxBlock, err := GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) if err != nil { - return zero, nil, fmt.Errorf("failed to decode data availability: %w", err) + return zero, nil, fmt.Errorf("failed to retrieve input box deployment block: %w", err) + } + if !inputBoxBlock.IsUint64() { + return zero, nil, fmt.Errorf("input box deployment block does not fit uint64: %v", inputBoxBlock) } + result.IInputBoxBlock = inputBoxBlock.Uint64() if err := VerifyDeployedWithdrawalConfig(ctx, client, appAddress, me.WithdrawalConfig); err != nil { return zero, nil, err @@ -169,3 +196,19 @@ func (me *PRTApplicationDeployment) Deploy( func (me *PRTApplicationDeployment) GetFactoryAddress() common.Address { return me.FactoryAddress } + +// ValidateSentryAddresses checks the constructor's address rules without changing +// slot order. An empty list is valid and requires the full claim staging period. +func ValidateSentryAddresses(sentries []common.Address) error { + seen := make(map[common.Address]struct{}, len(sentries)) + for i, address := range sentries { + if address == (common.Address{}) { + return fmt.Errorf("sentry %d must not be the zero address", i+1) + } + if _, duplicate := seen[address]; duplicate { + return fmt.Errorf("sentry %d duplicates address %s", i+1, address) + } + seen[address] = struct{}{} + } + return nil +} diff --git a/pkg/ethutil/prt_test.go b/pkg/ethutil/prt_test.go new file mode 100644 index 000000000..257c5d900 --- /dev/null +++ b/pkg/ethutil/prt_test.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" +) + +func TestPRTDeploymentSendsFactoryArguments(t *testing.T) { + withdrawalConfig := iapplicationfactory.WithdrawalConfig{ + Guardian: common.HexToAddress("0x1000000000000000000000000000000000000001"), + } + + for _, test := range []struct { + name string + manager common.Address + sentries []common.Address + }{ + {name: "no sentries"}, + {name: "manager without slots", manager: common.HexToAddress("0x10")}, + {name: "fixed slots", sentries: []common.Address{common.HexToAddress("0x30"), common.HexToAddress("0x20")}}, + {name: "rotatable slots", manager: common.HexToAddress("0x10"), + sentries: []common.Address{common.HexToAddress("0x30"), common.HexToAddress("0x20")}}, + } { + for _, claimStagingPeriod := range []uint64{0, 1234} { + t.Run(fmt.Sprintf("%s/period_%d", test.name, claimStagingPeriod), func(t *testing.T) { + parsed, err := idaveappfactory.IDaveAppFactoryMetaData.GetAbi() + require.NoError(t, err) + predicted := common.HexToAddress("0x400") + response, err := parsed.Methods["calculateDaveAppAddress"].Outputs.Pack(predicted, common.HexToAddress("0x500")) + require.NoError(t, err) + backend := &prtFactoryCallBackend{calls: make(chan prtFactoryCall, 1), response: response} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + deployment := &PRTApplicationDeployment{ + SentryManager: test.manager, Sentries: test.sentries, ApplicationDeployment: ApplicationDeployment{ + FactoryAddress: common.HexToAddress("0x200"), + TemplateHash: common.HexToHash("0x300"), + Salt: SaltBytes{31: 4}, + ClaimStagingPeriod: claimStagingPeriod, + WithdrawalConfig: withdrawalConfig, + }, + } + + opts := &bind.TransactOpts{ + Nonce: big.NewInt(0), GasPrice: big.NewInt(1), GasLimit: 1_000_000, NoSend: true, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + var deployData []byte + address, result, err := deployment.DeployWithTransaction(t.Context(), client, NewStaticTransactOptsFactory(opts), + func( + _ context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + tx, err := build(opts) + require.NoError(t, err) + require.Equal(t, deployment.FactoryAddress, *tx.To()) + deployData = tx.Data() + return nil, nil + }) + require.NoError(t, err) + require.Equal(t, predicted, address) + require.Nil(t, result) + call := <-backend.calls + require.Equal(t, deployment.FactoryAddress, call.To) + for methodName, data := range map[string][]byte{"calculateDaveAppAddress": call.Input, "newDaveApp": deployData} { + method := parsed.Methods[methodName] + require.Equal(t, method.ID, data[:4]) + args, err := method.Inputs.Unpack(data[4:]) + require.NoError(t, err) + require.Equal(t, [32]byte(deployment.TemplateHash), args[0]) + require.Zero(t, new(big.Int).SetUint64(claimStagingPeriod).Cmp(args[1].(*big.Int))) + require.Equal(t, test.manager, args[2]) + require.Equal(t, append([]common.Address{}, test.sentries...), args[3]) + gotWithdrawal := abi.ConvertType(args[4], new(idaveappfactory.WithdrawalConfig)).(*idaveappfactory.WithdrawalConfig) + require.Equal(t, idaveappfactory.WithdrawalConfig(withdrawalConfig), *gotWithdrawal) + require.Equal(t, [32]byte(deployment.Salt), args[5]) + } + }) + } + } +} + +type prtFactoryCall struct { + To common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` +} + +type prtFactoryCallBackend struct { + calls chan prtFactoryCall + response hexutil.Bytes +} + +func (b *prtFactoryCallBackend) Call(_ context.Context, call prtFactoryCall, _ string) (hexutil.Bytes, error) { + b.calls <- call + return b.response, nil +} + +func (*prtFactoryCallBackend) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{}, nil +} + +func TestPRTDeploymentRejectsInvalidSentriesBeforeRPC(t *testing.T) { + address := common.HexToAddress("0x01") + for _, test := range []struct { + name string + sentries []common.Address + wantError string + }{ + {name: "zero", sentries: []common.Address{{}}, wantError: "must not be the zero address"}, + {name: "duplicate", sentries: []common.Address{address, address}, wantError: "duplicates address"}, + } { + t.Run(test.name, func(t *testing.T) { + deployment := &PRTApplicationDeployment{Sentries: test.sentries} + _, _, err := deployment.Deploy(t.Context(), nil, nil) + require.ErrorContains(t, err, test.wantError) + }) + } +} diff --git a/pkg/ethutil/quorum.go b/pkg/ethutil/quorum.go index 4eafca1bd..716356f22 100644 --- a/pkg/ethutil/quorum.go +++ b/pkg/ethutil/quorum.go @@ -11,6 +11,7 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) @@ -26,7 +27,7 @@ type QuorumDeployment struct { func (me *QuorumDeployment) String() string { result := "" - result += fmt.Sprintf("quorum deployment:\n") + result += "quorum deployment:\n" result += fmt.Sprintf("\tvalidators: %v\n", me.Validators) if me.Verbose { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) @@ -41,6 +42,17 @@ func (me *QuorumDeployment) Deploy( ctx context.Context, client *ethclient.Client, txOpts *bind.TransactOpts, +) (common.Address, error) { + return me.DeployWithTransaction(ctx, client, txOpts, nil) +} + +// DeployWithTransaction returns a predicted address when runner does not wait for +// a receipt. The prediction does not prove that the deployment transaction succeeded. +func (me *QuorumDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, + runner TransactionRunner, ) (common.Address, error) { zero := common.Address{} factory, err := iquorumfactory.NewIQuorumFactory(me.FactoryAddress, client) @@ -51,7 +63,7 @@ func (me *QuorumDeployment) Deploy( epochLength := new(big.Int).SetUint64(me.EpochLength) claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) quorumAddress, err := factory.CalculateQuorumAddress( - nil, + &bind.CallOpts{Context: ctx}, me.Validators, epochLength, claimStagingPeriod, @@ -66,21 +78,18 @@ func (me *QuorumDeployment) Deploy( return zero, err } if len(quorumCode) != 0 { - return zero, fmt.Errorf("quorum with address: %v already exists. Try a different salt.", quorumAddress) + return zero, fmt.Errorf("quorum with address %v already exists; use a different salt", quorumAddress) } - tx, err := factory.NewQuorum(txOpts, me.Validators, epochLength, claimStagingPeriod, me.Salt) + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewQuorum(opts, me.Validators, epochLength, claimStagingPeriod, me.Salt) + }, runner) if err != nil { return zero, fmt.Errorf("failed to create new quorum: %w", err) } - receipt, err := bind.WaitMined(ctx, client, tx) - if err != nil { - return zero, fmt.Errorf("failed to mine new quorum transaction: %w", err) - } - - if receipt.Status != 1 { - return zero, fmt.Errorf("transaction failed") + if receipt == nil { + return quorumAddress, nil } for _, vLog := range receipt.Logs { diff --git a/pkg/ethutil/return_data_test.go b/pkg/ethutil/return_data_test.go new file mode 100644 index 000000000..6951e98f5 --- /dev/null +++ b/pkg/ethutil/return_data_test.go @@ -0,0 +1,69 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "errors" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestApplicationReturnDataSuffix(t *testing.T) { + for _, contract := range []struct { + name string + metadata *bind.MetaData + }{ + {"IConsensus", iconsensus.IConsensusMetaData}, + {"IDaveConsensus", idaveconsensus.IDaveConsensusMetaData}, + } { + for _, name := range []string{"ApplicationReverted", "IllformedApplicationReturnData"} { + t.Run(contract.name+"/"+name, func(t *testing.T) { + parsed, err := contract.metadata.GetAbi() + require.NoError(t, err) + abiError, ok := parsed.Errors[name] + require.True(t, ok) + for _, data := range [][]byte{nil, {0, 0xff, '\n', '\r', 0x1b, '[', '2', 'J'}} { + packed, err := abiError.Inputs.Pack(common.HexToAddress("0x1234"), data) + require.NoError(t, err) + payload := append(append([]byte{}, abiError.ID[:revertSelectorLength]...), packed...) + rpcErr := &rpcDataError{code: 3, msg: executionRevertedMessage, data: payload} + for _, wrapped := range []error{rpcErr, fmt.Errorf("estimate: %w", rpcErr)} { + require.Equal(t, fmt.Sprintf(" Application return data: 0x%x.", data), + ApplicationReturnDataSuffix(wrapped, contract.metadata, name)) + } + } + truncated := &rpcDataError{data: abiError.ID[:revertSelectorLength]} + require.Empty(t, ApplicationReturnDataSuffix(truncated, contract.metadata, name)) + require.Empty(t, ApplicationReturnDataSuffix(nil, contract.metadata, name)) + require.Empty(t, ApplicationReturnDataSuffix(errors.New("unavailable"), contract.metadata, name)) + }) + } + } +} + +func TestApplicationReturnDataSuffixRejectsUnrelatedShapes(t *testing.T) { + // These errors do not have the (address, bytes) return-data shape. + for _, test := range []struct { + name string + args []any + }{ + {"AppReverted", []any{[]byte{1}}}, + {"InsufficientFunds", []any{common.Big1, common.Big2}}, + } { + t.Run(test.name, func(t *testing.T) { + err := describeTestRevert(t, test.name, test.args...) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, nil, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, &bind.MetaData{ABI: "invalid"}, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, "Unknown")) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, "Closed")) + }) + } +} diff --git a/pkg/ethutil/rpcerror.go b/pkg/ethutil/rpcerror.go index 074b6de6e..9b2bb305c 100644 --- a/pkg/ethutil/rpcerror.go +++ b/pkg/ethutil/rpcerror.go @@ -148,6 +148,22 @@ func UnpackRevert(err error, metadata *bind.MetaData, errorName string) ([]any, return values, true } +// ApplicationReturnDataSuffix formats the bytes carried by ApplicationReverted +// or IllformedApplicationReturnData(address, bytes). Contract-controlled bytes +// are hex-encoded so they stay inert in logs and stored reasons. Invalid revert +// data produces no suffix. The caller supplies its consensus ABI explicitly. +func ApplicationReturnDataSuffix(err error, metadata *bind.MetaData, name string) string { + values, ok := UnpackRevert(err, metadata, name) + if !ok || len(values) < 2 { + return "" + } + data, ok := values[1].([]byte) + if !ok { + return "" + } + return fmt.Sprintf(" Application return data: 0x%x.", data) +} + // DescribeRevert decodes the custom-error revert payload carried by err // against the given contract metadatas and renders it as // "Name(param=value, ...)". It returns ("", false) when err carries no revert diff --git a/pkg/ethutil/rpcerror_test.go b/pkg/ethutil/rpcerror_test.go index b3aadb512..2adb0e88a 100644 --- a/pkg/ethutil/rpcerror_test.go +++ b/pkg/ethutil/rpcerror_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/require" ) +const executionRevertedMessage = "execution reverted" + // rpcDataError simulates an RPC error with revert data, as returned by // eth_estimateGas when a contract reverts. type rpcDataError struct { @@ -41,7 +43,7 @@ func TestExtractJSONErrorInfo(t *testing.T) { }) t.Run("RPCDataError", func(t *testing.T) { - err := &rpcDataError{code: 3, msg: "execution reverted", data: "0xdeadbeef"} + err := &rpcDataError{code: 3, msg: executionRevertedMessage, data: "0xdeadbeef"} info, ok := ExtractJSONErrorInfo(err) assert.True(t, ok) assert.True(t, info.HasCode) @@ -134,7 +136,7 @@ func TestIsNonceTooLowError(t *testing.T) { want: true, }, {name: "UnrelatedError", err: errors.New("connection refused"), want: false}, - {name: "RevertedError", err: errors.New("execution reverted"), want: false}, + {name: "RevertedError", err: errors.New(executionRevertedMessage), want: false}, { name: "NonceTooHigh", err: errors.New("nonce too high"), @@ -168,7 +170,7 @@ func describeTestRevert(t *testing.T, name string, args ...any) error { packed, err := abiErr.Inputs.Pack(args...) require.NoError(t, err) payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) - return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", payload)} + return &rpcDataError{code: 3, msg: executionRevertedMessage, data: fmt.Sprintf("0x%x", payload)} } func TestDescribeRevert(t *testing.T) { @@ -208,14 +210,14 @@ func TestDescribeRevert(t *testing.T) { // Selector of a parameterized error with a truncated body. id := parsed.Errors["InsufficientFunds"].ID payload := append([]byte{}, id[:4]...) - e := &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", payload)} + e := &rpcDataError{code: 3, msg: executionRevertedMessage, data: fmt.Sprintf("0x%x", payload)} desc, ok := DescribeRevert(e, describeTestMetaData) assert.True(t, ok) assert.Equal(t, "InsufficientFunds", desc, "bare name when args do not decode") }) t.Run("UnknownSelector", func(t *testing.T) { - e := &rpcDataError{code: 3, msg: "execution reverted", data: "0xdeadbeef"} + e := &rpcDataError{code: 3, msg: executionRevertedMessage, data: "0xdeadbeef"} _, ok := DescribeRevert(e, describeTestMetaData) assert.False(t, ok) }) diff --git a/pkg/ethutil/selfhosted.go b/pkg/ethutil/selfhosted.go index 89817fd68..a95f4c9b4 100644 --- a/pkg/ethutil/selfhosted.go +++ b/pkg/ethutil/selfhosted.go @@ -4,7 +4,6 @@ package ethutil import ( "context" - "encoding/hex" "fmt" "math/big" @@ -18,15 +17,13 @@ import ( ) type SelfhostedApplicationDeployment struct { - FactoryAddress common.Address `json:"factory_address"` - ApplicationOwnerAddress common.Address `json:"application_owner"` - AuthorityOwnerAddress common.Address `json:"authority_owner"` - TemplateHash common.Hash `json:"template_hash"` - DataAvailability []byte `json:"-"` - EpochLength uint64 `json:"epoch_length"` - ClaimStagingPeriod uint64 `json:"claim_staging_period"` - WithdrawalConfig iapplicationfactory.WithdrawalConfig `json:"withdrawal_config"` - Salt SaltBytes `json:"salt"` + FactoryAddress common.Address `json:"factory_address"` + AuthorityOwnerAddress common.Address `json:"authority_owner"` + TemplateHash common.Hash `json:"template_hash"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + WithdrawalConfig iapplicationfactory.WithdrawalConfig `json:"withdrawal_config"` + Salt SaltBytes `json:"salt"` InputBoxAddress common.Address `json:"inputbox_address"` IInputBoxBlock uint64 `json:"inputbox_block"` @@ -46,13 +43,12 @@ type SelfhostedApplicationDeploymentResult struct { func (me *SelfhostedApplicationDeployment) String() string { result := "" - result += fmt.Sprintf("selfhosted deployment:\n") - result += fmt.Sprintf("\tapplication owner: %v\n", me.ApplicationOwnerAddress) + result += "selfhosted deployment:\n" result += fmt.Sprintf("\tauthority owner: %v\n", me.AuthorityOwnerAddress) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) if me.Verbose { result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) - result += fmt.Sprintf("\tdata availability: 0x%v\n", hex.EncodeToString(me.DataAvailability)) result += fmt.Sprintf("\tsalt: %v\n", me.Salt) result += fmt.Sprintf("\tepoch length: %v\n", me.EpochLength) result += fmt.Sprintf("\tclaim staging period: %v\n", me.ClaimStagingPeriod) @@ -75,6 +71,18 @@ func (me *SelfhostedApplicationDeployment) Deploy( ctx context.Context, client *ethclient.Client, txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction returns the predicted application address and no confirmed +// deployment result when runner does not wait for a receipt. The prediction does +// not prove that the deployment transaction succeeded. +func (me *SelfhostedApplicationDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, + runner TransactionRunner, ) (common.Address, IApplicationDeploymentResult, error) { zero := common.Address{} result := &SelfhostedApplicationDeploymentResult{} @@ -97,13 +105,12 @@ func (me *SelfhostedApplicationDeployment) Deploy( // check if addresses are available (have no code) applicationAddress, authorityAddress, err := factory.CalculateAddresses( - nil, + &bind.CallOpts{Context: ctx}, me.AuthorityOwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), - me.ApplicationOwnerAddress, me.TemplateHash, - me.DataAvailability, + me.InputBoxAddress, shWC, me.Salt, ) @@ -116,7 +123,7 @@ func (me *SelfhostedApplicationDeployment) Deploy( return zero, nil, err } if len(applicationCode) != 0 { - return zero, nil, fmt.Errorf("application with address: %v already exists. Try a different salt.", applicationAddress) + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", applicationAddress) } authorityCode, err := client.CodeAt(ctx, authorityAddress, nil) @@ -124,18 +131,30 @@ func (me *SelfhostedApplicationDeployment) Deploy( return zero, nil, err } if len(authorityCode) != 0 { - return zero, nil, fmt.Errorf("authority with address: %v already exists. Try a different salt.", authorityAddress) + return zero, nil, fmt.Errorf("authority with address %v already exists; use a different salt", authorityAddress) + } + + var txOpts *bind.TransactOpts + if runner == nil { + // Preserve the existing helper's preparation for non-CLI callers. The + // CLI runner applies its common transaction policy instead. + txOpts, err = _prepareTransaction(ctx, client, txOptsFactory, big.NewInt(0)) + } else { + txOpts, err = txOptsFactory.NewTransactOpts(ctx) + } + if err != nil { + return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) } // deploy the contracts - receipt, err := sendTransaction( - ctx, client, txOptsFactory, big.NewInt(0), + receipt, err := runDeploymentTransaction( + ctx, client, txOpts, func(txOpts *bind.TransactOpts) (*types.Transaction, error) { - result.ApplicationFactoryAddress, err = factory.GetApplicationFactory(nil) + result.ApplicationFactoryAddress, err = factory.GetApplicationFactory(&bind.CallOpts{Context: ctx}) if err != nil { return nil, fmt.Errorf("failed to retrieve application factory address: %w", err) } - result.AuthorityFactoryAddress, err = factory.GetAuthorityFactory(nil) + result.AuthorityFactoryAddress, err = factory.GetAuthorityFactory(&bind.CallOpts{Context: ctx}) if err != nil { return nil, fmt.Errorf("failed to retrieve authority factory address: %w", err) } @@ -144,17 +163,20 @@ func (me *SelfhostedApplicationDeployment) Deploy( me.AuthorityOwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), - me.ApplicationOwnerAddress, me.TemplateHash, - me.DataAvailability, + me.InputBoxAddress, shWC, me.Salt, ) }, + runner, ) if err != nil { return zero, nil, fmt.Errorf("failed to create a self hosted application: %w", err) } + if receipt == nil { + return applicationAddress, nil, nil + } applicationFactory, err := iapplicationfactory.NewIApplicationFactory(result.ApplicationFactoryAddress, client) if err != nil { @@ -174,7 +196,7 @@ func (me *SelfhostedApplicationDeployment) Deploy( result.ApplicationAddress = event.AppContract goto applicationEventFound } - return zero, nil, fmt.Errorf("failed to obtain application address during self hosted application deployment. ApplicationCreated event not found in the receipt logs") + return zero, nil, fmt.Errorf("self-hosted deployment receipt has no ApplicationCreated event") applicationEventFound: for _, vLog := range receipt.Logs { @@ -185,7 +207,7 @@ applicationEventFound: result.AuthorityAddress = event.Authority goto authorityEventFound } - return zero, nil, fmt.Errorf("failed to obtain authority address during self hosted application deployment. AuthorityCreated event not found in the recipe logs") + return zero, nil, fmt.Errorf("self-hosted deployment receipt has no AuthorityCreated event") authorityEventFound: if err := VerifyDeployedWithdrawalConfig(ctx, client, result.ApplicationAddress, me.WithdrawalConfig); err != nil { diff --git a/pkg/ethutil/withdrawal_account.go b/pkg/ethutil/withdrawal_account.go index 9679a8856..68e9ad8ff 100644 --- a/pkg/ethutil/withdrawal_account.go +++ b/pkg/ethutil/withdrawal_account.go @@ -5,7 +5,6 @@ package ethutil import ( "context" - "encoding/binary" "fmt" "math/big" @@ -17,15 +16,12 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/iusdwithdrawaloutputbuilder" ) -// usdAccountMinSize is the minimum byte length of the LibUsdAccount encoding consumed +// usdAccountSize is the exact byte length of the LibUsdAccount encoding consumed // by every UsdWithdrawalOutputBuilder: // -// bytes 0..7 uint64 balance, little-endian -// bytes 8..27 20-byte user address -// -// The account may be larger. LibUsdAccount ignores bytes after byte 27, which -// lets ewtools-style 32-byte account-drive records be withdrawn directly. -const usdAccountMinSize = 28 +// bytes 0..11 uint96 balance, little-endian +// bytes 12..31 20-byte user address +const usdAccountSize = 32 // DescribeWithdrawalAccount renders a multi-line human description of the // `account` bytes consumed by an IApplication.withdraw() call so the @@ -36,14 +32,12 @@ const usdAccountMinSize = 28 // 1. Call IUsdWithdrawalOutputBuilder.Token() on the on-chain builder. // A revert here means the builder is not a USD-family builder; the // caller should fall back to a raw-bytes display. -// 2. Split the first 28 bytes into recipient and balance per LibUsdAccount. -// A shorter account is a hard error — a malformed proof against a -// recognized builder, not a fallback signal. Longer account records are -// accepted because the contract ignores trailing bytes. +// 2. Require and split the exact 32-byte record into recipient and balance +// per LibUsdAccount. Any other length is a hard error. // 3. Best-effort fetch IERC20Metadata.Symbol() and Decimals() on the // returned token address so the balance can be rendered as a // fixed-point amount. If either view reverts (broken or non-standard -// ERC-20), the raw uint64 balance is shown unmodified. +// ERC-20), the raw uint96 balance is shown unmodified. // // Tri-state return: // @@ -66,12 +60,10 @@ func DescribeWithdrawalAccount( if err != nil { return "", false, nil } - if len(account) < usdAccountMinSize { - return "", true, fmt.Errorf( - "USD account must be at least %d bytes, got %d (token %s)", - usdAccountMinSize, len(account), token) + recipient, balance, err := decodeUSDAccount(account) + if err != nil { + return "", true, fmt.Errorf("%w (token %s)", err, token) } - recipient, balance := decodeUSDAccount(account) symbol, decimals, metaOK := fetchERC20Metadata(ctx, client, token) tokenLine := fmt.Sprintf(" token: %s", token) @@ -81,12 +73,12 @@ func DescribeWithdrawalAccount( var amountLine string if metaOK { amountLine = fmt.Sprintf( - " amount: %s %s (raw: %d, decimals: %d)", - formatTokenAmount(balance, decimals), symbol, balance, decimals) + " amount: %s %s (raw: %s, decimals: %d)", + formatTokenAmount(balance, decimals), symbol, balance.String(), decimals) } else { amountLine = fmt.Sprintf( - " amount (raw uint64): %d (token metadata unavailable)", - balance) + " amount (raw uint96): %s (token metadata unavailable)", + balance.String()) } return fmt.Sprintf( "USD-style account (recognized via IUsdWithdrawalOutputBuilder.Token)\n"+ @@ -95,11 +87,23 @@ func DescribeWithdrawalAccount( ), true, nil } -func decodeUSDAccount(account []byte) (common.Address, uint64) { - balance := binary.LittleEndian.Uint64(account[:8]) +func decodeUSDAccount(account []byte) (common.Address, *big.Int, error) { + if len(account) != usdAccountSize { + return common.Address{}, nil, fmt.Errorf( + "USD account must be exactly %d bytes, got %d", + usdAccountSize, + len(account), + ) + } + + var balanceBytes [12]byte + for i := range balanceBytes { + balanceBytes[len(balanceBytes)-1-i] = account[i] + } + balance := new(big.Int).SetBytes(balanceBytes[:]) var recipient common.Address - copy(recipient[:], account[8:usdAccountMinSize]) - return recipient, balance + copy(recipient[:], account[12:usdAccountSize]) + return recipient, balance, nil } // fetchERC20Metadata best-effort-fetches the symbol and decimals of an @@ -130,12 +134,12 @@ func fetchERC20Metadata( // fixed-point string (e.g. balance=1_500_000, decimals=6 → "1.5"). Trailing // zeros in the fractional part are trimmed so common round amounts render // compactly. -func formatTokenAmount(raw uint64, decimals uint8) string { +func formatTokenAmount(raw *big.Int, decimals uint8) string { if decimals == 0 { - return fmt.Sprintf("%d", raw) + return raw.String() } denom := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) - whole, frac := new(big.Int).QuoRem(new(big.Int).SetUint64(raw), denom, new(big.Int)) + whole, frac := new(big.Int).QuoRem(new(big.Int).Set(raw), denom, new(big.Int)) if frac.Sign() == 0 { return whole.String() } diff --git a/pkg/ethutil/withdrawal_account_test.go b/pkg/ethutil/withdrawal_account_test.go index 274b04e09..4d4ec5474 100644 --- a/pkg/ethutil/withdrawal_account_test.go +++ b/pkg/ethutil/withdrawal_account_test.go @@ -4,7 +4,8 @@ package ethutil import ( - "encoding/binary" + "bytes" + "math/big" "testing" "github.com/ethereum/go-ethereum/common" @@ -13,38 +14,47 @@ import ( func TestFormatTokenAmount(t *testing.T) { cases := []struct { - raw uint64 + raw string decimals uint8 want string }{ - {0, 0, "0"}, - {42, 0, "42"}, - {1_500_000, 6, "1.5"}, - {1_234_567, 6, "1.234567"}, - {1_000_000, 6, "1"}, - {1, 6, "0.000001"}, - {1_000_000_000_000_000_000, 18, "1"}, - {1_500_000_000_000_000_000, 18, "1.5"}, - {999_999_999, 8, "9.99999999"}, - {1, 18, "0.000000000000000001"}, + {"0", 0, "0"}, + {"42", 0, "42"}, + {"1500000", 6, "1.5"}, + {"1234567", 6, "1.234567"}, + {"1000000", 6, "1"}, + {"1", 6, "0.000001"}, + {"1000000000000000000", 18, "1"}, + {"1500000000000000000", 18, "1.5"}, + {"999999999", 8, "9.99999999"}, + {"1", 18, "0.000000000000000001"}, + {"79228162514264337593543950335", 6, "79228162514264337593543.950335"}, } for _, c := range cases { - got := formatTokenAmount(c.raw, c.decimals) - require.Equalf(t, c.want, got, "formatTokenAmount(%d, %d)", c.raw, c.decimals) + raw, ok := new(big.Int).SetString(c.raw, 10) + require.True(t, ok) + before := new(big.Int).Set(raw) + got := formatTokenAmount(raw, c.decimals) + require.Equalf(t, c.want, got, "formatTokenAmount(%s, %d)", c.raw, c.decimals) + require.Zero(t, before.Cmp(raw), "formatTokenAmount mutated its input") } } -func TestDecodeUSDAccount_AcceptsMinimumAndPaddedAccount(t *testing.T) { +func TestDecodeUSDAccountExactUint96Record(t *testing.T) { recipient := common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") - account := make([]byte, 32) - binary.LittleEndian.PutUint64(account[:8], 75) - copy(account[8:28], recipient.Bytes()) + account := common.FromHex("0x0102030405060708090a0b0cbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + before := bytes.Clone(account) - gotRecipient, gotBalance := decodeUSDAccount(account[:28]) + gotRecipient, gotBalance, err := decodeUSDAccount(account) + require.NoError(t, err) require.Equal(t, recipient, gotRecipient) - require.Equal(t, uint64(75), gotBalance) + require.Equal(t, "3727165692135864801209549313", gotBalance.String()) + require.Equal(t, before, account, "decodeUSDAccount mutated its source bytes") +} - gotRecipient, gotBalance = decodeUSDAccount(account) - require.Equal(t, recipient, gotRecipient) - require.Equal(t, uint64(75), gotBalance) +func TestDecodeUSDAccountRequiresExactSize(t *testing.T) { + for _, size := range []int{0, usdAccountSize - 1, usdAccountSize + 1} { + _, _, err := decodeUSDAccount(make([]byte, size)) + require.ErrorContains(t, err, "exactly 32 bytes") + } } diff --git a/pkg/service/supervisor.go b/pkg/service/supervisor.go index 221bf811e..bf5d5af1d 100644 --- a/pkg/service/supervisor.go +++ b/pkg/service/supervisor.go @@ -15,6 +15,7 @@ import ( "sync/atomic" "syscall" + "github.com/cartesi/rollups-node/internal/errutil" "github.com/cartesi/rollups-node/internal/version" "golang.org/x/sync/errgroup" ) @@ -199,7 +200,7 @@ func (s *supervisorImpl) Serve() (err error) { if svcErr == nil { svcErr = ErrServiceStopped } - case svcErr == nil || isCancellationOnly(svcErr): + case svcErr == nil || errutil.IsOnlyCancellation(svcErr): s.logger.Info("Subservice stopped", "subservice", svc, ) @@ -228,29 +229,6 @@ func (s *supervisorImpl) Serve() (err error) { return errors.Join(errs...) } -// isCancellationOnly checks every leaf before suppressing a shutdown error. -// Matching the whole tree with errors.Is would hide failures joined with cancellation. -func isCancellationOnly(err error) bool { - switch wrapped := err.(type) { - case interface{ Unwrap() []error }: - children := wrapped.Unwrap() - if len(children) == 0 { - return errors.Is(err, context.Canceled) - } - for _, child := range children { - if !isCancellationOnly(child) { - return false - } - } - return true - case interface{ Unwrap() error }: - if child := wrapped.Unwrap(); child != nil { - return isCancellationOnly(child) - } - } - return errors.Is(err, context.Canceled) -} - // Fatal publishes the failure before cancellation so Serve observes it even if // all services return context.Canceled as a result of shutdown. func (s *supervisorImpl) Fatal(err error) { diff --git a/scripts/load-test.sh b/scripts/load-test.sh index 25bed5165..4951a94df 100755 --- a/scripts/load-test.sh +++ b/scripts/load-test.sh @@ -166,7 +166,7 @@ send_input() { fi CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX="$acct_idx" \ - cartesi-rollups-cli send "$app" "$payload" --yes --async > /dev/null 2>&1 & + cartesi-rollups-cli send "$app" "$payload" --yes --no-wait > /dev/null 2>&1 & SENDER_PIDS[$acct_idx]=$! } diff --git a/scripts/withdrawal-lifecycle b/scripts/withdrawal-lifecycle index d7ffb0a22..b61dd1f39 100755 --- a/scripts/withdrawal-lifecycle +++ b/scripts/withdrawal-lifecycle @@ -19,8 +19,8 @@ NODE="${NODE:-$ROOT/cartesi-rollups-node}" MACHINE_TOOL="${MACHINE_TOOL:-$ROOT/cartesi-rollups-machine-tool}" RPC_URL="${CARTESI_BLOCKCHAIN_HTTP_ENDPOINT:-http://localhost:8545}" -PORTAL="${PORTAL:-${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1}}" -TOKEN="${TOKEN:-${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x88A2120B7068E78692C8fd12E751d610B6377E4d}}" +PORTAL="${PORTAL:-${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419}}" +TOKEN="${TOKEN:-${CARTESI_DEVNET_TEST_ERC20_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}}" DEPOSIT_AMOUNT="${DEPOSIT_AMOUNT:-100}" WITHDRAW_AMOUNT="${WITHDRAW_AMOUNT:-25}" diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 5054be4c6..a6072c892 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -3,17 +3,20 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: http://ethereum_provider:8545 CARTESI_BLOCKCHAIN_ID: 31337 CARTESI_EVM_READER_POLLING_INTERVAL: 1 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x346B3df038FE9f8380071eC6514D5a83aD143939 - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0 - CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: 0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483 - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x6145C5996a71a379E030aEb0440df79D60833418 - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0x33FFf0b681c90664dD048a60400AE2D827a4c5bb - CARTESI_DEVNET_ERC20_PORTAL_ADDRESS: 0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1 - CARTESI_DEVNET_TEST_ERC20_ADDRESS: 0x88A2120B7068E78692C8fd12E751d610B6377E4d - CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: 0x0745787835A019cd4dae8EDB541Fbc0647793d63 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" + CARTESI_DEVNET_ERC20_PORTAL_ADDRESS: "0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419" + CARTESI_DEVNET_TEST_ERC20_ADDRESS: "0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" CARTESI_DATABASE_CONNECTION: postgres://postgres:password@database:5432/rollupsdb?sslmode=disable CARTESI_AUTH_MNEMONIC: "test test test test test test test test test test test junk" + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC: "test test test test test test test test test test test junk" + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 services: ethereum_provider: diff --git a/test/compose/compose.test.yaml b/test/compose/compose.test.yaml index b7fa3b3b2..dd566b048 100644 --- a/test/compose/compose.test.yaml +++ b/test/compose/compose.test.yaml @@ -3,16 +3,19 @@ x-env: &env CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: http://ethereum_provider:8545 CARTESI_BLOCKCHAIN_ID: 31337 CARTESI_EVM_READER_POLLING_INTERVAL: 1 - CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: 0x346B3df038FE9f8380071eC6514D5a83aD143939 - CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: 0x3C1FE01c542a88A523FF6847eD1E26176c8C4ED0 - CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: 0x1f94009389F408B8D0ADfFcF8BBDCe5552BaCa5F - CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: 0xC549F89cF1ca43eDDECC64Ac2208F4b283B1c483 - CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: 0x6145C5996a71a379E030aEb0440df79D60833418 - CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: 0x33FFf0b681c90664dD048a60400AE2D827a4c5bb - CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: 0x0745787835A019cd4dae8EDB541Fbc0647793d63 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" CARTESI_DATABASE_CONNECTION: postgres://postgres:password@database:5432/rollupsdb?sslmode=disable CARTESI_TEST_DATABASE_CONNECTION: postgres://test_user:password@database:5432/test_rollupsdb?sslmode=disable CARTESI_AUTH_MNEMONIC: "test test test test test test test test test test test junk" + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC: "test test test test test test test test test test test junk" + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 services: ethereum_provider: diff --git a/test/dapps/erc20-withdrawal/install.sh b/test/dapps/erc20-withdrawal/install.sh index c7acd7ef7..bd7940263 100644 --- a/test/dapps/erc20-withdrawal/install.sh +++ b/test/dapps/erc20-withdrawal/install.sh @@ -79,13 +79,21 @@ zero_record() { } record_address() { - printf '%s' "${1:16:40}" + printf '%s' "${1:24:40}" } record_balance() { uint64_be_to_dec "$(reverse_bytes "${1:0:16}")" } +encode_record() { + local balance="$1" + local address="$2" + # This fixture limits arithmetic to positive int64 values. Store them + # zero-extended in the uint96 balance field required by LibUsdAccount. + printf '%s00000000%s' "$(uint64_dec_to_le "$balance")" "$address" +} + find_account_index() { local address="$1" local i record @@ -138,7 +146,7 @@ credit_account() { if (( new_balance <= 0 )); then return 1 fi - write_record "$idx" "$(uint64_dec_to_le "$new_balance")${address}00000000" + write_record "$idx" "$(encode_record "$new_balance" "$address")" } debit_account() { @@ -160,7 +168,7 @@ debit_account() { new_balance=$((balance - amount)) if (( new_balance > 0 )); then - write_record "$idx" "$(uint64_dec_to_le "$new_balance")${address}00000000" + write_record "$idx" "$(encode_record "$new_balance" "$address")" return 0 fi diff --git a/test/devnet/Dockerfile b/test/devnet/Dockerfile index c0dece8b7..079160ec1 100644 --- a/test/devnet/Dockerfile +++ b/test/devnet/Dockerfile @@ -1,5 +1,5 @@ -ARG FOUNDRY_VERSION=1.4.3 -ARG PRT_CONTRACTS_VERSION=3.0.0-alpha.3 +ARG FOUNDRY_VERSION=1.5.1 +ARG PRT_CONTRACTS_VERSION=3.0.0-alpha.4 ARG DEVNET_BUILD_PATH=/opt/cartesi/rollups-contracts FROM debian:trixie-20250811 AS rollups-node-devnet @@ -25,64 +25,25 @@ RUN </dev/null | grep -q 31337; do sleep 0.2; done - - TOKEN=$(jq -r .address ${DEVNET_BUILD_PATH}/deployments/31337/TestFungibleToken.json) - FACTORY=$(jq -r .address ${DEVNET_BUILD_PATH}/deployments/31337/UsdWithdrawalOutputBuilderFactory.json) - SALT=0x0000000000000000000000000000000000000000000000000000000000000000 - - # Predict the deterministic builder address (used to synth the JSON below). - BUILDER=$(cast call "$FACTORY" \ - 'calculateUsdWithdrawalOutputBuilderAddress(address,bytes32)(address)' \ - "$TOKEN" "$SALT") - - # Deploy the builder via the factory using anvil's first unlocked account. - DEPLOYER=$(cast rpc eth_accounts | jq -r '.[0]') - cast send --from "$DEPLOYER" --unlocked "$FACTORY" \ - 'newUsdWithdrawalOutputBuilder(address,bytes32)' "$TOKEN" "$SALT" - - # Synthesize the per-contract JSON BEFORE killing anvil, so a cast-send - # failure (set -e) aborts before we write a JSON unbacked by chain state. - jq -n --arg addr "$BUILDER" \ - '{contractName: "UsdWithdrawalOutputBuilder", address: $addr}' \ - > ${DEVNET_BUILD_PATH}/deployments/31337/UsdWithdrawalOutputBuilder.json - - # Graceful shutdown lets --state dump the post-deploy chain back to disk. - # `wait` returns non-zero on a SIGINT-terminated child; `|| true` keeps - # `set -e` from aborting the build on that expected non-zero. - kill -INT $ANVIL_PID - wait $ANVIL_PID || true - cat ${DEVNET_BUILD_PATH}/deployments/31337/*.json | jq -s 'map({ (.contractName): .address }) | add' > /usr/share/devnet/deployment.json mv ${DEVNET_BUILD_PATH}/state.json /usr/share/devnet/anvil_state.json EOF diff --git a/test/integration/anvil_helpers_test.go b/test/integration/anvil_helpers_test.go index 66c9670fc..846393085 100644 --- a/test/integration/anvil_helpers_test.go +++ b/test/integration/anvil_helpers_test.go @@ -9,8 +9,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "math/big" "net/http" "testing" "time" @@ -25,7 +27,13 @@ import ( // maxBlocksToMine is the sanity cap for mineForTournamentTimeout to prevent // hanging if the tournament contract reports an unreasonably large allowance. -const maxBlocksToMine = 10_000 +const ( + maxBlocksToMine = 10_000 + bondDispositionTournamentRunning = 0 + bondDispositionNoWinner = 1 + bondDispositionRecoverable = 2 + bondDispositionRecovered = 3 +) // Anvil devnet RPC helpers. @@ -115,9 +123,9 @@ func mineForTournamentTimeout( return 0, fmt.Errorf("bind tournament: %w", err) } - args, err := tournament.TournamentArguments(&bind.CallOpts{Context: ctx}) + descriptor, err := tournament.TournamentDescriptor(&bind.CallOpts{Context: ctx}) if err != nil { - return 0, fmt.Errorf("tournament arguments: %w", err) + return 0, fmt.Errorf("tournament descriptor: %w", err) } currentBlock, err := client.BlockNumber(ctx) @@ -125,10 +133,10 @@ func mineForTournamentTimeout( return 0, fmt.Errorf("block number: %w", err) } - finishBlock := args.StartInstant + args.Allowance - if finishBlock < args.StartInstant { // uint64 overflow + finishBlock := descriptor.StartInstant + descriptor.Allowance + if finishBlock < descriptor.StartInstant { // uint64 overflow return 0, fmt.Errorf("tournament timeout overflows: start=%d allowance=%d", - args.StartInstant, args.Allowance) + descriptor.StartInstant, descriptor.Allowance) } if currentBlock >= finishBlock { return 0, nil // Already past the timeout. @@ -205,19 +213,20 @@ func waitForTournamentAndCommitment( } // waitForTournamentWinner polls until the root tournament for the given epoch -// has a winner commitment. +// has a winner commitment and the contract's finish block is persisted. func waitForTournamentWinner( ctx context.Context, t testing.TB, require *require.Assertions, + client *ethclient.Client, appName string, epochIndex uint64, ) { t.Helper() tctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() var lastErr error + var winner *model.Tournament err := pollUntil(tctx, 5*time.Second, func() (bool, error) { resp, err := readTournaments(tctx, appName) if err != nil { @@ -229,17 +238,35 @@ func waitForTournamentWinner( return false, fmt.Errorf("poll tournament winner: %w", err) } tournament := findRootTournament(resp.Data, epochIndex) - return tournament != nil && tournament.WinnerCommitment != nil, nil + if tournament == nil || tournament.Snapshot.WinnerCommitment == nil || tournament.Snapshot.FinishedAtBlock == 0 { + return false, nil + } + winner = tournament + return true, nil }) if err != nil && lastErr != nil { err = fmt.Errorf("%w (last poll error: %v)", err, lastErr) } + cancel() require.NoError(err, "wait for epoch %d tournament winner", epochIndex) + + tournament, err := itournament.NewITournament(winner.Address, client) + require.NoError(err, "bind epoch %d root tournament", epochIndex) + descriptorCtx, descriptorCancel := context.WithTimeout(ctx, 30*time.Second) + defer descriptorCancel() + descriptor, err := tournament.TournamentDescriptor(&bind.CallOpts{Context: descriptorCtx}) + require.NoError(err, "read epoch %d root tournament descriptor", epochIndex) + expectedFinishedAt := descriptor.StartInstant + descriptor.Allowance + require.GreaterOrEqual(expectedFinishedAt, descriptor.StartInstant, + "epoch %d root tournament finish block must not overflow", epochIndex) + require.Equal(expectedFinishedAt, winner.Snapshot.FinishedAtBlock, + "epoch %d must persist the exact on-chain finish block", epochIndex) } -// settleTournament runs the full tournament cycle for a given epoch: -// wait for tournament+commitment, mine past the timeout, wait for winner. -func settleTournament( +// finalizePrtEpoch runs the uncontested PRT lifecycle for one epoch. It waits +// for the node commitment, finishes the root tournament, waits for the staged +// result to be accepted, and confirms recovery of the node-owned root bond. +func finalizePrtEpoch( ctx context.Context, t testing.TB, require *require.Assertions, @@ -248,7 +275,7 @@ func settleTournament( epochIndex uint64, ) { t.Helper() - defer timed(t, fmt.Sprintf("settle tournament epoch %d", epochIndex))() + defer timed(t, fmt.Sprintf("finalize PRT epoch %d", epochIndex))() t.Logf("Waiting for PRT to create a root tournament and join with a commitment for epoch %d...", epochIndex) @@ -262,10 +289,80 @@ func settleTournament( require.NoError(err, "mine for epoch %d tournament timeout", epochIndex) t.Logf(" mined %d blocks to reach timeout", blocksMined) - t.Logf("Waiting for the PRT service to settle epoch %d (uncontested single-commitment win)...", + t.Logf("Waiting for the PRT service to observe the epoch %d uncontested winner...", epochIndex) - waitForTournamentWinner(ctx, t, require, appName, epochIndex) - t.Logf(" epoch %d tournament settled — winner declared", epochIndex) + waitForTournamentWinner(ctx, t, require, client, appName, epochIndex) + t.Logf(" epoch %d tournament finished — winner declared", epochIndex) + + waitForPrtEpochAcceptedAndBondRecovered(ctx, t, require, client, appName, epochIndex, tournament.Address) +} + +// waitForPrtEpochAcceptedAndBondRecovered verifies the new Dave settlement +// boundary. A configured-block observation can skip the entire stage interval, +// so acceptance does not require a locally observed CLAIM_STAGED transition. +// The old root tournament bond must reach RECOVERED on chain. +func waitForPrtEpochAcceptedAndBondRecovered( + ctx context.Context, + t testing.TB, + require *require.Assertions, + client *ethclient.Client, + appName string, + epochIndex uint64, + tournamentAddress common.Address, +) *model.Epoch { + t.Helper() + + acceptedCtx, acceptedCancel := context.WithTimeout(ctx, claimAcceptedTimeout) + accepted, err := waitForEpochStatus( + acceptedCtx, t, appName, epochIndex, model.EpochStatus_ClaimAccepted) + acceptedCancel() + require.NoError(err, "wait for PRT epoch %d acceptance", epochIndex) + require.NotNil(accepted.ClaimTransactionHash, + "accepted PRT epoch %d must retain its EpochSealed transaction", epochIndex) + + recoveryCtx, recoveryCancel := context.WithTimeout(ctx, claimAcceptedTimeout) + defer recoveryCancel() + waitForRootBondRecovered(recoveryCtx, t, require, client, tournamentAddress) + t.Logf(" epoch %d accepted and root bond recovered", epochIndex) + return accepted +} + +func waitForRootBondRecovered( + ctx context.Context, + t testing.TB, + require *require.Assertions, + client *ethclient.Client, + tournamentAddress common.Address, +) { + t.Helper() + tournament, err := itournament.NewITournament(tournamentAddress, client) + require.NoError(err, "bind root tournament %s", tournamentAddress) + + var recoveredClaimer common.Address + var recoveredPayment *big.Int + err = pollUntil(ctx, 2*time.Second, func() (bool, error) { + recovery, err := tournament.BondRecovery(&bind.CallOpts{Context: ctx}) + if err != nil { + return false, fmt.Errorf("read root bond recovery: %w", err) + } + switch recovery.Disposition { + case bondDispositionRecovered: + recoveredClaimer = recovery.Claimer + recoveredPayment = recovery.Payment + return true, nil + case bondDispositionNoWinner: + return false, errors.New("root tournament finished without a recoverable winner") + case bondDispositionTournamentRunning, bondDispositionRecoverable: + return false, nil + default: + return false, fmt.Errorf("root tournament returned unknown bond disposition %d", recovery.Disposition) + } + }) + require.NoError(err, "wait for root bond recovery at %s", tournamentAddress) + require.Equal(common.Address{}, recoveredClaimer, + "recovered bond must clear the claimer") + require.NotNil(recoveredPayment, "recovered bond must return a payment value") + require.Zero(recoveredPayment.Sign(), "recovered bond must clear the pending payment") } // findRootTournament returns the root tournament for the given epoch index, diff --git a/test/integration/cli_helpers_test.go b/test/integration/cli_helpers_test.go index 1f350f91e..235f7fa63 100644 --- a/test/integration/cli_helpers_test.go +++ b/test/integration/cli_helpers_test.go @@ -1,8 +1,6 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) -//go:build endtoendtests - package integration import ( @@ -14,18 +12,32 @@ import ( "fmt" "os" "os/exec" + "slices" "strconv" "time" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/jsonrpc/api" "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" ) const cliBinary = "cartesi-rollups-cli" +const ( + deployCommand = "deploy" + jsonFlag = "--json" + prtFlag = "--prt" +) + +// The devnet funds these mnemonic accounts. Account 0 submits node claims; +// account 6 submits node PRT actions. Account 5 pays for ordinary test CLI +// transactions and relay calls, which the test driver runs sequentially. +const integrationCLIAccountIndex uint32 = 5 + // cliCommandTimeout is the maximum time a single CLI command may run before // being killed. This prevents a hanging command from consuming the entire // suite timeout. @@ -95,17 +107,19 @@ func runCLI(ctx context.Context, args ...string) (string, error) { return runCLIWithEnv(ctx, nil, args...) } -// runCLIWithEnv is like runCLI but allows appending environment variables -// to the subprocess. Used for selecting a non-default signer (e.g., -// CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1 when the guardian wallet differs -// from the node's default account). +// runCLIWithEnv keeps the test driver's transactions separate from node +// transactions. Overrides are applied last so tests can select a guardian, +// depositor, or other actor. The node's environment is never changed. func runCLIWithEnv(ctx context.Context, extraEnv []string, args ...string) (string, error) { cmdCtx, cancel := context.WithTimeout(ctx, cliCommandTimeout) defer cancel() cmd := exec.CommandContext(cmdCtx, cliBinary, args...) - if len(extraEnv) > 0 { - cmd.Env = append(os.Environ(), extraEnv...) - } + cmd.Env = append(os.Environ(), + "CARTESI_AUTH_KIND=mnemonic", + "CARTESI_AUTH_MNEMONIC="+ethutil.FoundryMnemonic, + fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", integrationCLIAccountIndex), + ) + cmd.Env = append(cmd.Env, extraEnv...) out, err := cmd.Output() if err != nil { var exitErr *exec.ExitError @@ -126,11 +140,27 @@ func runCLIWithEnv(ctx context.Context, extraEnv []string, args ...string) (stri // the application address from the JSON output. // Extra CLI flags (e.g., "--salt", value, "--prt") can be appended via extraArgs. func deployApplication(ctx context.Context, appName, dappPath string, extraArgs ...string) (string, error) { - args := []string{"deploy", "application", appName, dappPath, "--json"} - args = append(args, extraArgs...) + address, _, err := deployApplicationWithConsensus(ctx, appName, dappPath, extraArgs...) + return address, err +} + +// deployApplicationWithConsensus also returns the consensus address. The +// Authority owner remains the devnet Claimer (account 0), not the test payer. +// Explicit owner flags in extraArgs take precedence over this fixture default. +func deployApplicationWithConsensus( + ctx context.Context, + appName, dappPath string, + extraArgs ...string, +) (string, string, error) { + key, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + if err != nil { + return "", "", fmt.Errorf("derive devnet Authority owner: %w", err) + } + args := slices.Concat([]string{deployCommand, "application", appName, dappPath, jsonFlag, + "--authority-owner", crypto.PubkeyToAddress(key.PublicKey).Hex()}, extraArgs) out, err := runCLI(ctx, args...) if err != nil { - return "", fmt.Errorf("deploy: %w", err) + return "", "", fmt.Errorf("deploy: %w", err) } var app struct { @@ -138,9 +168,12 @@ func deployApplication(ctx context.Context, appName, dappPath string, extraArgs IConsensusAddress string `json:"iconsensus_address"` } if err := json.Unmarshal([]byte(out), &app); err != nil { - return "", fmt.Errorf("parse deploy output: %w", err) + return "", "", fmt.Errorf("parse deploy output: %w", err) + } + if app.IApplicationAddress == "" || app.IConsensusAddress == "" { + return "", "", fmt.Errorf("deploy output missing addresses: %s", out) } - return app.IApplicationAddress, nil + return app.IApplicationAddress, app.IConsensusAddress, nil } // disableApplication sets the application status to disabled so the node @@ -178,7 +211,7 @@ func inspectApplication(ctx context.Context, appName, payload string) (*inspectR // sendInput sends a payload to the application and returns (inputIndex, blockNumber). func sendInput(ctx context.Context, appName string, payload string) (uint64, uint64, error) { - out, err := runCLI(ctx, "send", appName, payload, "--yes", "--json") + out, err := runCLI(ctx, "send", appName, payload, "--yes", jsonFlag) if err != nil { return 0, 0, fmt.Errorf("send: %w", err) } @@ -264,11 +297,11 @@ func readInput(ctx context.Context, appName string, inputIndex uint64) (*model.I // executeOutput executes a voucher on L1 via the CLI. func executeOutput(ctx context.Context, appName string, index uint64) (string, error) { - out, err := runCLI(ctx, "execute", appName, strconv.FormatUint(index, 10), "--yes", "--json") + out, err := runCLI(ctx, "execute", appName, strconv.FormatUint(index, 10), "--yes", jsonFlag) if err != nil { return "", fmt.Errorf("execute: %w", err) } - var result cli.ExecuteResult + var result cli.TransactionResult if err := json.Unmarshal([]byte(out), &result); err != nil { return "", fmt.Errorf("parse execute output: %w", err) } diff --git a/test/integration/cli_helpers_unit_test.go b/test/integration/cli_helpers_unit_test.go new file mode 100644 index 000000000..851d600b0 --- /dev/null +++ b/test/integration/cli_helpers_unit_test.go @@ -0,0 +1,127 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" +) + +// These tests run without endtoendtests. The fake CLI checks the actual +// subprocess arguments and environment without starting a node or using L1. +func TestIntegrationCLI(t *testing.T) { + t.Run("signers", func(t *testing.T) { + installTestCLI(t, `printf '%s\n' "$CARTESI_AUTH_KIND" "$CARTESI_AUTH_MNEMONIC" \ +"$CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX" "$CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX" \ +"$CARTESI_DATABASE_CONNECTION" "$@"`) + const parentDatabase = "parent-database" + t.Setenv("CARTESI_AUTH_KIND", "private_key") + t.Setenv("CARTESI_AUTH_MNEMONIC", "parent mnemonic must not be used") + t.Setenv("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX", "0") + t.Setenv("CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX", "6") + t.Setenv("CARTESI_DATABASE_CONNECTION", parentDatabase) + for _, tc := range []struct { + name, index, database string + overrides []string + }{ + {name: "default payer", index: "5", database: parentDatabase}, + {name: "unrelated override", index: "5", database: "unavailable", overrides: []string{ + "CARTESI_DATABASE_CONNECTION=unavailable", + }}, + {name: "guardian", index: "1", database: parentDatabase, overrides: []string{ + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1", + }}, + {name: "depositor", index: "8", database: parentDatabase, overrides: []string{ + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=8", + }}, + {name: "refund payer", index: "9", database: parentDatabase, overrides: []string{ + "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=9", + }}, + } { + t.Run(tc.name, func(t *testing.T) { + before := os.Environ() + var out string + var err error + if tc.overrides == nil { + out, err = runCLI(t.Context(), "send", "app", "payload with spaces") + } else { + out, err = runCLIWithEnv(t.Context(), tc.overrides, "send", "app", "payload with spaces") + } + require.NoError(t, err) + require.Equal(t, []string{"mnemonic", ethutil.FoundryMnemonic, tc.index, "6", tc.database, + "send", "app", "payload with spaces"}, strings.Split(strings.TrimSpace(out), "\n")) + require.Equal(t, before, os.Environ(), "CLI execution must not change the node environment") + }) + } + }) + + t.Run("deployment", func(t *testing.T) { + installTestCLI(t, `printf '%s\n' "$CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX" "$@" > "$CARTESI_TEST_CLI_CAPTURE" +printf '%s\n' "$CARTESI_TEST_CLI_RESULT"`) + capture := filepath.Join(t.TempDir(), "arguments") + t.Setenv("CARTESI_TEST_CLI_CAPTURE", capture) + t.Setenv("CARTESI_TEST_CLI_RESULT", `{"iapplication_address":"application","iconsensus_address":"consensus"}`) + const claimerAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + const ownerOverride = "0x0000000000000000000000000000000000001234" + for _, tc := range []struct { + name, owner string + args []string + }{ + {name: "authority", owner: claimerAddress}, + {name: "prt", owner: claimerAddress, args: []string{prtFlag}}, + {name: "external consensus", owner: claimerAddress, args: []string{"--consensus", "external"}}, + {name: "explicit owner", owner: ownerOverride, args: []string{"--authority-owner", ownerOverride}}, + {name: "owner shorthand", owner: ownerOverride, args: []string{"-O", ownerOverride}}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, withConsensus := range []bool{false, true} { + var address string + var err error + if withConsensus { + var consensus string + address, consensus, err = deployApplicationWithConsensus(t.Context(), "app", "template", tc.args...) + require.NoError(t, err) + require.Equal(t, "consensus", consensus) + } else { + address, err = deployApplication(t.Context(), "app", "template", tc.args...) + require.NoError(t, err) + } + require.Equal(t, "application", address) + captured, err := os.ReadFile(capture) + require.NoError(t, err) + args := strings.Split(strings.TrimSpace(string(captured)), "\n") + require.Equal(t, []string{"5", deployCommand, "application"}, args[:3]) + flags := pflag.NewFlagSet("application", pflag.ContinueOnError) + owner := flags.StringP("authority-owner", "O", "", "") + flags.Bool("json", false, "") + flags.Bool("prt", false, "") + flags.String("consensus", "", "") + require.NoError(t, flags.Parse(args[3:])) + require.Equal(t, []string{"app", "template"}, flags.Args()) + require.Equal(t, tc.owner, *owner) + } + }) + } + for _, result := range []string{"{", `{}`, `{"iapplication_address":"application"}`} { + t.Setenv("CARTESI_TEST_CLI_RESULT", result) + _, _, err := deployApplicationWithConsensus(t.Context(), "app", "template") + require.Error(t, err, "incomplete deployment output must fail") + } + }) +} + +func installTestCLI(t *testing.T, script string) { + t.Helper() + dir := t.TempDir() + // The fixture contains no secrets and must be executable by the subprocess. + err := os.WriteFile(filepath.Join(dir, cliBinary), []byte("#!/bin/sh\nset -eu\n"+script+"\n"), 0o755) //nolint:gosec + require.NoError(t, err) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/test/integration/divergent_claim_test.go b/test/integration/divergent_claim_test.go index 997673051..1dd520b6c 100644 --- a/test/integration/divergent_claim_test.go +++ b/test/integration/divergent_claim_test.go @@ -7,8 +7,6 @@ package integration import ( "context" - "crypto/rand" - "encoding/json" "fmt" "math/big" "regexp" @@ -21,6 +19,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -36,8 +35,8 @@ import ( // 2. Send inputs 0 and 1 in distinct epochs; wait for legitimate ACCEPT. // 3. Stop the node so the attacker can race the pipeline deterministically. // 4. Send input 2 and mine past the 3rd epoch's last block. -// 5. Attacker submits a divergent claim for epoch 2 (random outputsMerkleRoot, -// reusing epoch 1's proof for valid-length argument bytes). The chain +// 5. Attacker submits a divergent claim for the third input epoch by +// reusing the second input epoch's complete machine-validity proof. The chain // emits ClaimSubmitted + ClaimStaged with the divergent machine root. // acceptClaim is intentionally NOT called — this models the realistic // attacker who pushes a single divergent claim and disappears. @@ -53,9 +52,9 @@ import ( // so the claimer cannot submit anything; only the read-only scan // pipeline runs. // 9. Re-register the same on-chain address as app B. -// 10. The reader-mode node replays inputs 0-2, finds epochs 0/1 -// legitimately accepted (reconciles), reaches CLAIM_COMPUTED for -// epoch 2, scans the chain, finds the divergent claim, and marks B +// 10. The reader-mode node replays inputs 0-2, finds the first two input +// epochs legitimately accepted (reconciles), reaches CLAIM_COMPUTED for +// the third input epoch, scans the chain, finds the divergent claim, and marks B // DIVERGED. The point of this phase is to confirm that the // divergence-detection path is independent of the submission path — // a node with no key (or a paranoid operator who has disabled @@ -63,8 +62,9 @@ import ( type DivergentClaimSuite struct { suite.Suite LogChecker - ctx context.Context - cancel context.CancelFunc + ctx context.Context + cancel context.CancelFunc + readerFixtureOwnsRestart bool } func TestDivergentClaim(t *testing.T) { @@ -82,15 +82,12 @@ func (s *DivergentClaimSuite) SetupSuite() { } func (s *DivergentClaimSuite) TearDownSuite() { - // Phase 2 brings the node up in reader mode. Subsequent suites expect - // the default (claim-submission-enabled) configuration, so always - // recycle the node here regardless of state. - if sharedNode != nil { - s.T().Log("Stopping reader-mode node before restoring default for subsequent suites...") - stopSharedNode(s.T()) + // Reader-mode cleanup restores the node after phase 2. A failure earlier + // in phase 1 can leave it stopped before that cleanup is registered. + if sharedNode == nil && !s.readerFixtureOwnsRestart { + s.T().Log("Restarting shared node in default mode for subsequent suites...") + startSharedNode(s.T()) } - s.T().Log("Restarting shared node in default mode for subsequent suites...") - startSharedNode(s.T()) s.cancel() } @@ -112,11 +109,8 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { // divergence reasons — Authority's submit-stage-accept lifecycle means // whichever scan (ClaimSubmitted or ClaimAccepted) lands first wins, // and both are terminal. The claimer's tick wraps the transition error - // and re-logs it, so we allow-list that too. Stopping the node mid- - // tick (Phase 1.5 and Phase 2 transitions) cancels in-flight RPC - // queries, producing a handful of evmreader ERR lines that are benign - // shutdown noise. The rapid mining can race the EVM reader's block - // fetcher; tolerate transient BlockOutOfRangeError. + // and re-logs it, so we allow-list that too. Rapid mining can race the + // EVM reader's block fetcher; tolerate transient BlockOutOfRangeError. s.SetExpectedLogs(s.T(), ExpectedLog{ Pattern: regexp.MustCompile( @@ -131,12 +125,6 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { Level: LevelError, Reason: "claimer Tick wraps and re-logs the divergence-induced DIVERGED error", }, - ExpectedLog{ - Pattern: regexp.MustCompile(`service=evm-reader.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from stopping the node mid-tick; " + - "retryablehttp wraps the cancellation as `Post \"\": context canceled`", - }, ExpectedLog{ Pattern: regexp.MustCompile(`BlockOutOfRangeError`), Level: LevelError, @@ -157,7 +145,7 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { const guardianIndex = 1 withdrawalConfigJSON, _ := withdrawalConfigForGuardian(s.T(), guardianIndex) - // ─── Phase 1: deploy and run epochs 0–1 to legitimate ACCEPT ──────── + // ─── Phase 1: accept the first two input epochs ────────────── s.T().Logf("--- Phase 1: deploy %s and accept two legitimate claims ---", appAName) appAddrStr, consensusAddrStr, err := deployApplicationWithConsensus(s.ctx, @@ -171,14 +159,14 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { "fund application contract") // Inputs 0 and 1 go through the normal flow so we can observe both the - // legitimate ClaimAccepted on chain AND grab a valid-length - // outputsMerkleProof from epoch 1 to reuse for the attack. - inputEpochs := make([]uint64, 0, 3) //nolint:mnd - for i := 0; i < 2; i++ { //nolint:mnd + // legitimate ClaimAccepted on chain AND grab a valid machine-state proof + // from the second input epoch to reuse for the attack. + inputEpochs := make([]uint64, 0, 3) + for i := 0; i < 2; i++ { payload := fmt.Sprintf("divergent-input-%d", i) idx, _, err := sendInput(s.ctx, appAName, payload) r.NoError(err, "send input %d", i) - r.Equal(uint64(i), idx) //nolint:gosec + r.Equal(uint64(i), idx) procCtx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) input, err := waitForInputProcessed(procCtx, s.T(), appAName, idx) @@ -187,6 +175,7 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { r.Equal(model.InputCompletionStatus_Accepted, input.Status) inputEpochs = append(inputEpochs, input.EpochIndex) + minePastEpochBoundary(s.ctx, s.T(), r, appAName, input.EpochIndex) claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) _, err = waitForEpochStatus(claimCtx, s.T(), appAName, input.EpochIndex, model.EpochStatus_ClaimAccepted) @@ -194,20 +183,20 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { r.NoError(err, "epoch %d → CLAIM_ACCEPTED", input.EpochIndex) s.T().Logf(" input %d processed; epoch %d ACCEPTED", i, input.EpochIndex) - // Mine to the next epoch boundary so input i+1 lands in a distinct epoch. - r.NoError(anvilMine(s.ctx, 15), "mine to next epoch") //nolint:mnd + // Advance beyond this input epoch before the next input. + r.NoError(anvilMine(s.ctx, 15), "advance beyond current input epoch") } - // Read epoch 1 to harvest a valid-length outputsMerkleProof — the - // IAuthority contract validates only the proof's length, not its - // semantic correctness, so we can splice it into the divergent payload. - epoch1, err := readEpoch(s.ctx, appAName, inputEpochs[1]) - r.NoError(err, "read epoch 1") - r.NotEmpty(epoch1.TxBufferProof, - "epoch 1 must have an outputs merkle proof to reuse for the attack") - epochLen := epoch1.LastBlock - epoch1.FirstBlock + 1 - s.T().Logf(" epoch length = %d blocks; epoch 1 proof = %d siblings", - epochLen, len(epoch1.TxBufferProof)) + // Read the second input epoch to harvest a complete proof for a valid accepted machine + // state. The attacker reuses this stale proof for a later epoch. The proof + // remains valid for its machine root, but it does not describe the target epoch. + proofSourceEpoch, err := readEpoch(s.ctx, appAName, inputEpochs[1]) + r.NoError(err, "read second input epoch") + r.True(proofSourceEpoch.HasCompleteStateProof(), + "second input epoch must have a complete machine-state proof to reuse for the attack") + epochLen := proofSourceEpoch.LastBlock - proofSourceEpoch.FirstBlock + 1 + s.T().Logf(" epoch length = %d blocks; proof source has %d siblings per leaf", + epochLen, len(proofSourceEpoch.TxBufferProof)) // ─── Phase 1.5: stop the node so the attacker cannot lose the race ── s.T().Log("--- Phase 1.5: stop node, then send input 2 and submit divergent claim ---") @@ -216,18 +205,18 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { // Send input 2 — it lands at whatever block anvil mines for the tx. idx2, block2, err := sendInput(s.ctx, appAName, "divergent-input-2") r.NoError(err, "send input 2") - r.Equal(uint64(2), idx2) //nolint:mnd,gosec + r.Equal(uint64(2), idx2) s.T().Logf(" input 2 sent at block %d", block2) // Compute the epoch input 2 landed in from its block number relative - // to epoch 1. Guard against the (unexpected) case where mining timing - // drifts and input 2 falls inside epoch 1 — that would underflow the + // to the proof source epoch. Guard against the unexpected case where mining + // timing drifts and input 2 falls inside that epoch. That would underflow the // uint64 subtraction and produce a nonsense target epoch. - r.Greater(block2, epoch1.LastBlock, + r.Greater(block2, proofSourceEpoch.LastBlock, "input 2 must land past epoch %d's last block (%d); got block %d", - inputEpochs[1], epoch1.LastBlock, block2) - targetEpochIndex := inputEpochs[1] + ((block2 - epoch1.LastBlock - 1) / epochLen) + 1 - targetEpochFirstBlock := epoch1.FirstBlock + (targetEpochIndex-inputEpochs[1])*epochLen + inputEpochs[1], proofSourceEpoch.LastBlock, block2) + targetEpochIndex := inputEpochs[1] + ((block2 - proofSourceEpoch.LastBlock - 1) / epochLen) + 1 + targetEpochFirstBlock := proofSourceEpoch.FirstBlock + (targetEpochIndex-inputEpochs[1])*epochLen targetEpochLastBlock := targetEpochFirstBlock + epochLen - 1 r.GreaterOrEqual(block2, targetEpochFirstBlock, "input 2 block %d must be inside epoch %d's window [%d, %d]", @@ -260,12 +249,11 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { authorityBinding, err := iauthority.NewIAuthority(consensusAddr, client) r.NoError(err, "bind iauthority") - divergentOutputs := randomBytes32(s.T()) - proof := merkleProofToBytes32(epoch1.TxBufferProof) - s.T().Logf(" attacker submitting divergent claim: lpbn=%d outputs=0x%x proof_siblings=%d", - targetEpochLastBlock, divergentOutputs, len(proof)) + machineRoot, proof := authorityMachineValidityProof(s.T(), proofSourceEpoch) + s.T().Logf(" attacker submitting stale machine proof: lpbn=%d machine_root=%s proof_siblings=%d", + targetEpochLastBlock, machineRoot.Hex(), len(proof.TxBufferProof.Siblings)) submitTx, err := authorityBinding.SubmitClaim(attackerOpts, appAddr, - new(big.Int).SetUint64(targetEpochLastBlock), divergentOutputs, proof) + new(big.Int).SetUint64(targetEpochLastBlock), machineRoot, proof) r.NoError(err, "attacker SubmitClaim") submitReceipt, err := bind.WaitMined(s.ctx, client, submitTx) r.NoError(err, "wait for divergent submitClaim tx to mine") @@ -288,7 +276,7 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { s.T().Log("--- Phase 1: restart node and wait for divergence-driven DIVERGED ---") startSharedNode(s.T()) - stateCtx, stateCancel := context.WithTimeout(s.ctx, 5*time.Minute) //nolint:mnd + stateCtx, stateCancel := context.WithTimeout(s.ctx, 5*time.Minute) r.NoError(waitForApplicationStatus(stateCtx, s.T(), appAName, "DIVERGED"), "A should reach DIVERGED after observing the divergent on-chain claim") stateCancel() @@ -317,12 +305,10 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { r.NoError(disableApplication(s.ctx, appAName), "disable %s before remove", appAName) r.NoError(removeApplication(s.ctx, appAName), "remove %s", appAName) - stopSharedNode(s.T()) - // CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false brings the claimer up - // in read-only mode: it computes claims locally and runs the scan path - // but never broadcasts a submitClaim tx. The divergence-detection path - // must still fire — that is the assertion of this phase. - startSharedNodeWithEnv(s.T(), "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false") + // The fixture sets matching saved/requested reader settings while stopped. + // This tests read-only replay, not a production submission-mode transition. + s.readerFixtureOwnsRestart = true + startReaderNode(s.ctx, s.T()) appBName := uniqueAppName("divergent-b") r.NoError(registerApplication(s.ctx, appBName, appAddrStr, dappPath), @@ -331,14 +317,14 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { // B has to replay all 3 inputs locally before it reaches the epoch // where the divergent claim sits. Wait for the same DIVERGED outcome. - for i := uint64(0); i < 3; i++ { //nolint:mnd + for i := uint64(0); i < 3; i++ { procCtx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) input, err := waitForInputProcessed(procCtx, s.T(), appBName, i) cancel() r.NoError(err, "B: wait for input %d", i) r.Equal(model.InputCompletionStatus_Accepted, input.Status) } - stateCtx, stateCancel = context.WithTimeout(s.ctx, 5*time.Minute) //nolint:mnd + stateCtx, stateCancel = context.WithTimeout(s.ctx, 5*time.Minute) r.NoError(waitForApplicationStatus(stateCtx, s.T(), appBName, "DIVERGED"), "B should reach DIVERGED via the read-only scan path") stateCancel() @@ -351,53 +337,26 @@ func (s *DivergentClaimSuite) TestDivergentClaimReplay() { s.T().Logf("=== Phase 2 complete: %s is DIVERGED in reader mode ===\n%s", appBName, statusB) } -// deployApplicationWithConsensus wraps deployApplication so the test also -// gets the on-chain Authority/IConsensus address — needed to bind the -// IAuthority contract for the attacker's direct submitClaim call. -func deployApplicationWithConsensus( - ctx context.Context, - appName, dappPath string, - extraArgs ...string, -) (appAddr string, consensusAddr string, err error) { - args := []string{"deploy", "application", appName, dappPath, "--json"} - args = append(args, extraArgs...) - out, err := runCLI(ctx, args...) - if err != nil { - return "", "", fmt.Errorf("deploy: %w", err) - } - var parsed struct { - IApplicationAddress string `json:"iapplication_address"` - IConsensusAddress string `json:"iconsensus_address"` - } - if err := json.Unmarshal([]byte(out), &parsed); err != nil { - return "", "", fmt.Errorf("parse deploy output: %w", err) - } - if parsed.IApplicationAddress == "" || parsed.IConsensusAddress == "" { - return "", "", fmt.Errorf("deploy output missing addresses: %s", out) - } - return parsed.IApplicationAddress, parsed.IConsensusAddress, nil -} - -// randomBytes32 returns 32 random bytes for use as a fake outputsMerkleRoot. -// The hash is deliberately arbitrary — the IAuthority contract performs no -// semantic check on it, so any 32-byte value is accepted, and the resulting -// machineMerkleRoot derived from it will not match the node's legitimate -// computation. -func randomBytes32(t testing.TB) [32]byte { +func authorityMachineValidityProof( + t testing.TB, + epoch *model.Epoch, +) (common.Hash, iauthority.MachineValidityProof) { t.Helper() - var b [32]byte - if _, err := rand.Read(b[:]); err != nil { - t.Fatalf("rand: %v", err) - } - return b -} -// merkleProofToBytes32 reshapes []common.Hash from the JSON-RPC API into the -// [][32]byte the abigen IAuthority.SubmitClaim binding expects. -func merkleProofToBytes32(in []common.Hash) [][32]byte { - out := make([][32]byte, len(in)) - for i, h := range in { - out[i] = h + stateProof, err := epoch.StateProof() + require.NoError(t, err, "epoch %d state proof", epoch.Index) + return stateProof.MachineHash, iauthority.MachineValidityProof{ + IflagsYProof: iauthority.LeafProof{ + DataBlock: stateProof.IflagsYDataBlock, + Siblings: stateProof.IflagsYProof, + }, + HtifTohostProof: iauthority.LeafProof{ + DataBlock: stateProof.HtifTohostDataBlock, + Siblings: stateProof.HtifTohostProof, + }, + TxBufferProof: iauthority.LeafProof{ + DataBlock: stateProof.TxBufferDataBlock, + Siblings: stateProof.TxBufferProof, + }, } - return out } diff --git a/test/integration/echo_authority_staging_test.go b/test/integration/echo_authority_staging_test.go index 34f230014..660f58b38 100644 --- a/test/integration/echo_authority_staging_test.go +++ b/test/integration/echo_authority_staging_test.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "testing" "time" @@ -16,9 +17,12 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) +const claimStagingPeriodFlag = "--claim-staging-period" + // EchoAuthorityStagingSuite exercises the non-fast-path claim flow by // deploying an Authority application with claimStagingPeriod >= 2. With a // non-zero staging period the chain forces COMPUTED → SUBMITTED → STAGED → @@ -60,24 +64,46 @@ func (s *EchoAuthorityStagingSuite) TearDownTest() { s.CheckLogs(s.T()) } -// TestEchoAuthorityStagingPath deploys with --claim-staging-period 5 and -// runs the full lifecycle. The 5-block period is large enough to make the -// STAGED state visible in node logs (the claim sits in STAGED until anvil -// advances 5 blocks past the staging tx) but small enough to keep the test -// short. The chain-side acceptClaim() will revert with -// ClaimStagingPeriodNotOverYet until the period elapses, which the claimer -// treats as transient until the next tick — the existing retry loop drives -// the transition once the period clears. +// TestEchoAuthorityStagingPath deploys with a non-zero claim staging period +// and runs the full lifecycle. The period is long enough for the test to +// observe CLAIM_STAGED before interval mining can make the claim acceptable. +// The test then advances the chain to the acceptance boundary. func (s *EchoAuthorityStagingSuite) TestEchoAuthorityStagingPath() { r := s.Require() dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") s.appName = uniqueAppName("echo-authority-staging") + const claimStagingPeriod uint64 = 30 runEchoLifecycleTest(s.ctx, s.T(), r, echoLifecycleConfig{ AppName: s.appName, DappPath: dappPath, Payload: "hello cartesi (staging)", - ExtraDeployArgs: []string{"--claim-staging-period", "5"}, + ExtraDeployArgs: []string{claimStagingPeriodFlag, strconv.FormatUint(claimStagingPeriod, 10)}, + PreClaimHook: func(ctx context.Context, t testing.TB, require *require.Assertions, appName string) { + input, err := readInput(ctx, appName, 0) + require.NoError(err, "read input 0 to find its epoch") + minePastEpochBoundary(ctx, t, require, appName, input.EpochIndex) + + stagedCtx, stagedCancel := context.WithTimeout(ctx, claimAcceptedTimeout) + defer stagedCancel() + epoch, err := waitForEpochStatus( + stagedCtx, t, appName, input.EpochIndex, model.EpochStatus_ClaimStaged) + require.NoError(err, "wait for authority claim to become CLAIM_STAGED") + require.NotNil(epoch.StagedAtBlock, "staged claim must record staged_at_block") + + client := newIntegrationEthClient(ctx, t) + defer client.Close() + currentBlock, err := client.BlockNumber(ctx) + require.NoError(err, "read block before claim acceptance") + acceptanceBlock := *epoch.StagedAtBlock + claimStagingPeriod + if currentBlock < acceptanceBlock { + blocksToMine := acceptanceBlock - currentBlock + require.LessOrEqual(blocksToMine, claimStagingPeriod, + "blocks to mine must fit within the configured staging period") + require.NoError(anvilMine(ctx, int(blocksToMine)), //nolint:gosec // Bounded by claimStagingPeriod. + "mine to the authority claim acceptance boundary") + } + }, }) // Pin the staging-path invariant: the epoch must have gone through @@ -128,7 +154,7 @@ func (s *EchoAuthorityStagingSuite) TestEchoAuthorityForecloseStagedClaim() { s.appName, dappPath, "--salt", uniqueSalt(), - "--claim-staging-period", claimStagingPeriod, + claimStagingPeriodFlag, claimStagingPeriod, "--withdrawal-config", withdrawalConfigJSON, ) r.NoError(err, "deploy foreclosable authority app") diff --git a/test/integration/echo_authority_transactions_test.go b/test/integration/echo_authority_transactions_test.go new file mode 100644 index 000000000..a3d905972 --- /dev/null +++ b/test/integration/echo_authority_transactions_test.go @@ -0,0 +1,118 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/model" +) + +// TestTransactionsWithoutDatabase keeps the node's database available, but gives +// each transaction command an invalid connection string. Only the node needs a +// database to process the input and publish its output proof. +func (s *EchoAuthoritySuite) TestTransactionsWithoutDatabase() { + r := s.Require() + s.appName = uniqueAppName("echo-no-cli-db") + dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") + address, err := deployApplication(s.ctx, s.appName, dappPath, "--salt", uniqueSalt()) + r.NoError(err) + r.NoError(anvilSetBalance(s.ctx, address, oneEtherWei)) + noDatabase := []string{"CARTESI_DATABASE_CONNECTION=invalid-database-connection"} + + // A wrong global InputBox must not override the Application contract. + sendEnv := append([]string{"CARTESI_CONTRACTS_INPUT_BOX_ADDRESS=0x0000000000000000000000000000000000000001"}, noDatabase...) + // Echo returns a voucher to the signer. Empty call data permits its EOA + // destination; nonempty call data would require a destination contract. + text, err := runCLIWithEnv(s.ctx, sendEnv, "send", address, "", "--yes", "--json") + r.NoError(err, "send by address without database access") + var sent cli.SendResult + r.NoError(json.Unmarshal([]byte(text), &sent)) + r.Equal("mined", sent.Status) + inputIndex, err := hexutil.DecodeUint64(sent.InputIndex) + r.NoError(err) + r.Zero(inputIndex) + r.NotEmpty(sent.TransactionHash) + + processCtx, cancelProcess := context.WithTimeout(s.ctx, inputProcessingTimeout) + defer cancelProcess() + input, err := waitForInputProcessed(processCtx, s.T(), s.appName, inputIndex) + r.NoError(err) + r.Equal(model.InputCompletionStatus_Accepted, input.Status) + minePastEpochBoundary(s.ctx, s.T(), r, s.appName, input.EpochIndex) + claimCtx, cancelClaim := context.WithTimeout(s.ctx, claimAcceptedTimeout) + defer cancelClaim() + _, err = waitForEpochStatus(claimCtx, s.T(), s.appName, input.EpochIndex, model.EpochStatus_ClaimAccepted) + r.NoError(err) + + // Select the same two fields as the documented jq export. The CLI reads + // them from the file during execution, with no database or node API lookup. + outputs, err := readOutputs(s.ctx, s.appName) + r.NoError(err) + var voucherIndex *uint64 + const voucherOutputType = "Voucher" + for _, output := range outputs.Data { + if output.DecodedData != nil && output.DecodedData.Type == voucherOutputType { + index := output.Index + voucherIndex = &index + break + } + } + r.NotNil(voucherIndex, "echo output must include a voucher") + indexText := strconv.FormatUint(*voucherIndex, 10) + text, err = runCLIWithEnv(s.ctx, noDatabase, "read", "outputs", address, indexText, "--jsonrpc") + r.NoError(err, "export proof through JSON-RPC without database access") + var exported struct { + Data map[string]json.RawMessage `json:"data"` + } + r.NoError(json.Unmarshal([]byte(text), &exported)) + r.Contains(exported.Data, "raw_data") + r.Contains(exported.Data, "output_hashes_siblings") + proof, err := json.Marshal(map[string]json.RawMessage{ + "raw_data": exported.Data["raw_data"], + "output_hashes_siblings": exported.Data["output_hashes_siblings"], + }) + r.NoError(err) + proofFile := filepath.Join(s.T().TempDir(), "output-proof.json") + r.NoError(os.WriteFile(proofFile, proof, 0o600)) + executeEnv := append([]string{"CARTESI_JSONRPC_API_URL=invalid-jsonrpc-url"}, noDatabase...) + + // A manual gas limit permits a call to an address with no code. Receipt + // status 1 must not be reported as output execution without its event. + client, err := ethclient.DialContext(s.ctx, envOrDefault("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", "http://localhost:8545")) + r.NoError(err) + defer client.Close() + const unusedAccountIndex = 9 + noCodeAddress := mnemonicAddress(s.T(), unusedAccountIndex) + code, err := client.CodeAt(s.ctx, noCodeAddress, nil) + r.NoError(err) + r.Empty(code, "the negative test requires an address with no contract code") + _, err = runCLIWithEnv(s.ctx, executeEnv, "execute", noCodeAddress.Hex(), indexText, + "--proof-file", proofFile, "--yes", "--json", "--gas-limit=500000") + r.ErrorContains(err, "no matching OutputExecuted event", "a mined no-op must not report output execution") + + text, err = runCLIWithEnv(s.ctx, executeEnv, "execute", address, indexText, "--proof-file", proofFile, "--yes", "--json") + r.NoError(err, "execute with supplied data without database or node API access") + var executed cli.TransactionResult + r.NoError(json.Unmarshal([]byte(text), &executed)) + r.Equal("mined", executed.Status) + r.NotEmpty(executed.TransactionHash) + execCtx, cancelExecution := context.WithTimeout(s.ctx, inputProcessingTimeout) + defer cancelExecution() + r.NoError(waitForExecutionRecorded(execCtx, s.T(), s.appName, *voucherIndex)) + + _, err = runCLIWithEnv(s.ctx, executeEnv, + "execute", address, indexText, "--proof-file", proofFile, "--yes", "--json", "--gas-limit=0") + r.ErrorContains(err, "OutputNotReexecutable", "the supplied proof must not permit replay") +} diff --git a/test/integration/echo_prt_sentries_test.go b/test/integration/echo_prt_sentries_test.go new file mode 100644 index 000000000..00e63cc00 --- /dev/null +++ b/test/integration/echo_prt_sentries_test.go @@ -0,0 +1,215 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strconv" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" +) + +// TestEchoPrtSentrySettlement deploys sentry slots through the CLI. External +// signers claim the locally computed state; the Go node stages and accepts it. +func (s *EchoPrtSuite) TestEchoPrtSentrySettlement() { + r := s.Require() + s.appName = uniqueAppName("echo-prt-sentries") + // The tournament helper mines many blocks while the node remains active. + s.SetExpectedLogs(s.T(), prtBlockOutOfRangeAllowlist) + const ( + claimStagingPeriod uint64 = 1000 + managerIndex uint32 = 9 + firstSentryIndex uint32 = 7 + secondSentryIndex uint32 = 8 + rotatedIndex uint32 = 4 + ) + // These accounts are distinct from the devnet claimer (0) and PRT signer (6). + manager := transactorForMnemonicIndex(s.ctx, s.T(), s.ethClient, managerIndex) + first := transactorForMnemonicIndex(s.ctx, s.T(), s.ethClient, firstSentryIndex) + second := transactorForMnemonicIndex(s.ctx, s.T(), s.ethClient, secondSentryIndex) + rotated := transactorForMnemonicIndex(s.ctx, s.T(), s.ethClient, rotatedIndex) + + runEchoLifecycleTest(s.ctx, s.T(), r, echoLifecycleConfig{ + AppName: s.appName, + DappPath: envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp"), + Payload: "prt-sentries", + ExtraDeployArgs: []string{ + prtFlag, claimStagingPeriodFlag, strconv.FormatUint(claimStagingPeriod, 10), + "--sentry-manager", manager.From.Hex(), "--sentries", first.From.Hex() + "," + second.From.Hex(), + }, + PreClaimHook: func(ctx context.Context, _ testing.TB, r *require.Assertions, appName string) { + dsn, err := config.GetDatabaseConnection() + r.NoError(err, "get database connection") + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + r.NoError(err, "open repository") + defer repo.Close() + app, err := repo.GetApplication(ctx, appName) + r.NoError(err, "read PRT application") + r.NotNil(app) + consensus, err := idaveconsensus.NewIDaveConsensus(app.IConsensusAddress, s.ethClient) + r.NoError(err, "bind Dave consensus") + period, err := consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: ctx}) + r.NoError(err, "read configured staging period") + r.Zero(period.Cmp(new(big.Int).SetUint64(claimStagingPeriod))) + originalSentries := []common.Address{first.From, second.From} + s.checkSentryInspection(app.IApplicationAddress, manager.From, originalSentries, "latest") + + // Epoch 0 is sealed empty at deployment. Epoch 1 contains the input. + s.finalizeEpochWithSentries(consensus, 0, claimStagingPeriod, first, second) + beforeRotation, err := s.ethClient.BlockNumber(ctx) + r.NoError(err, "read block before sentry rotation") + tx, err := consensus.RotateSentry(manager, first.From, rotated.From) + r.NoError(err, "rotate first sentry with the configured manager") + receipt := waitReceipt(ctx, s.T(), s.ethClient, tx) + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + oldID, err := consensus.GetSentryId(&bind.CallOpts{Context: ctx}, first.From) + r.NoError(err, "read retired sentry ID") + r.Zero(oldID.Sign()) + newID, err := consensus.GetSentryId(&bind.CallOpts{Context: ctx}, rotated.From) + r.NoError(err, "read replacement sentry ID") + r.Zero(newID.Cmp(big.NewInt(1)), "rotation must preserve the first slot") + s.checkSentryInspection(app.IApplicationAddress, manager.From, + []common.Address{rotated.From, second.From}, "latest") + s.checkSentryInspection(app.IApplicationAddress, manager.From, + originalSentries, strconv.FormatUint(beforeRotation, 10)) + s.finalizeEpochWithSentries(consensus, 1, claimStagingPeriod, rotated, second) + }, + }) +} + +func (s *EchoPrtSuite) finalizeEpochWithSentries( + consensus *idaveconsensus.IDaveConsensus, + epochIndex, claimStagingPeriod uint64, + first, second *bind.TransactOpts, +) { + s.T().Helper() + r := s.Require() + tournament := waitForTournamentAndCommitment(s.ctx, s.T(), r, s.appName, epochIndex) + _, err := mineForTournamentTimeout(s.ctx, s.ethClient, tournament.Address) + r.NoError(err, "mine past epoch %d tournament timeout", epochIndex) + waitForTournamentWinner(s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex) + + stagedCtx, cancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + staged, err := waitForEpochStatus(stagedCtx, s.T(), s.appName, epochIndex, model.EpochStatus_ClaimStaged) + cancel() + r.NoError(err, "wait for epoch %d staging", epochIndex) + r.NotNil(staged.StagedAtBlock) + r.NotNil(staged.MachineHash, "sentries need the state computed by the validator") + acceptanceBlock := *staged.StagedAtBlock + claimStagingPeriod + commitments, err := readCommitments(s.ctx, s.appName) + r.NoError(err, "read the node commitment") + commitment := findCommitmentForEpoch(commitments.Data, epochIndex) + r.NotNil(commitment) + r.NotEqual(commitment.SubmitterAddress, first.From, "the first sentry must not use the PRT signer") + r.NotEqual(commitment.SubmitterAddress, second.From, "the second sentry must not use the PRT signer") + + // Use local computation, not the staged hash read from the consensus contract. + tx, err := consensus.SubmitSentryClaim(first, new(big.Int).SetUint64(epochIndex), *staged.MachineHash) + r.NoError(err, "submit the first sentry claim for epoch %d", epochIndex) + firstReceipt := waitReceipt(s.ctx, s.T(), s.ethClient, tx) + r.Equal(types.ReceiptStatusSuccessful, firstReceipt.Status) + callOpts := &bind.CallOpts{Context: s.ctx, BlockNumber: firstReceipt.BlockNumber} + canAccept, err := consensus.CanAcceptStagedTournamentResult(callOpts) + r.NoError(err, "read acceptance conditions after the first sentry claim") + r.Zero(canAccept.EpochNumber.Cmp(new(big.Int).SetUint64(epochIndex))) + r.True(canAccept.IsTournamentResultStaged) + r.False(canAccept.DoAllSentriesAgreeWithStagedTournamentResult, "one of two sentries is not sufficient") + r.False(canAccept.IsClaimStagingPeriodOver) + r.Less(firstReceipt.BlockNumber.Uint64(), acceptanceBlock) + claimCount, err := consensus.GetSentryClaimCount(callOpts, new(big.Int).SetUint64(epochIndex), *staged.MachineHash) + r.NoError(err, "read the first sentry claim count") + r.Zero(claimCount.Cmp(big.NewInt(1))) + + tx, err = consensus.SubmitSentryClaim(second, new(big.Int).SetUint64(epochIndex), *staged.MachineHash) + r.NoError(err, "submit the second sentry claim for epoch %d", epochIndex) + secondReceipt := waitReceipt(s.ctx, s.T(), s.ethClient, tx) + r.Equal(types.ReceiptStatusSuccessful, secondReceipt.Status) + callOpts.BlockNumber = secondReceipt.BlockNumber + canAccept, err = consensus.CanAcceptStagedTournamentResult(callOpts) + r.NoError(err, "read acceptance conditions after both sentry claims") + r.Zero(canAccept.EpochNumber.Cmp(new(big.Int).SetUint64(epochIndex))) + r.True(canAccept.IsTournamentResultStaged) + r.True(canAccept.DoAllSentriesAgreeWithStagedTournamentResult) + r.False(canAccept.IsClaimStagingPeriodOver) + + // The test sends no acceptance transaction. The live Go node must accept. + accepted := waitForPrtEpochAcceptedAndBondRecovered( + s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex, tournament.Address) + r.Equal(staged.StagedAtBlock, accepted.StagedAtBlock) + receipt, err := s.ethClient.TransactionReceipt(s.ctx, *accepted.ClaimTransactionHash) + r.NoError(err, "read the node acceptance receipt") + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + r.Less(receipt.BlockNumber.Uint64(), acceptanceBlock, "all sentries must permit acceptance before the deadline") + tx, pending, err := s.ethClient.TransactionByHash(s.ctx, receipt.TxHash) + r.NoError(err, "read the node acceptance transaction") + r.False(pending) + sender, err := types.Sender(types.LatestSignerForChainID(tx.ChainId()), tx) + r.NoError(err, "recover acceptance sender") + r.Equal(commitment.SubmitterAddress, sender, "the node PRT signer must submit acceptance") +} + +type inspectedSentry struct { + ID uint64 `json:"id"` + Address string `json:"address"` +} + +type sentryInspection struct { + Manager string `json:"sentry_manager"` + Count uint64 `json:"num_sentries"` + Sentries []inspectedSentry `json:"sentries"` +} + +func (s *EchoPrtSuite) checkSentryInspection(app, manager common.Address, sentries []common.Address, block string) { + s.T().Helper() + r := s.Require() + expected := make([]inspectedSentry, len(sentries)) + for i, address := range sentries { + expected[i] = inspectedSentry{ID: uint64(i) + 1, Address: address.Hex()} + } + for _, command := range []string{"consensus", "summary"} { + args := []string{"contract", command, app.Hex(), "--block", block} + text, err := runCLI(s.ctx, append(args, "--json")...) + r.NoError(err, "inspect sentries with contract %s at %s", command, block) + data := []byte(text) + if command == "summary" { + var summary struct { + Consensus json.RawMessage `json:"consensus"` + ConsensusError string `json:"consensus_error"` + } + r.NoError(json.Unmarshal(data, &summary)) + r.Empty(summary.ConsensusError) + r.NotEmpty(summary.Consensus) + data = summary.Consensus + } + var got sentryInspection + r.NoError(json.Unmarshal(data, &got)) + r.Equal(manager.Hex(), got.Manager) + r.Equal(uint64(len(sentries)), got.Count) + r.Equal(expected, got.Sentries, "the roster must retain slot IDs and address order") + + text, err = runCLI(s.ctx, args...) + r.NoError(err, "inspect sentries in contract %s text output", command) + text = strings.Join(strings.Fields(text), " ") + r.Contains(text, "Sentry Manager "+manager.Hex()) + r.Contains(text, fmt.Sprintf("Sentries %d", len(sentries))) + for _, sentry := range expected { + r.Contains(text, fmt.Sprintf("Sentry #%d %s", sentry.ID, sentry.Address)) + } + } +} diff --git a/test/integration/echo_prt_staging_test.go b/test/integration/echo_prt_staging_test.go new file mode 100644 index 000000000..28537202a --- /dev/null +++ b/test/integration/echo_prt_staging_test.go @@ -0,0 +1,123 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "math/big" + "strconv" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// TestEchoPrtStagingPath keeps the node running through the complete staging +// interval. The test mines blocks but never submits an acceptance transaction. +func (s *EchoPrtSuite) TestEchoPrtStagingPath() { + r := s.Require() + s.appName = uniqueAppName("echo-prt-staging") + const claimStagingPeriod uint64 = 300 + + runEchoLifecycleTest(s.ctx, s.T(), r, echoLifecycleConfig{ + AppName: s.appName, + DappPath: envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp"), + Payload: "prt-staging", + ExtraDeployArgs: []string{ + prtFlag, claimStagingPeriodFlag, strconv.FormatUint(claimStagingPeriod, 10), + }, + PreClaimHook: func(ctx context.Context, _ testing.TB, r *require.Assertions, appName string) { + dsn, err := config.GetDatabaseConnection() + r.NoError(err, "get database connection") + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + r.NoError(err, "open repository") + defer repo.Close() + app, err := repo.GetApplication(ctx, appName) + r.NoError(err, "read PRT application") + consensus, err := idaveconsensus.NewIDaveConsensus(app.IConsensusAddress, s.ethClient) + r.NoError(err, "bind Dave consensus") + period, err := consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: ctx}) + r.NoError(err, "read configured staging period") + r.Zero(period.Cmp(new(big.Int).SetUint64(claimStagingPeriod)), "the configured staging period must match") + + // Epoch 0 is sealed empty at deployment. Epoch 1 contains the input. + for _, epochIndex := range []uint64{0, 1} { + s.finalizeEpochAfterStagingPeriod(consensus, epochIndex, claimStagingPeriod) + } + }, + }) +} + +func (s *EchoPrtSuite) finalizeEpochAfterStagingPeriod( + consensus *idaveconsensus.IDaveConsensus, + epochIndex, claimStagingPeriod uint64, +) { + s.T().Helper() + r := s.Require() + tournament := waitForTournamentAndCommitment(s.ctx, s.T(), r, s.appName, epochIndex) + _, err := mineForTournamentTimeout(s.ctx, s.ethClient, tournament.Address) + r.NoError(err, "mine past epoch %d tournament timeout", epochIndex) + waitForTournamentWinner(s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex) + + stagedCtx, stagedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + staged, err := waitForEpochStatus(stagedCtx, s.T(), s.appName, epochIndex, model.EpochStatus_ClaimStaged) + stagedCancel() + r.NoError(err, "wait for epoch %d staging", epochIndex) + r.NotNil(staged.StagedAtBlock, "the node must persist the staging block") + + currentBlock, err := s.ethClient.BlockNumber(s.ctx) + r.NoError(err, "read block before claim acceptance") + acceptanceBlock := *staged.StagedAtBlock + claimStagingPeriod + r.Less(currentBlock, acceptanceBlock, "the test must observe the epoch before acceptance is permitted") + callOpts := &bind.CallOpts{Context: s.ctx, BlockNumber: new(big.Int).SetUint64(currentBlock)} + sealed, err := consensus.GetCurrentSealedEpoch(callOpts) + r.NoError(err, "read staged epoch on chain") + r.Zero(sealed.EpochNumber.Cmp(new(big.Int).SetUint64(epochIndex)), "the epoch must not be accepted early") + r.True(sealed.IsTournamentResultStaged) + r.Zero(sealed.StagingBlockNumber.Cmp(new(big.Int).SetUint64(*staged.StagedAtBlock)), + "the persisted staging block must match the contract") + canAccept, err := consensus.CanAcceptStagedTournamentResult(callOpts) + r.NoError(err, "read acceptance conditions before the boundary") + r.False(canAccept.IsClaimStagingPeriodOver) + r.False(canAccept.DoAllSentriesAgreeWithStagedTournamentResult, "this test must not use the sentry fast path") + + root, err := itournament.NewITournament(tournament.Address, s.ethClient) + r.NoError(err, "bind root tournament") + recovery, err := root.BondRecovery(callOpts) + r.NoError(err, "read root bond owner before acceptance") + r.Equal(uint8(bondDispositionRecoverable), recovery.Disposition) + commitments, err := readCommitments(s.ctx, s.appName) + r.NoError(err, "read the node commitment") + commitment := findCommitmentForEpoch(commitments.Data, epochIndex) + r.NotNil(commitment) + r.Equal(commitment.SubmitterAddress, recovery.Claimer, "the node must own the root bond") + + // Only advance time. The live node must submit acceptance and recover its bond. + blocksToMine := acceptanceBlock - currentBlock + r.LessOrEqual(blocksToMine, claimStagingPeriod) + r.NoError(anvilMine(s.ctx, int(blocksToMine)), //nolint:gosec // Bounded by claimStagingPeriod. + "mine to the PRT acceptance boundary") + accepted := waitForPrtEpochAcceptedAndBondRecovered( + s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex, tournament.Address) + r.Equal(staged.StagedAtBlock, accepted.StagedAtBlock, "acceptance must preserve the staging block") + + receipt, err := s.ethClient.TransactionReceipt(s.ctx, *accepted.ClaimTransactionHash) + r.NoError(err, "read the node acceptance receipt") + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + r.GreaterOrEqual(receipt.BlockNumber.Uint64(), acceptanceBlock, "acceptance must not occur before the period ends") + tx, pending, err := s.ethClient.TransactionByHash(s.ctx, receipt.TxHash) + r.NoError(err, "read the node acceptance transaction") + r.False(pending) + sender, err := types.Sender(types.LatestSignerForChainID(tx.ChainId()), tx) + r.NoError(err, "recover acceptance sender") + r.Equal(commitment.SubmitterAddress, sender, "the node PRT signer must submit acceptance") +} diff --git a/test/integration/echo_prt_test.go b/test/integration/echo_prt_test.go index 92f89fc42..8058e17fb 100644 --- a/test/integration/echo_prt_test.go +++ b/test/integration/echo_prt_test.go @@ -70,10 +70,10 @@ func (s *EchoPrtSuite) TestEchoPrtLifecycle() { AppName: s.appName, DappPath: dappPath, Payload: "prt-hello", - ExtraDeployArgs: []string{"--prt"}, + ExtraDeployArgs: []string{prtFlag}, PreClaimHook: func(ctx context.Context, t testing.TB, require *require.Assertions, appName string) { - settleTournament(ctx, t, require, ethClient, appName, 0) - settleTournament(ctx, t, require, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 1) }, }) diff --git a/test/integration/echo_quorum_test.go b/test/integration/echo_quorum_test.go index e471294d6..885a70253 100644 --- a/test/integration/echo_quorum_test.go +++ b/test/integration/echo_quorum_test.go @@ -96,10 +96,11 @@ func (s *EchoQuorumSuite) TearDownTest() { } func (s *EchoQuorumSuite) TestEchoQuorumLifecycle() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) r := s.Require() app := s.deployQuorumEchoApp("echo-quorum-lifecycle") - epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum lifecycle)") + epoch := s.prepareQuorumEpochThroughRelay(app, "hello cartesi (quorum lifecycle)") outputsResp, err := readOutputs(s.ctx, app.appName) r.NoError(err, "read quorum lifecycle outputs") @@ -139,8 +140,8 @@ func (s *EchoQuorumSuite) TestEchoQuorumLifecycle() { submittedCancel() r.NoError(err, "wait for node to submit quorum claim") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, epoch) s.waitForQuorumAccepted(app.appName, epoch.Index) verifyClaimAndExecute(s.ctx, s.T(), r, verifyAndExecuteConfig{ @@ -154,6 +155,7 @@ func (s *EchoQuorumSuite) TestEchoQuorumLifecycle() { } func (s *EchoQuorumSuite) TestNodeVoteFirstThenOtherValidatorsStageAndAccept() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) app := s.deployQuorumEchoApp("echo-quorum-node-first") epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum node first)") @@ -162,8 +164,8 @@ func (s *EchoQuorumSuite) TestNodeVoteFirstThenOtherValidatorsStageAndAccept() { submittedCancel() s.Require().NoError(err, "wait for node to submit quorum claim") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, epoch) s.waitForQuorumAccepted(app.appName, epoch.Index) } @@ -172,11 +174,7 @@ func (s *EchoQuorumSuite) TestExternalValidatorThenNodeVoteStagesAndAccepts() { if !isNodeSelfManaged() { s.T().Skip("skipping: validator-order test requires test-managed node to slow claimer polling") } - s.SetExpectedLogs(s.T(), ExpectedLog{ - Pattern: regexp.MustCompile(`service=.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from restarting the shared node with different claimer polling", - }) + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) stopSharedNode(s.T()) startSharedNodeWithEnv(s.T(), "CARTESI_CLAIMER_POLLING_INTERVAL=3600") @@ -193,7 +191,7 @@ func (s *EchoQuorumSuite) TestExternalValidatorThenNodeVoteStagesAndAccepts() { s.Require().Equal(model.EpochStatus_ClaimComputed, epoch.Status, "node should compute the claim before the slowed claimer polling interval submits it") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) stopSharedNode(s.T()) startSharedNode(s.T()) @@ -206,11 +204,7 @@ func (s *EchoQuorumSuite) TestExternalMajorityStagesBeforeNodeVoteThenNodeAccept if !isNodeSelfManaged() { s.T().Skip("skipping: external-majority test requires test-managed node to slow claimer polling") } - s.SetExpectedLogs(s.T(), ExpectedLog{ - Pattern: regexp.MustCompile(`service=.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from restarting the shared node with different claimer polling", - }) + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) stopSharedNode(s.T()) startSharedNodeWithEnv(s.T(), "CARTESI_CLAIMER_POLLING_INTERVAL=3600") @@ -227,8 +221,8 @@ func (s *EchoQuorumSuite) TestExternalMajorityStagesBeforeNodeVoteThenNodeAccept s.Require().Equal(model.EpochStatus_ClaimComputed, epoch.Status, "node should compute the claim before the slowed claimer polling interval submits it") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, epoch) stopSharedNode(s.T()) startSharedNode(s.T()) @@ -238,23 +232,23 @@ func (s *EchoQuorumSuite) TestExternalMajorityStagesBeforeNodeVoteThenNodeAccept } func (s *EchoQuorumSuite) TestDivergentMinorityVoteDoesNotBlockAcceptance() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) app := s.deployQuorumEchoApp("echo-quorum-divergent-minority") - epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum divergent minority)") - - submittedCtx, submittedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) - epoch, err := waitForEpochStatus(submittedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimSubmitted) - submittedCancel() - s.Require().NoError(err, "wait for node to submit quorum claim") + staleEpoch, epoch := s.prepareQuorumDivergence( + app, + "hello cartesi (quorum minority source)", + "hello cartesi (quorum divergent minority)", + ) - divergentOutputs := randomOutputsMerkleRoot(s.T(), *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, divergentOutputs) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, staleEpoch) s.waitForQuorumAccepted(app.appName, epoch.Index) } func (s *EchoQuorumSuite) TestDivergentMajorityMarksApplicationDiverged() { s.SetExpectedLogs(s.T(), + anvilBlockOutOfRangeAllowlist, ExpectedLog{ Pattern: regexp.MustCompile(`claim divergence detected.*quorum_divergence_at_staging`), Level: LevelError, @@ -268,19 +262,17 @@ func (s *EchoQuorumSuite) TestDivergentMajorityMarksApplicationDiverged() { ) app := s.deployQuorumEchoApp("echo-quorum-outvoted") - epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (quorum outvoted)") - - submittedCtx, submittedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) - epoch, err := waitForEpochStatus(submittedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimSubmitted) - submittedCancel() - s.Require().NoError(err, "wait for node to submit quorum claim") + staleEpoch, epoch := s.prepareQuorumDivergence( + app, + "hello cartesi (quorum majority source)", + "hello cartesi (quorum outvoted)", + ) - divergentOutputs := randomOutputsMerkleRoot(s.T(), *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, divergentOutputs) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, divergentOutputs) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, staleEpoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, staleEpoch) rejectedCtx, rejectedCancel := context.WithTimeout(s.ctx, 5*time.Minute) - epoch, err = waitForEpochStatus(rejectedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimRejected) + _, err := waitForEpochStatus(rejectedCtx, s.T(), app.appName, epoch.Index, model.EpochStatus_ClaimRejected) rejectedCancel() s.Require().NoError(err, "wait for outvoted quorum epoch to become CLAIM_REJECTED") @@ -298,6 +290,7 @@ func (s *EchoQuorumSuite) TestDivergentMajorityMarksApplicationDiverged() { } func (s *EchoQuorumSuite) TestForecloseQuorumClaimBeforeAcceptanceMarksClaimForeclosed() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) r := s.Require() builderEnv := os.Getenv("CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS") @@ -359,13 +352,14 @@ func (s *EchoQuorumSuite) TestForecloseQuorumClaimBeforeAcceptanceMarksClaimFore } func (s *EchoQuorumSuite) TestForecloseQuorumOutputExecutionAfterForeclosureIsRecorded() { + s.SetExpectedLogs(s.T(), anvilBlockOutOfRangeAllowlist) r := s.Require() const guardianIndex = 1 withdrawalConfigJSON, _ := withdrawalConfigForGuardian(s.T(), guardianIndex) app := s.deployQuorumEchoApp("foreclose-quorum-output", "--withdrawal-config", withdrawalConfigJSON) - epoch := s.prepareQuorumEpoch(app.appName, "hello cartesi (foreclose quorum output)") + epoch := s.prepareQuorumEpochThroughRelay(app, "hello cartesi (foreclose quorum output)") outputsResp, err := readOutputs(s.ctx, app.appName) r.NoError(err, "read outputs") @@ -377,8 +371,8 @@ func (s *EchoQuorumSuite) TestForecloseQuorumOutputExecutionAfterForeclosureIsRe submittedCancel() r.NoError(err, "wait for node to submit quorum claim") - s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexA, epoch) + s.submitQuorumClaim(app, epoch, quorumValidatorIndexB, epoch) s.waitForQuorumAccepted(app.appName, epoch.Index) r.NoError(guardianForeclose(s.ctx, app.appName, guardianIndex), "guardian foreclose") @@ -433,7 +427,7 @@ func (s *EchoQuorumSuite) deployQuorumEchoApp(prefix string, extraApplicationArg "deploy", "quorum", "--json", "--salt", uniqueSalt(), - "--claim-staging-period", strconv.FormatUint(quorumClaimStagingPeriod, 10), + claimStagingPeriodFlag, strconv.FormatUint(quorumClaimStagingPeriod, 10), } for _, validator := range validators { quorumArgs = append(quorumArgs, "--validator", validator.Hex()) @@ -480,11 +474,32 @@ func (s *EchoQuorumSuite) deployQuorumEchoApp(prefix string, extraApplicationArg } func (s *EchoQuorumSuite) prepareQuorumEpoch(appName string, payload string) *model.Epoch { + return s.prepareQuorumEpochAtInput(appName, payload, 0) +} + +func (s *EchoQuorumSuite) prepareQuorumEpochAtInput(appName string, payload string, expectedInputIndex uint64) *model.Epoch { r := s.Require() inputIndex, blockNum, err := sendInput(s.ctx, appName, payload) r.NoError(err, "send input") - r.Equal(uint64(0), inputIndex) + return s.completeQuorumEpochPreparation(appName, inputIndex, blockNum, expectedInputIndex) +} + +func (s *EchoQuorumSuite) prepareQuorumEpochThroughRelay(app quorumAppDeployment, payload string) *model.Epoch { + inputIndex, blockNum, relayAddress := sendInputThroughRelay(s.ctx, s.T(), app.appAddress, payload) + s.T().Logf(" quorum input sent through relay %s", relayAddress) + return s.completeQuorumEpochPreparation(app.appName, inputIndex, blockNum, 0) +} + +func (s *EchoQuorumSuite) completeQuorumEpochPreparation( + appName string, + inputIndex uint64, + blockNum uint64, + expectedInputIndex uint64, +) *model.Epoch { + r := s.Require() + + r.Equal(expectedInputIndex, inputIndex) s.T().Logf(" quorum input accepted on-chain: index=%d block=%d", inputIndex, blockNum) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -498,6 +513,46 @@ func (s *EchoQuorumSuite) prepareQuorumEpoch(appName string, payload string) *mo return s.waitForEpochWithClaim(appName, input.EpochIndex) } +func (s *EchoQuorumSuite) prepareQuorumDivergence( + app quorumAppDeployment, + sourcePayload string, + targetPayload string, +) (*model.Epoch, *model.Epoch) { + sourceEpoch := s.prepareQuorumEpoch(app.appName, sourcePayload) + + sourceCtx, sourceCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + sourceEpoch, err := waitForEpochStatus( + sourceCtx, + s.T(), + app.appName, + sourceEpoch.Index, + model.EpochStatus_ClaimSubmitted, + ) + sourceCancel() + s.Require().NoError(err, "wait for node to submit source quorum claim") + + s.submitQuorumClaim(app, sourceEpoch, quorumValidatorIndexA, sourceEpoch) + s.submitQuorumClaim(app, sourceEpoch, quorumValidatorIndexB, sourceEpoch) + s.waitForQuorumAccepted(app.appName, sourceEpoch.Index) + + targetEpoch := s.prepareQuorumEpochAtInput(app.appName, targetPayload, 1) + targetCtx, targetCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + targetEpoch, err = waitForEpochStatus( + targetCtx, + s.T(), + app.appName, + targetEpoch.Index, + model.EpochStatus_ClaimSubmitted, + ) + targetCancel() + s.Require().NoError(err, "wait for node to submit target quorum claim") + s.Require().NotEqual(sourceEpoch.Index, targetEpoch.Index, "source and target claims must use different epochs") + s.Require().NotEqual(*sourceEpoch.MachineHash, *targetEpoch.MachineHash, + "source proof must diverge from the target machine root") + + return sourceEpoch, targetEpoch +} + func (s *EchoQuorumSuite) waitForEpochAvailable(appName string, epochIndex uint64) *model.Epoch { ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) defer cancel() @@ -540,7 +595,7 @@ func (s *EchoQuorumSuite) waitForEpochWithClaim(appName string, epochIndex uint6 } return false, fmt.Errorf("poll epoch %d claim: %w", epochIndex, err) } - if epoch.TxBufferDataBlock != nil && epoch.MachineHash != nil && isQuorumClaimReadyStatus(epoch.Status) { + if epoch.HasCompleteStateProof() && isQuorumClaimReadyStatus(epoch.Status) { result = epoch return true, nil } @@ -596,12 +651,12 @@ func (s *EchoQuorumSuite) minePastBlock(block uint64) { func (s *EchoQuorumSuite) submitQuorumClaim( app quorumAppDeployment, - epoch *model.Epoch, + claimedEpoch *model.Epoch, accountIndex uint32, - outputsMerkleRoot [32]byte, + proofEpoch *model.Epoch, ) common.Hash { r := s.Require() - r.NotNil(epoch.TxBufferDataBlock, "epoch %d missing outputs merkle root", epoch.Index) + machineRoot, proof := quorumMachineValidityProof(s.T(), proofEpoch) key, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, accountIndex) r.NoError(err, "derive validator key %d", accountIndex) @@ -613,20 +668,41 @@ func (s *EchoQuorumSuite) submitQuorumClaim( tx, err := app.quorum.SubmitClaim( opts, app.appAddress, - new(big.Int).SetUint64(epoch.LastBlock), - outputsMerkleRoot, - merkleProofToBytes32(epoch.TxBufferProof), + new(big.Int).SetUint64(claimedEpoch.LastBlock), + machineRoot, + proof, ) r.NoError(err, "validator %d submit quorum claim", accountIndex) receipt, err := bind.WaitMined(s.ctx, s.client, tx) r.NoError(err, "wait for validator %d quorum submit tx", accountIndex) r.Equal(uint64(1), receipt.Status, "validator %d quorum submit tx must succeed", accountIndex) - s.T().Logf(" validator mnemonic[%d] submitClaim mined in block %d tx=%s", - accountIndex, receipt.BlockNumber.Uint64(), tx.Hash().Hex()) + s.T().Logf(" validator mnemonic[%d] submitClaim mined in block %d tx=%s proof_epoch=%d", + accountIndex, receipt.BlockNumber.Uint64(), tx.Hash().Hex(), proofEpoch.Index) return tx.Hash() } +func quorumMachineValidityProof(t testing.TB, epoch *model.Epoch) (common.Hash, iquorum.MachineValidityProof) { + t.Helper() + + stateProof, err := epoch.StateProof() + require.NoError(t, err, "epoch %d state proof", epoch.Index) + return stateProof.MachineHash, iquorum.MachineValidityProof{ + IflagsYProof: iquorum.LeafProof{ + DataBlock: stateProof.IflagsYDataBlock, + Siblings: stateProof.IflagsYProof, + }, + HtifTohostProof: iquorum.LeafProof{ + DataBlock: stateProof.HtifTohostDataBlock, + Siblings: stateProof.HtifTohostProof, + }, + TxBufferProof: iquorum.LeafProof{ + DataBlock: stateProof.TxBufferDataBlock, + Siblings: stateProof.TxBufferProof, + }, + } +} + func quorumValidatorAddresses(t testing.TB) []common.Address { t.Helper() indexes := []uint32{quorumNodeValidatorIndex, quorumValidatorIndexA, quorumValidatorIndexB} @@ -638,13 +714,3 @@ func quorumValidatorAddresses(t testing.TB) []common.Address { } return addresses } - -func randomOutputsMerkleRoot(t testing.TB, legitimate common.Hash) [32]byte { - t.Helper() - for { - outputs := randomBytes32(t) - if common.Hash(outputs) != legitimate { - return outputs - } - } -} diff --git a/test/integration/foreclose_prt_test.go b/test/integration/foreclose_prt_test.go index a3e801dfe..1cfb627d2 100644 --- a/test/integration/foreclose_prt_test.go +++ b/test/integration/foreclose_prt_test.go @@ -16,6 +16,7 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" @@ -120,8 +121,8 @@ func (s *ForeclosePrtSuite) TestForeclosePrtLifecycle() { "--withdrawal-config", withdrawalConfigJSON, }, PreClaimHook: func(ctx context.Context, t testing.TB, r *require.Assertions, appName string) { - settleTournament(ctx, t, r, ethClient, appName, 0) - settleTournament(ctx, t, r, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, r, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, r, ethClient, appName, 1) }, }) s.T().Log("=== Pre-foreclosure PRT lifecycle complete ===") @@ -229,7 +230,7 @@ func (s *ForeclosePrtSuite) TestForeclosePrtBeforeTournamentSettlementStopsParti // the root tournament for the input-carrying epoch, then foreclose before // that tournament reaches a winner. if input.EpochIndex > 0 { - settleTournament(s.ctx, s.T(), r, s.ethClient, s.appName, 0) + finalizePrtEpoch(s.ctx, s.T(), r, s.ethClient, s.appName, 0) } tournament := waitForTournamentAndCommitment(s.ctx, s.T(), r, s.appName, input.EpochIndex) @@ -277,8 +278,8 @@ func (s *ForeclosePrtSuite) TestForeclosePrtOutputExecutionAfterForeclosureIsRec r.NoError(anvilSetBalance(s.ctx, appAddr, oneEtherWei), "fund application contract") - inputIndex, _, err := sendInput(s.ctx, s.appName, "foreclose PRT output execution") - r.NoError(err, "send input") + inputIndex, _, _ := sendInputThroughRelay( + s.ctx, s.T(), common.HexToAddress(appAddr), "foreclose PRT output execution") r.Equal(uint64(0), inputIndex) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -293,7 +294,7 @@ func (s *ForeclosePrtSuite) TestForeclosePrtOutputExecutionAfterForeclosureIsRec voucherIdx := firstVoucherOutputIndex(s.T(), outputsResp.Data) for epochIndex := uint64(0); epochIndex <= input.EpochIndex; epochIndex++ { - settleTournament(s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex) + finalizePrtEpoch(s.ctx, s.T(), r, s.ethClient, s.appName, epochIndex) } claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) @@ -347,7 +348,7 @@ func (s *ForeclosePrtSuite) TestForeclosePrtReregisterReplay() { r.Equal(model.InputCompletionStatus_Accepted, inputA.Status) for epochIndex := uint64(0); epochIndex <= inputA.EpochIndex; epochIndex++ { - settleTournament(s.ctx, s.T(), r, s.ethClient, appAName, epochIndex) + finalizePrtEpoch(s.ctx, s.T(), r, s.ethClient, appAName, epochIndex) } claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) diff --git a/test/integration/foreclose_replay_test.go b/test/integration/foreclose_replay_test.go index 860f9aa26..497e88ed1 100644 --- a/test/integration/foreclose_replay_test.go +++ b/test/integration/foreclose_replay_test.go @@ -16,6 +16,7 @@ import ( "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -152,6 +153,7 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplay() { // Wait for every epoch to reach CLAIM_ACCEPTED on chain. for _, ep := range distinctEpochs { + minePastEpochBoundary(s.ctx, s.T(), r, appAName, ep) claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) epoch, err := waitForEpochStatus(claimCtx, s.T(), appAName, ep, model.EpochStatus_ClaimAccepted) claimCancel() @@ -252,11 +254,6 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplayReaderMode() { r := s.Require() s.SetExpectedLogs(s.T(), - ExpectedLog{ - Pattern: regexp.MustCompile(`service=evm-reader.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from switching the shared node into reader mode", - }, ExpectedLog{ Pattern: regexp.MustCompile(`BlockOutOfRangeError`), Level: LevelError, @@ -287,6 +284,7 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplayReaderMode() { r.NoError(err, "wait for input") r.Equal(model.InputCompletionStatus_Accepted, input.Status) + minePastEpochBoundary(s.ctx, s.T(), r, appAName, input.EpochIndex) claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) _, err = waitForEpochStatus(claimCtx, s.T(), appAName, input.EpochIndex, model.EpochStatus_ClaimAccepted) claimCancel() @@ -301,17 +299,7 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplayReaderMode() { r.NoError(disableApplication(s.ctx, appAName), "disable A before remove") r.NoError(removeApplication(s.ctx, appAName), "remove A") - readerMode := false - defer func() { - if readerMode { - stopSharedNode(s.T()) - startSharedNode(s.T()) - } - }() - - stopSharedNode(s.T()) - startSharedNodeWithEnv(s.T(), "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false") - readerMode = true + startReaderNode(s.ctx, s.T()) appBName := uniqueAppName("foreclose-reader-b") r.NoError(registerApplication(s.ctx, appBName, appAddr, dappPath), @@ -337,10 +325,6 @@ func (s *ForecloseReplaySuite) TestForecloseReregisterReplayReaderMode() { r.NoError(err, "read B status") r.Equal("OK", firstStatusLine(status)) r.Contains(status, "Enabled: true") - - stopSharedNode(s.T()) - startSharedNode(s.T()) - readerMode = false } func (s *ForecloseReplaySuite) TestOutputExecutionAfterForeclosureReplaysOnReregisteredApp() { @@ -365,8 +349,8 @@ func (s *ForecloseReplaySuite) TestOutputExecutionAfterForeclosureReplaysOnRereg r.NoError(anvilSetBalance(s.ctx, appAddr, oneEtherWei), "fund application contract") - inputIndex, _, err := sendInput(s.ctx, appAName, "foreclose output replay") - r.NoError(err, "send input") + inputIndex, _, _ := sendInputThroughRelay( + s.ctx, s.T(), common.HexToAddress(appAddr), "foreclose output replay") r.Equal(uint64(0), inputIndex) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -390,6 +374,7 @@ func (s *ForecloseReplaySuite) TestOutputExecutionAfterForeclosureReplaysOnRereg } r.True(voucherFound, "voucher output not found") + minePastEpochBoundary(s.ctx, s.T(), r, appAName, input.EpochIndex) claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) _, err = waitForEpochStatus(claimCtx, s.T(), appAName, input.EpochIndex, model.EpochStatus_ClaimAccepted) claimCancel() diff --git a/test/integration/foreclose_test.go b/test/integration/foreclose_test.go index eebcde93f..a187124e7 100644 --- a/test/integration/foreclose_test.go +++ b/test/integration/foreclose_test.go @@ -321,8 +321,8 @@ func (s *ForecloseSuite) TestOutputExecutionAfterForeclosureIsRecorded() { require.NoError(anvilSetBalance(s.ctx, appAddr, oneEtherWei), "fund application contract") - inputIndex, _, err := sendInput(s.ctx, s.appName, "execute after foreclosure") - require.NoError(err, "send input") + inputIndex, _, _ := sendInputThroughRelay( + s.ctx, s.T(), common.HexToAddress(appAddr), "execute after foreclosure") require.Equal(uint64(0), inputIndex) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -402,8 +402,7 @@ func (s *ForecloseSuite) TestSameBlockInputForecloseAndOutputOrdering() { "fund application contract") appAddr := common.HexToAddress(appAddrString) - inputIndex, _, err := sendInput(s.ctx, s.appName, "same-block setup") - require.NoError(err, "send setup input") + inputIndex, _, _ := sendInputThroughRelay(s.ctx, s.T(), appAddr, "same-block setup") require.Equal(uint64(0), inputIndex) processCtx, processCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) diff --git a/test/integration/input_relay_helpers_test.go b/test/integration/input_relay_helpers_test.go new file mode 100644 index 000000000..b20fe8cf9 --- /dev/null +++ b/test/integration/input_relay_helpers_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + _ "embed" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// Generated from testdata/InputRelay.sol with solc 0.8.30, no optimizer: +// +// solc --abi --bin testdata/InputRelay.sol +// +// The static artifacts keep the integration tests independent of a local +// Solidity toolchain. Regenerate both files when InputRelay.sol changes. +// +//go:embed testdata/input_relay_abi.json +var inputRelayABIJSON string + +//go:embed testdata/input_relay_bytecode.hex +var inputRelayBytecode string + +// sendInputThroughRelay deploys a contract that forwards one input to the +// InputBox. The echo DApp then addresses its voucher to this contract. The +// relay's payable fallback accepts the non-empty voucher payload under the v3 +// application target-code rule. +func sendInputThroughRelay( + ctx context.Context, + t testing.TB, + appAddress common.Address, + payload string, +) (uint64, uint64, common.Address) { + t.Helper() + r := require.New(t) + r.NotEqual(common.Address{}, appAddress, "application address must be non-zero") + r.NotEmpty(payload, "relay input payload must be non-empty") + + client := newIntegrationEthClient(ctx, t) + defer client.Close() + inputBoxAddr := inputBoxAddress(t) + inputBox, err := iinputbox.NewIInputBox(inputBoxAddr, client) + r.NoError(err, "bind InputBox") + + parsed, err := abi.JSON(strings.NewReader(inputRelayABIJSON)) + r.NoError(err, "parse InputRelay ABI") + deployOpts := transactorForMnemonicIndex(ctx, t, client, integrationCLIAccountIndex) + deployOpts.GasLimit = 3_000_000 + relayAddress, deployTx, relay, err := bind.DeployContract( + deployOpts, + parsed, + common.FromHex(strings.TrimSpace(inputRelayBytecode)), + client, + inputBoxAddr, + ) + r.NoError(err, "deploy InputRelay") + + receiptCtx, receiptCancel := context.WithTimeout(ctx, 30*time.Second) + deployReceipt := waitReceipt(receiptCtx, t, client, deployTx) + receiptCancel() + r.Equal(types.ReceiptStatusSuccessful, deployReceipt.Status, + "InputRelay deployment reverted in tx %s", deployTx.Hash()) + + submitOpts := transactorForMnemonicIndex(ctx, t, client, integrationCLIAccountIndex) + submitOpts.GasLimit = 2_000_000 + submitTx, err := relay.Transact(submitOpts, "addInput", appAddress, []byte(payload)) + r.NoError(err, "submit input through InputRelay") + receiptCtx, receiptCancel = context.WithTimeout(ctx, 30*time.Second) + submitReceipt := waitReceipt(receiptCtx, t, client, submitTx) + receiptCancel() + r.Equal(types.ReceiptStatusSuccessful, submitReceipt.Status, + "InputRelay submission reverted in tx %s", submitTx.Hash()) + + var matchingInputs []*iinputbox.IInputBoxInputAdded + for _, rawLog := range submitReceipt.Logs { + if rawLog.Address != inputBoxAddr { + continue + } + event, err := inputBox.ParseInputAdded(*rawLog) + if err == nil && event.AppContract == appAddress { + matchingInputs = append(matchingInputs, event) + } + } + r.Len(matchingInputs, 1, "relay transaction must emit one InputAdded event for the application") + event := matchingInputs[0] + r.Equal(submitTx.Hash(), event.Raw.TxHash, "InputAdded event must belong to the relay transaction") + r.True(event.Index.IsUint64(), "input index must fit uint64") + + return event.Index.Uint64(), submitReceipt.BlockNumber.Uint64(), relayAddress +} diff --git a/test/integration/lifecycle_test.go b/test/integration/lifecycle_test.go index 696a0c6d5..fe2db133e 100644 --- a/test/integration/lifecycle_test.go +++ b/test/integration/lifecycle_test.go @@ -19,6 +19,8 @@ import ( "github.com/cartesi/rollups-node/internal/jsonrpc/api" "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/stretchr/testify/require" ) @@ -72,11 +74,13 @@ func runEchoLifecycleTest(ctx context.Context, t testing.TB, require *require.As defer timed(t, "full echo lifecycle")() deployArgs := append([]string{"--salt", uniqueSalt()}, cfg.ExtraDeployArgs...) + var appAddress common.Address func() { defer timed(t, "deploy echo-dapp")() appAddr, err := deployApplication(ctx, cfg.AppName, cfg.DappPath, deployArgs...) require.NoError(err, "deploy echo-dapp") + appAddress = common.HexToAddress(appAddr) t.Logf(" application deployed at %s", appAddr) err = anvilSetBalance(ctx, appAddr, oneEtherWei) @@ -87,10 +91,9 @@ func runEchoLifecycleTest(ctx context.Context, t testing.TB, require *require.As // --- L1 -> Machine: send input and wait for processing --- t.Logf("Sending input payload=%q to the echo-dapp on L1", cfg.Payload) - inputIndex, blockNum, err := sendInput(ctx, cfg.AppName, cfg.Payload) - require.NoError(err, "send input") + inputIndex, blockNum, relayAddress := sendInputThroughRelay(ctx, t, appAddress, cfg.Payload) require.Equal(uint64(0), inputIndex) - t.Logf(" input accepted on-chain: index=%d block=%d", inputIndex, blockNum) + t.Logf(" input accepted on-chain through relay %s: index=%d block=%d", relayAddress, inputIndex, blockNum) func() { defer timed(t, "wait for input processing")() @@ -123,6 +126,10 @@ func runEchoLifecycleTest(ctx context.Context, t testing.TB, require *require.As case "Voucher": voucherIdx = out.Index voucherFound = true + require.Equal(relayAddress, common.HexToAddress(out.DecodedData.Destination), + "voucher destination must be the input relay") + require.Equal(hexutil.Encode([]byte(cfg.Payload)), out.DecodedData.Payload, + "voucher payload must match the relayed input") case "DelegateCallVoucher": delegateVoucherFound = true case "Notice": @@ -202,11 +209,13 @@ func runRejectExceptionLifecycleTest( defer timed(t, fmt.Sprintf("full %s lifecycle", cfg.TestName))() deployArgs := append([]string{"--salt", uniqueSalt()}, cfg.ExtraDeployArgs...) + var appAddress common.Address func() { defer timed(t, fmt.Sprintf("deploy %s-loop-dapp", cfg.TestName))() appAddr, err := deployApplication(ctx, cfg.AppName, cfg.DappPath, deployArgs...) require.NoError(err, "deploy %s-loop-dapp", cfg.TestName) + appAddress = common.HexToAddress(appAddr) t.Logf(" application deployed at %s", appAddr) err = anvilSetBalance(ctx, appAddr, oneEtherWei) @@ -225,8 +234,14 @@ func runRejectExceptionLifecycleTest( const numInputs = 3 for i := range numInputs { payload := fmt.Sprintf("%s-payload-%d", cfg.TestName, i) - idx, blockNum, err := sendInput(ctx, cfg.AppName, payload) - require.NoError(err, "send input %d", i) + var idx, blockNum uint64 + if i == 0 && !terminal { + idx, blockNum, _ = sendInputThroughRelay(ctx, t, appAddress, payload) + } else { + var err error + idx, blockNum, err = sendInput(ctx, cfg.AppName, payload) + require.NoError(err, "send input %d", i) + } require.Equal(uint64(i), idx, "input index mismatch") t.Logf(" input %d sent at block %d (payload=%q)", i, blockNum, payload) } diff --git a/test/integration/logscanner_test.go b/test/integration/logscanner_test.go index 63932f9a2..a391f4fea 100644 --- a/test/integration/logscanner_test.go +++ b/test/integration/logscanner_test.go @@ -70,6 +70,15 @@ type ExpectedLog struct { Required bool // if true, absence of a matching line fails the test } +// Opt in only for tests that mine blocks in bulk. Anvil can reject historical +// reads while these tests advance the chain. Keep the state and receipt checks: +// allowing this log does not prove that the node recovered from the read error. +var anvilBlockOutOfRangeAllowlist = ExpectedLog{ + Pattern: regexp.MustCompile(`BlockOutOfRangeError: block height is [0-9]+ but requested was [0-9]+`), + Level: LevelError, + Reason: "transient Anvil historical-read error during test-controlled block mining", +} + // LogChecker is an embeddable helper for test suites that captures a time // window and an expectation list, then scans node logs per test in // TearDownTest. diff --git a/test/integration/logscanner_unit_test.go b/test/integration/logscanner_unit_test.go new file mode 100644 index 000000000..79366eb1b --- /dev/null +++ b/test/integration/logscanner_unit_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestIntegrationLogScanner(t *testing.T) { + const anvilError = "BlockOutOfRangeError: block height is 3051 but requested was 3040" + for _, tc := range []struct { + name string + message string + allow bool + matched bool + }{ + {name: "Anvil error with allowance", message: anvilError, allow: true, matched: true}, + {name: "Anvil error without allowance", message: anvilError}, + {name: "shutdown cancellation", message: "context canceled", allow: true}, + {name: "query deadline", message: "context deadline exceeded", allow: true}, + {name: "database error", message: "connection refused", allow: true}, + {name: "provider error", message: "missing trie node", allow: true}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Now().Truncate(time.Millisecond) + line := now.Format(nodeLogTimeFmt) + ` ERR Tick service=claimer error="` + tc.message + `"` + path := filepath.Join(t.TempDir(), "node.log") + require.NoError(t, os.WriteFile(path, []byte(line+"\n"), 0600)) + t.Setenv("CARTESI_TEST_NODE_LOG_FILE", path) + + var checker LogChecker + if tc.allow { + checker.SetExpectedLogs(t, anvilBlockOutOfRangeAllowlist) + } + unexpected, unmatched := scanNodeLogsBetween(t, now, now, checker.expectedLogs) + if tc.matched { + require.Empty(t, unexpected) + } else { + require.Equal(t, []string{line}, unexpected) + } + if tc.allow && !tc.matched { + require.Equal(t, []int{0}, unmatched) + } else { + require.Empty(t, unmatched) + } + }) + } +} diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 2388eb0b5..b70e726c3 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -28,6 +28,16 @@ import ( // skipped only when an external node is already running (not test-managed). func TestMain(m *testing.M) { flag.Parse() + // Cleanup failure tests use child processes with simulated callbacks only. + // Never start or stop the shared node in those children. + if os.Getenv(observerCleanupExitEnv) != "" { + run := flag.Lookup("test.run") + if run == nil || run.Value.String() != observerCleanupTestPattern { + fmt.Fprintln(os.Stderr, "cleanup subprocess must select only the cleanup test") + os.Exit(2) + } + os.Exit(m.Run()) + } if testing.Short() { fmt.Fprintln(os.Stderr, "skipping integration tests in short mode") os.Exit(0) diff --git a/test/integration/multi_app_test.go b/test/integration/multi_app_test.go index 8610e5127..36849674b 100644 --- a/test/integration/multi_app_test.go +++ b/test/integration/multi_app_test.go @@ -7,11 +7,15 @@ package integration import ( "context" + "encoding/json" "testing" "time" "github.com/cartesi/rollups-node/internal/jsonrpc/api" "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + jsonrpcclient "github.com/cartesi/rollups-node/pkg/jsonrpc/client" + "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/suite" ) @@ -90,16 +94,33 @@ func (s *MultiAppSuite) TestMultiAppIsolation() { payload2 := "input-for-app-2" defer timed(s.T(), "full multi-app isolation test")() + client := newIntegrationEthClient(s.ctx, s.T()) + defer client.Close() + rpc := jsonrpcclient.NewClient(envOrDefault("CARTESI_JSONRPC_API_URL", "http://localhost:10011/rpc")) + for _, app := range []struct{ name, address string }{ + {s.app1Name, s.app1Addr}, + {s.app2Name, s.app2Addr}, + } { + inputBox, err := ethutil.GetInputBox(s.ctx, client, common.HexToAddress(app.address)) + require.NoError(err, "read %s input box from the contract", app.name) + + var response api.SingleResponse[map[string]json.RawMessage] + err = rpc.Call(s.ctx, "cartesi_getApplication", api.GetApplicationParams{Application: app.name}, &response) + require.NoError(err, "read %s through JSON-RPC", app.name) + require.NotContains(response.Data, "data_availability") + var storedInputBox common.Address + require.NoError(json.Unmarshal(response.Data["iinputbox_address"], &storedInputBox)) + require.Equal(inputBox, storedInputBox) + } + // --- L1 -> Machine: send one input to each app and verify independent processing --- s.T().Log("Sending one input to each app — they should process independently with separate input indices...") - idx1, _, err := sendInput(s.ctx, s.app1Name, payload1) - require.NoError(err, "send input to app-1") + idx1, _, _ := sendInputThroughRelay(s.ctx, s.T(), common.HexToAddress(s.app1Addr), payload1) require.Equal(uint64(0), idx1) s.T().Logf(" app-1: input sent (index=%d)", idx1) - idx2, _, err := sendInput(s.ctx, s.app2Name, payload2) - require.NoError(err, "send input to app-2") + idx2, _, _ := sendInputThroughRelay(s.ctx, s.T(), common.HexToAddress(s.app2Addr), payload2) require.Equal(uint64(0), idx2, "app-2 should start at input index 0 independently") s.T().Logf(" app-2: input sent (index=%d) — independent counter, also starts at 0", idx2) @@ -177,8 +198,6 @@ func (s *MultiAppSuite) TestMultiAppIsolation() { // --- Consensus + L1 execution for both apps independently --- - client := newIntegrationEthClient(s.ctx, s.T()) - defer client.Close() var maxLastBlock uint64 for _, app := range []struct { name string diff --git a/test/integration/multinode_helpers_test.go b/test/integration/multinode_helpers_test.go index 063875265..b4c9f5245 100644 --- a/test/integration/multinode_helpers_test.go +++ b/test/integration/multinode_helpers_test.go @@ -12,7 +12,6 @@ import ( "net/http" "os" "os/exec" - "sync" "testing" "time" ) @@ -59,18 +58,9 @@ var multiNodeListenAddrs = []string{ ":10012", // inspect API } -type multiNodeProcess struct { - name string - cmd *exec.Cmd - done chan struct{} - - mu sync.Mutex - waitErr error -} - // multiNode is a running host multiprocess deployment. type multiNode struct { - procs []*multiNodeProcess + procs []*nodeSubprocess addrs []string logFile *os.File tail *exec.Cmd // tail -f process streaming the log to the terminal @@ -137,18 +127,11 @@ func startMultiNode(logPath string, extraEnv ...string) (*multiNode, error) { } cmd.Dir = workDir } - if err := cmd.Start(); err != nil { + proc, err := startNodeSubprocess(svc.name, cmd) + if err != nil { mn.stop(nil) return nil, fmt.Errorf("start %s: %w", svc.name, err) } - proc := &multiNodeProcess{name: svc.name, cmd: cmd, done: make(chan struct{})} - go func() { - err := cmd.Wait() - proc.mu.Lock() - proc.waitErr = err - proc.mu.Unlock() - close(proc.done) - }() fmt.Fprintf(os.Stderr, " started %s (telemetry %s, pid %d)\n", svc.name, svc.telemetryAddr, cmd.Process.Pid) mn.procs = append(mn.procs, proc) @@ -183,7 +166,7 @@ func (mn *multiNode) waitForHealth(ctx context.Context, _ testing.TB) error { if err != nil { return false, nil } - resp, err := client.Do(req) //nolint:gosec // url is a fixed localhost telemetry port + resp, err := client.Do(req) if err != nil { return false, nil } @@ -200,27 +183,13 @@ func (mn *multiNode) waitForHealth(ctx context.Context, _ testing.TB) error { func (mn *multiNode) exitedProcessError() error { for _, proc := range mn.procs { - if exited, err := proc.exitStatus(); exited { - if err != nil { - return fmt.Errorf("%s exited unexpectedly: %w", proc.name, err) - } - return fmt.Errorf("%s exited unexpectedly", proc.name) + if err := proc.exitedProcessError(); err != nil { + return err } } return nil } -func (p *multiNodeProcess) exitStatus() (bool, error) { - select { - case <-p.done: - p.mu.Lock() - defer p.mu.Unlock() - return true, p.waitErr - default: - return false, nil - } -} - // stop interrupts every service subprocess (in reverse start order) and waits // for it to exit, then stops the log tail and closes the log file. func (mn *multiNode) stop(t testing.TB) { @@ -260,21 +229,3 @@ func (mn *multiNode) stop(t testing.TB) { mn.logFile.Close() } } - -func (p *multiNodeProcess) isDone() bool { - select { - case <-p.done: - return true - default: - return false - } -} - -func (p *multiNodeProcess) wait(timeout time.Duration) bool { - select { - case <-p.done: - return true - case <-time.After(timeout): - return false - } -} diff --git a/test/integration/node_helpers_test.go b/test/integration/node_helpers_test.go index 10f0164ad..78f9f5066 100644 --- a/test/integration/node_helpers_test.go +++ b/test/integration/node_helpers_test.go @@ -72,11 +72,9 @@ func startSharedNode(t testing.TB) { } // startSharedNodeWithEnv is like startSharedNode but also lets the caller -// inject extra environment variables (e.g., -// CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false to bring the node up in -// reader mode for a single test phase). Restore default mode on test -// teardown by stopping the node and calling startSharedNode again. Under the -// multiprocess topology this starts/stops the whole service set. +// inject extra environment variables. Values must agree with saved settings; +// use startReaderNode for isolated reader-mode fixtures. Under the multiprocess +// topology this starts/stops the whole service set. func startSharedNodeWithEnv(t testing.TB, extraEnv ...string) { if sharedNode != nil { t.Fatal("cannot start node: already running") @@ -117,7 +115,7 @@ func nodePortAvailable() bool { // nodeProcess represents a running node subprocess managed by the test. type nodeProcess struct { - cmd *exec.Cmd + *nodeSubprocess logFile *os.File tail *exec.Cmd // tail -f process for live log streaming tty *os.File // /dev/tty FD used by tail; closed in stop() @@ -127,8 +125,8 @@ type nodeProcess struct { // to the given log file path. The node inherits the current environment // (database connection, blockchain endpoint, etc.) and additionally sets // fast polling intervals for test responsiveness. Any extraEnv entries are -// appended last, so they win against the suite defaults (useful for, e.g., -// CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false reader-mode tests). +// appended last, so they win against the suite defaults. Saved service settings +// must also match; environment changes alone cannot change submission mode. // // A background `tail -f` process streams the log file to the terminal so // the user can see node output in real time. This must be a separate process @@ -144,7 +142,7 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) return nil, fmt.Errorf("open log file %s: %w", logPath, err) } - cmd := exec.Command(nodeBinary) //nolint:gosec + cmd := exec.Command(nodeBinary) cmd.Stdout = logFile cmd.Stderr = logFile if workDir := os.Getenv("CARTESI_TEST_NODE_WORKDIR"); workDir != "" { @@ -162,7 +160,8 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) ) cmd.Env = append(cmd.Env, extraEnv...) - if err := cmd.Start(); err != nil { + proc, err := startNodeSubprocess(nodeBinary, cmd) + if err != nil { logFile.Close() return nil, fmt.Errorf("start node: %w", err) } @@ -171,9 +170,9 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) // We write to /dev/tty to bypass go test and gotestsum's output capture, // so the user sees node logs in real time just like the old Makefile did. // Falls back silently if /dev/tty is not available (e.g., CI, compose). - tty, ttyErr := os.OpenFile("/dev/tty", os.O_WRONLY, 0) //nolint:gosec + tty, ttyErr := os.OpenFile("/dev/tty", os.O_WRONLY, 0) if ttyErr != nil { - return &nodeProcess{cmd: cmd, logFile: logFile}, nil + return &nodeProcess{nodeSubprocess: proc, logFile: logFile}, nil } tail := exec.Command("tail", "-f", logPath) //nolint:gosec @@ -181,10 +180,10 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) tail.Stderr = tty if err := tail.Start(); err != nil { tty.Close() - return &nodeProcess{cmd: cmd, logFile: logFile}, nil + return &nodeProcess{nodeSubprocess: proc, logFile: logFile}, nil } - return &nodeProcess{cmd: cmd, logFile: logFile, tail: tail, tty: tty}, nil + return &nodeProcess{nodeSubprocess: proc, logFile: logFile, tail: tail, tty: tty}, nil } // waitForHealth polls the node's readyz endpoint until it responds 200 OK @@ -192,6 +191,9 @@ func startNodeWithLog(logPath string, extraEnv ...string) (*nodeProcess, error) func (n *nodeProcess) waitForHealth(ctx context.Context, t testing.TB) error { client := &http.Client{Timeout: 2 * time.Second} return pollUntil(ctx, 2*time.Second, func() (bool, error) { + if err := n.exitedProcessError(); err != nil { + return false, err + } req, err := http.NewRequestWithContext( ctx, "GET", "http://localhost:10000/readyz", nil) if err != nil { @@ -232,25 +234,23 @@ func (n *nodeProcess) stop(t testing.TB) { } // Send interrupt for graceful shutdown. - if err := n.cmd.Process.Signal(os.Interrupt); err != nil { - if t != nil { - t.Logf(" signal failed, killing: %v", err) + if !n.isDone() { + if err := n.cmd.Process.Signal(os.Interrupt); err != nil { + if t != nil { + t.Logf(" signal failed, killing: %v", err) + } + _ = n.cmd.Process.Kill() } - _ = n.cmd.Process.Kill() } // Wait for exit with a timeout — if the node hangs during shutdown, // fall back to SIGKILL so the test suite doesn't hang indefinitely. - done := make(chan error, 1) - go func() { done <- n.cmd.Wait() }() - select { - case <-done: - case <-time.After(30 * time.Second): + if !n.wait(30 * time.Second) { if t != nil { t.Log(" node did not exit within 30s, sending SIGKILL") } _ = n.cmd.Process.Kill() - <-done + <-n.done } n.logFile.Close() if t != nil { diff --git a/test/integration/node_subprocess_test.go b/test/integration/node_subprocess_test.go new file mode 100644 index 000000000..a944a5487 --- /dev/null +++ b/test/integration/node_subprocess_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "fmt" + "io" + "os/exec" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// nodeSubprocess waits exactly once and makes process exit visible to readiness +// and shutdown checks in both test topologies. +type nodeSubprocess struct { + name string + cmd *exec.Cmd + done chan struct{} + + mu sync.Mutex + waitErr error +} + +func startNodeSubprocess(name string, cmd *exec.Cmd) (*nodeSubprocess, error) { + if err := cmd.Start(); err != nil { + return nil, err + } + p := &nodeSubprocess{name: name, cmd: cmd, done: make(chan struct{})} + go func() { + err := cmd.Wait() + p.mu.Lock() + p.waitErr = err + p.mu.Unlock() + close(p.done) + }() + return p, nil +} + +func (p *nodeSubprocess) exitedProcessError() error { + select { + case <-p.done: + p.mu.Lock() + defer p.mu.Unlock() + if p.waitErr != nil { + return fmt.Errorf("%s exited unexpectedly: %w", p.name, p.waitErr) + } + return fmt.Errorf("%s exited unexpectedly", p.name) + default: + return nil + } +} + +func (p *nodeSubprocess) isDone() bool { + select { + case <-p.done: + return true + default: + return false + } +} + +func (p *nodeSubprocess) wait(timeout time.Duration) bool { + select { + case <-p.done: + return true + case <-time.After(timeout): + return false + } +} + +func TestNodeSubprocess(t *testing.T) { + for _, exitCode := range []int{0, 7} { + t.Run(fmt.Sprintf("exit_%d", exitCode), func(t *testing.T) { + cmd := exec.CommandContext(t.Context(), "sh", "-c", "read value; exit \"$value\"") + stdin, err := cmd.StdinPipe() + require.NoError(t, err) + p, err := startNodeSubprocess("test-node", cmd) + require.NoError(t, err) + t.Cleanup(func() { + _ = stdin.Close() + if !p.isDone() { + _ = cmd.Process.Kill() + } + require.True(t, p.wait(time.Second), "child must be reaped") + }) + require.NoError(t, p.exitedProcessError()) + require.False(t, p.wait(time.Millisecond)) + _, err = io.WriteString(stdin, fmt.Sprintf("%d\n", exitCode)) + require.NoError(t, err) + require.NoError(t, stdin.Close()) + require.True(t, p.wait(time.Second)) + require.True(t, p.isDone()) + err = p.exitedProcessError() + require.ErrorContains(t, err, "test-node exited unexpectedly") + if exitCode != 0 { + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, exitCode, exitErr.ExitCode()) + } + require.True(t, p.wait(time.Second), "repeated checks must not consume exit status") + }) + } +} diff --git a/test/integration/prt_commitment_helpers_test.go b/test/integration/prt_commitment_helpers_test.go new file mode 100644 index 000000000..a5b2e54de --- /dev/null +++ b/test/integration/prt_commitment_helpers_test.go @@ -0,0 +1,91 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// sparseDisputeCommitment is a test-only tree whose leaves all contain the same +// machine hash, except for an optional different final leaf. It follows Dave's +// ConfigurableCommitmentFixture SAME and RIGHTMOST_DIFFERENT families. Machine +// hashes are leaves directly; only ordered pairs of child hashes are hashed. +// +// This proves commitment membership, not validity of a machine execution. It +// supports external dispute actors without adding node dispute automation. +// There is deliberately no endtoendtests tag: its pure tests must run without +// the integration TestMain, which starts a node and connects to the devnet. +type sparseDisputeCommitment struct { + height uint64 + repeated []common.Hash // Entry h is a repeated-state subtree of height h. + final []common.Hash // Entry h has the different state at its final leaf. +} + +// The test helper uses uint64 leaf positions. All canonical heights (48, 17, +// and 27) fit this bound. Solidity supports larger coordinates independently. +const maxSparseDisputeHeight = 63 + +func newSparseDisputeCommitment(height uint64, repeatedState, finalState common.Hash) (*sparseDisputeCommitment, error) { + if height == 0 || height > maxSparseDisputeHeight { + return nil, fmt.Errorf("sparse dispute height %d is outside 1..%d", height, maxSparseDisputeHeight) + } + tree := &sparseDisputeCommitment{height: height, + repeated: make([]common.Hash, height+1), final: make([]common.Hash, height+1)} + tree.repeated[0], tree.final[0] = repeatedState, finalState + for level := uint64(1); level <= height; level++ { + left, right := tree.repeated[level-1], tree.final[level-1] + tree.repeated[level] = crypto.Keccak256Hash(left[:], left[:]) + tree.final[level] = crypto.Keccak256Hash(left[:], right[:]) + } + return tree, nil +} + +func (tree *sparseDisputeCommitment) root() common.Hash { + return tree.final[tree.height] +} + +func (tree *sparseDisputeCommitment) leafCount() uint64 { + return uint64(1) << tree.height +} + +// rightmostChildren opens a height-h subtree on the rightmost branch. With +// repeated and final-leaf-different actors, every bisection selects this branch. +// Passing h and h-1 supplies the two adjacent openings for advanceMatch; h=1 +// supplies the leaf pair for either seal method. +func (tree *sparseDisputeCommitment) rightmostChildren(height uint64) (common.Hash, common.Hash, error) { + if height == 0 || height > tree.height { + return common.Hash{}, common.Hash{}, fmt.Errorf("cannot open subtree height %d in height-%d commitment", height, tree.height) + } + return tree.repeated[height-1], tree.final[height-1], nil +} + +// proof returns the leaf and its siblings from leaf level to root level, as +// required by Commitment.getRoot. The returned slice is owned by the caller and +// uses the generated Solidity binding's bytes32[] type. The final-leaf proof +// is used for joinTournament; the second-last proof supplies the agree state +// when sealing the final-leaf divergence. +func (tree *sparseDisputeCommitment) proof(position uint64) (common.Hash, [][32]byte, error) { + if position >= tree.leafCount() { + return common.Hash{}, nil, fmt.Errorf("leaf position %d is outside height-%d commitment", position, tree.height) + } + leaf := tree.repeated[0] + if position == tree.leafCount()-1 { + leaf = tree.final[0] + } + siblings := make([][32]byte, tree.height) + for level := uint64(0); level < tree.height; level++ { + // Subtrees are numbered from left to right at each height. Only + // the last subtree can contain the different final leaf. + siblingIndex := (position >> level) ^ 1 + lastSubtreeIndex := (uint64(1) << (tree.height - level)) - 1 + siblings[level] = tree.repeated[level] + if siblingIndex == lastSubtreeIndex { + siblings[level] = tree.final[level] + } + } + return leaf, siblings, nil +} diff --git a/test/integration/prt_commitment_test.go b/test/integration/prt_commitment_test.go new file mode 100644 index 000000000..e5c57d93e --- /dev/null +++ b/test/integration/prt_commitment_test.go @@ -0,0 +1,161 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "math/big" + "strconv" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestSparseDisputeCommitmentMatchesDenseTrees(t *testing.T) { + repeated := common.HexToHash("0x1234") + for height := uint64(1); height <= 8; height++ { + for _, final := range []common.Hash{repeated, common.HexToHash("0xabcd")} { + t.Run(strconv.FormatUint(height, 10)+"/"+final.Hex(), func(t *testing.T) { + tree, err := newSparseDisputeCommitment(height, repeated, final) + require.NoError(t, err) + // This independent reference materializes only small trees. + levels := make([][]common.Hash, height+1) + levels[0] = make([]common.Hash, tree.leafCount()) + for position := range levels[0] { + levels[0][position] = repeated + } + levels[0][len(levels[0])-1] = final + for level := uint64(1); level <= height; level++ { + previous := levels[level-1] + levels[level] = make([]common.Hash, len(previous)/2) + for index := range levels[level] { + levels[level][index] = crypto.Keccak256Hash(previous[2*index][:], previous[2*index+1][:]) + } + } + require.Equal(t, levels[height][0], tree.root()) + for position := uint64(0); position < tree.leafCount(); position++ { + leaf, siblings, err := tree.proof(position) + require.NoError(t, err) + require.Equal(t, levels[0][position], leaf) + require.Equal(t, height, uint64(len(siblings))) + for level := uint64(0); level < height; level++ { + require.Equal(t, [32]byte(levels[level][(position>>level)^1]), siblings[level]) + } + require.Equal(t, tree.root(), rebuildSparseDisputeProof(leaf, position, siblings)) + } + }) + } + } +} + +func TestSparseDisputeCommitmentCanonicalGeometry(t *testing.T) { + repeated, changed := common.HexToHash("0x1234"), common.HexToHash("0xabcd") + // Canonical ArbitrationConstants.sol heights, in root-to-leaf order. + for _, height := range []uint64{48, 17, 27} { + t.Run(strconv.FormatUint(height, 10), func(t *testing.T) { + one, err := newSparseDisputeCommitment(height, repeated, repeated) + require.NoError(t, err) + two, err := newSparseDisputeCommitment(height, repeated, changed) + require.NoError(t, err) + require.NotEqual(t, one.root(), two.root()) + for _, tree := range []*sparseDisputeCommitment{one, two} { + require.Equal(t, height+1, uint64(len(tree.repeated))) + require.Equal(t, height+1, uint64(len(tree.final)), "storage is linear in height, not leaf count") + for _, position := range []uint64{0, 1, tree.leafCount() / 2, tree.leafCount() - 2, tree.leafCount() - 1} { + leaf, siblings, err := tree.proof(position) + require.NoError(t, err) + require.Equal(t, tree.root(), rebuildSparseDisputeProof(leaf, position, siblings)) + left, right, err := tree.rightmostChildren(height) + require.NoError(t, err) + require.Equal(t, tree.root(), crypto.Keccak256Hash(left[:], right[:])) + } + leaf, proof, err := tree.proof(tree.leafCount() - 1) + require.NoError(t, err) + root := tree.root() + proof[0][0] ^= 1 + require.NotEqual(t, root, rebuildSparseDisputeProof(leaf, tree.leafCount()-1, proof)) + _, fresh, err := tree.proof(tree.leafCount() - 1) + require.NoError(t, err) + require.NotEqual(t, proof[0], fresh[0], "callers cannot mutate cached subtrees through a proof") + require.Equal(t, root, tree.root()) + } + assertSparseDisputeBisection(t, one, two) + }) + } +} + +// Follow Match.create, advanceBisection, and sealDivergence without any RPC. +// The helper supplies openings; this independent state progression checks that +// their ordering fits the protocol, including odd/even final revealer parity. +func assertSparseDisputeBisection(t *testing.T, one, two *sparseDisputeCommitment) { + t.Helper() + actors := []*sparseDisputeCommitment{one, two} + otherParent := one.root() + waitingLeft, waitingRight, err := two.rightmostChildren(two.height) + require.NoError(t, err) + var revealer, position uint64 + for height := one.height; height > 1; height-- { + left, right, err := actors[revealer].rightmostChildren(height) + require.NoError(t, err) + require.Equal(t, otherParent, crypto.Keccak256Hash(left[:], right[:])) + require.Equal(t, waitingLeft, left) + require.NotEqual(t, waitingRight, right) + nextLeft, nextRight, err := actors[revealer].rightmostChildren(height - 1) + require.NoError(t, err) + require.Equal(t, right, crypto.Keccak256Hash(nextLeft[:], nextRight[:])) + otherParent, waitingLeft, waitingRight = waitingRight, nextLeft, nextRight + position += uint64(1) << (height - 1) + revealer ^= 1 + } + left, right, err := actors[revealer].rightmostChildren(1) + require.NoError(t, err) + require.Equal(t, otherParent, crypto.Keccak256Hash(left[:], right[:])) + require.Equal(t, waitingLeft, left) + require.NotEqual(t, waitingRight, right) + position++ + require.Equal(t, one.leafCount()-1, position) + require.Equal(t, (one.height-1)%2, revealer) + agree, proof, err := actors[revealer].proof(position - 1) + require.NoError(t, err) + require.Equal(t, one.repeated[0], agree) + require.Equal(t, actors[revealer].root(), rebuildSparseDisputeProof(agree, position-1, proof)) + if revealer == 0 { + require.Equal(t, one.final[0], right) + require.Equal(t, two.final[0], waitingRight) + } else { + require.Equal(t, one.final[0], waitingRight) + require.Equal(t, two.final[0], right) + } +} + +func TestSparseDisputeCommitmentRejectsOutOfRangeRequests(t *testing.T) { + for _, height := range []uint64{0, maxSparseDisputeHeight + 1, ^uint64(0)} { + _, err := newSparseDisputeCommitment(height, common.Hash{}, common.Hash{}) + require.Error(t, err) + } + tree, err := newSparseDisputeCommitment(maxSparseDisputeHeight, common.Hash{}, common.HexToHash("0x1")) + require.NoError(t, err) + for _, height := range []uint64{0, maxSparseDisputeHeight + 1} { + _, _, err := tree.rightmostChildren(height) + require.Error(t, err) + } + for _, position := range []uint64{tree.leafCount(), ^uint64(0)} { + _, proof, err := tree.proof(position) + require.Error(t, err) + require.Nil(t, proof) + } + leaf, proof, err := tree.proof(tree.leafCount() - 1) + require.NoError(t, err) + require.Equal(t, tree.root(), rebuildSparseDisputeProof(leaf, tree.leafCount()-1, proof)) +} + +func rebuildSparseDisputeProof(leaf common.Hash, position uint64, siblings [][32]byte) common.Hash { + proof := merkle.Leaf(leaf, new(big.Int).SetUint64(position)) + for _, sibling := range siblings { + proof.PushHash(sibling) + } + return proof.BuildRoot() +} diff --git a/test/integration/prt_passive_observer_actions_test.go b/test/integration/prt_passive_observer_actions_test.go new file mode 100644 index 000000000..f7865e49c --- /dev/null +++ b/test/integration/prt_passive_observer_actions_test.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func (f *passiveDisputeFixture) joinPair(layer *passiveDisputeLayer) { + for side, tree := range layer.trees { + if side == 1 && layer.descriptor.Level == 2 { + // A late join receives less allowance. Preserve this deliberate + // gap through the leaf race, so only B expires at its deadline. + f.r.NoError(anvilMine(f.ctx, passiveLeafJoinDelay)) + } + bond, err := layer.contract.BondValue(&bind.CallOpts{Context: f.ctx}) + f.r.NoError(err) + opts := *f.actors[side] + opts.Value = bond + left, right, err := tree.rightmostChildren(tree.height) + f.r.NoError(err) + finalState, proof, err := tree.proof(tree.leafCount() - 1) + f.r.NoError(err) + tx, err := layer.contract.JoinTournament(&opts, finalState, proof, left, right) + f.record(tx, err) + } +} + +func (f *passiveDisputeFixture) bisect(layer *passiveDisputeLayer) { + var responder uint64 + for height := layer.descriptor.Height; height > 1; height-- { + tree := layer.trees[responder] + left, right, err := tree.rightmostChildren(height) + f.r.NoError(err) + nextLeft, nextRight, err := tree.rightmostChildren(height - 1) + f.r.NoError(err) + tx, err := layer.contract.AdvanceMatch(f.actors[responder], layer.matchID, left, right, nextLeft, nextRight) + f.record(tx, err) + responder ^= 1 + if height == layer.descriptor.Height { + // Check a real intermediate snapshot, not only the final state. + f.assertCurrent(layer, MatchPhaseBisecting) + } + } +} + +func (f *passiveDisputeFixture) seal(layer *passiveDisputeLayer) common.Address { + responder := (layer.descriptor.Height - 1) % 2 + tree := layer.trees[responder] + left, right, err := tree.rightmostChildren(1) + f.r.NoError(err) + agree, proof, err := tree.proof(tree.leafCount() - 2) + f.r.NoError(err) + if layer.descriptor.Level == 2 { + tx, err := layer.contract.SealLeafMatch(f.actors[responder], layer.matchID, left, right, agree, proof) + f.record(tx, err) + return common.Address{} + } + tx, err := layer.contract.SealInnerMatchAndCreateInnerTournament(f.actors[responder], layer.matchID, + left, right, agree, proof) + receipt := f.record(tx, err) + for _, raw := range receipt.Logs { + if raw.Address != layer.address { + continue + } + event, err := layer.contract.ParseNewInnerTournament(*raw) + if err == nil { + f.r.Equal(layer.matchHash, common.Hash(event.MatchIdHash)) + return event.ChildTournament + } + } + f.r.FailNow("successful inner seal must emit NewInnerTournament") + return common.Address{} +} + +func (f *passiveDisputeFixture) winLeafByTimeout() { + layer := f.layers[len(f.layers)-1] + one, err := layer.contract.CommitmentStanding(&bind.CallOpts{Context: f.ctx}, layer.trees[0].root()) + f.r.NoError(err) + two, err := layer.contract.CommitmentStanding(&bind.CallOpts{Context: f.ctx}, layer.trees[1].root()) + f.r.NoError(err) + f.r.True(one.ClockRunning) + f.r.True(two.ClockRunning) + f.r.Greater(one.ClockDeadline, two.ClockDeadline+uint64(len(f.layers)), + "the winner must retain time to propagate through both parent tournaments") + f.mineTo(two.ClockDeadline) + timeout, err := layer.contract.ClassifyMatchTimeout(&bind.CallOpts{Context: f.ctx}, layer.matchID) + f.r.NoError(err) + f.r.Equal(uint8(1), timeout.Outcome, "only actor A must win, not ELIMINATE_BOTH") + f.assertCurrent(layer, MatchPhaseSealed) + left, right, err := layer.trees[0].rightmostChildren(layer.descriptor.Height) + f.r.NoError(err) + tx, err := layer.contract.WinMatchByTimeout(f.actors[0], layer.matchID, left, right) + f.record(tx, err) + f.assertCurrent(layer, MatchPhaseUninitialized) +} + +// Receipt events cannot prove that no extra reverted/no-log transaction was +// sent. Inspect all transactions targeting this fixture's tournaments as well. +func (f *passiveDisputeFixture) assertOnlyExternalTransactions() { + expected := make(map[common.Hash]struct{}, len(f.receipts)) + for _, receipt := range f.receipts { + expected[receipt.TxHash] = struct{}{} + } + addresses := make(map[common.Address]struct{}, len(f.layers)) + for _, layer := range f.layers { + addresses[layer.address] = struct{}{} + } + start := f.receipts[0].BlockNumber.Uint64() + end, err := f.client.BlockNumber(f.ctx) + f.r.NoError(err) + for height := start; height <= end; height++ { + block, err := f.client.BlockByNumber(f.ctx, new(big.Int).SetUint64(height)) + f.r.NoError(err) + for _, tx := range block.Transactions() { + if tx.To() == nil { + continue + } + if _, ok := addresses[*tx.To()]; !ok { + continue + } + f.r.Contains(expected, tx.Hash(), "the node must not send any tournament transaction") + sender, err := types.Sender(types.LatestSignerForChainID(tx.ChainId()), tx) + f.r.NoError(err) + f.r.Contains([]common.Address{f.actors[0].From, f.actors[1].From}, sender) + } + } +} diff --git a/test/integration/prt_passive_observer_api_test.go b/test/integration/prt_passive_observer_api_test.go new file mode 100644 index 000000000..cc1e742e6 --- /dev/null +++ b/test/integration/prt_passive_observer_api_test.go @@ -0,0 +1,291 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +func (f *passiveDisputeFixture) observedTournament(layer *passiveDisputeLayer) (*Tournament, *bind.CallOpts) { + f.t.Helper() + head, err := f.client.BlockNumber(f.ctx) + f.r.NoError(err) + var result *Tournament + f.r.NoError(pollUntil(f.ctx, passiveObserverPoll, func() (bool, error) { + var response api.ListResponse[*Tournament] + err := f.rpc.Call(f.ctx, "cartesi_listTournaments", api.ListTournamentsParams{Application: f.appName}, &response) + if err != nil { + return false, err + } + for _, row := range response.Data { + if row.Address == layer.address && row.Snapshot.AsOfBlock >= head { + result = row + return true, nil + } + } + return false, nil + }), "publish the complete tournament snapshot at block %d", head) + f.r.Equal(head, result.Snapshot.AsOfBlock, "no interval block may race the API assertions") + return result, &bind.CallOpts{Context: f.ctx, BlockNumber: new(big.Int).SetUint64(head)} +} + +func (f *passiveDisputeFixture) observedMatch(layer *passiveDisputeLayer) *Match { + f.t.Helper() + var response api.SingleResponse[*Match] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getMatch", api.GetMatchParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), IDHash: layer.matchHash.Hex()}, &response)) + f.r.NotNil(response.Data) + return response.Data +} + +func (f *passiveDisputeFixture) assertCurrent(layer *passiveDisputeLayer, expected MatchPhase) { + f.t.Helper() + tournament, opts := f.observedTournament(layer) + f.r.Equal(layer.descriptor.Height, tournament.Height) + f.r.Equal(layer.descriptor.Log2Stride, tournament.Log2Step) + f.r.Equal(layer.descriptor.StartInstant, tournament.StartInstant) + f.r.Equal(layer.descriptor.Allowance, tournament.Allowance) + f.r.Equal(common.Hash(layer.descriptor.InitialHash), tournament.InitialHash) + f.r.Zero(layer.descriptor.BaseCycle.Cmp(tournament.BaseCycle.ToBig())) + if layer.descriptor.Level > 0 { + f.r.Greater(tournament.BaseCycle.ToBig().BitLen(), 64, "nested cycle positions must not truncate to uint64") + f.r.NotNil(tournament.CreationEvent) + f.r.NotNil(tournament.Snapshot.InnerResult) + } else { + f.r.Nil(tournament.CreationEvent) + f.r.Nil(tournament.Snapshot.InnerResult) + } + match := f.observedMatch(layer) + if expected == MatchPhaseUninitialized { + f.r.Positive(match.DeletionBlockNumber) + f.r.GreaterOrEqual(match.Snapshot.AsOfBlock, match.DeletionBlockNumber) + f.r.LessOrEqual(match.Snapshot.AsOfBlock, tournament.Snapshot.AsOfBlock) + } else { + f.r.Equal(tournament.Snapshot.AsOfBlock, match.Snapshot.AsOfBlock) + } + f.r.Equal(expected, match.Snapshot.Phase) + f.r.Equal(layer.trees[0].root(), match.CommitmentOne) + f.r.Equal(layer.trees[1].root(), match.CommitmentTwo) + timeout, err := layer.contract.ClassifyMatchTimeout(opts, layer.matchID) + f.r.NoError(err) + outcomes := []MatchTimeoutOutcome{MatchTimeoutNone, MatchTimeoutOneWins, MatchTimeoutTwoWins, MatchTimeoutEliminateBoth} + f.r.Less(int(timeout.Outcome), len(outcomes)) + f.r.Equal(outcomes[timeout.Outcome], match.Snapshot.TimeoutOutcome) + f.r.Equal(timeout.DeferredCharge, match.Snapshot.DeferredCharge) + switch expected { + case MatchPhaseBisecting: + view, err := layer.contract.BisectingMatch(opts, layer.matchHash) + f.r.NoError(err) + f.r.Equal(uint8(1), view.ActualPhase) + f.r.NotNil(match.Snapshot.Bisection) + bisection := match.Snapshot.Bisection + f.r.NotNil(bisection.CurrentHeight) + f.r.Equal(view.Value.CurrentHeight, *bisection.CurrentHeight) + f.assertBisection(bisection, view.Value.RevealingParent, view.Value.WaitingLeft, view.Value.WaitingRight, + view.Value.SegmentStartPosition, view.Value.SegmentStartCycle, view.Value.Responder) + f.r.Nil(match.Snapshot.Sealed) + case MatchPhaseReadyToSeal: + view, err := layer.contract.ReadyToSealMatch(opts, layer.matchHash) + f.r.NoError(err) + f.r.Equal(uint8(2), view.ActualPhase) + f.r.NotNil(match.Snapshot.Bisection) + f.r.Nil(match.Snapshot.Bisection.CurrentHeight) + f.assertBisection(match.Snapshot.Bisection, view.Value.RevealingParent, view.Value.WaitingLeft, view.Value.WaitingRight, + view.Value.SegmentStartPosition, view.Value.SegmentStartCycle, view.Value.Responder) + f.r.Nil(match.Snapshot.Sealed) + case MatchPhaseSealed: + view, err := layer.contract.SealedMatch(opts, layer.matchHash) + f.r.NoError(err) + f.r.Equal(uint8(3), view.ActualPhase) + f.r.Nil(match.Snapshot.Bisection) + f.r.NotNil(match.Snapshot.Sealed) + sealed := match.Snapshot.Sealed + f.r.Equal(common.Hash(view.Value.AgreeState), sealed.AgreeState) + f.r.Equal(*f.epoch.MachineHash, sealed.AgreeState) + f.r.Zero(view.Value.DivergencePosition.Cmp(sealed.DivergencePosition.ToBig())) + f.r.Zero(view.Value.DivergenceCycle.Cmp(sealed.DivergenceCycle.ToBig())) + f.r.Equal(layer.trees[0].leafCount()-1, sealed.DivergencePosition.ToBig().Uint64()) + f.r.Greater(sealed.DivergenceCycle.ToBig().BitLen(), 64) + f.r.Equal(common.Hash(view.Value.FinalStateOne), sealed.FinalStateOne) + f.r.Equal(common.Hash(view.Value.FinalStateTwo), sealed.FinalStateTwo) + if layer.descriptor.Level == 2 { + f.r.NotNil(match.LeafSeal) + } else { + f.r.Nil(match.LeafSeal) + } + case MatchPhaseUninitialized: + f.r.Nil(match.Snapshot.Bisection) + f.r.Nil(match.Snapshot.Sealed) + f.r.NotNil(match.DeletionTxHash) + f.r.NotNil(match.DeletionLogIndex) + f.r.Equal(WinnerCommitment_ONE, match.Winner) + if layer.descriptor.Level == 2 { + f.r.Equal(MatchDeletionReason_TIMEOUT, match.DeletionReason) + f.r.NotNil(match.LeafSeal, "deletion must preserve the immutable leaf seal") + } else { + f.r.Equal(MatchDeletionReason_CHILD_TOURNAMENT, match.DeletionReason) + } + default: + f.r.FailNow("unexpected test phase", "%s", expected) + } + f.assertCommitmentClocks(layer, opts) +} + +func (f *passiveDisputeFixture) assertBisection( + observed *MatchBisectionSnapshot, parent, left, right [32]byte, position, cycle *big.Int, responder uint8, +) { + f.r.Equal(common.Hash(parent), observed.RevealingParent) + f.r.Equal(common.Hash(left), observed.WaitingLeft) + f.r.Equal(common.Hash(right), observed.WaitingRight) + f.r.Zero(position.Cmp(observed.SegmentStartPosition.ToBig())) + f.r.Zero(cycle.Cmp(observed.SegmentStartCycle.ToBig())) + sides := []CommitmentSide{CommitmentSideOne, CommitmentSideTwo} + f.r.Less(int(responder), len(sides)) + f.r.Equal(sides[responder], observed.Responder) +} + +func (f *passiveDisputeFixture) assertCommitmentClocks(layer *passiveDisputeLayer, opts *bind.CallOpts) { + for side, tree := range layer.trees { + var response api.SingleResponse[*Commitment] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getCommitment", api.GetCommitmentParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), Commitment: tree.root().Hex()}, &response)) + f.r.NotNil(response.Data) + commitment := response.Data + view, err := layer.contract.CommitmentStanding(opts, tree.root()) + f.r.NoError(err) + f.r.True(view.Joined) + f.r.Equal(f.actors[side].From, commitment.SubmitterAddress, "the original participant must remain visible") + f.r.Equal(common.Hash(view.FinalState), commitment.FinalStateHash) + f.r.Equal(view.Claimer, commitment.Snapshot.Claimer) + f.r.Equal(view.ClockRunning, commitment.Snapshot.ClockRunning) + f.r.Equal(view.ClockDeadline, commitment.Snapshot.ClockDeadline) + f.r.Equal(view.ClockAllowance, commitment.Snapshot.ClockAllowance) + f.r.Equal(opts.BlockNumber.Uint64(), commitment.Snapshot.AsOfBlock) + } +} + +func (f *passiveDisputeFixture) assertWinner(layer *passiveDisputeLayer, expected TournamentStandingState) { + tournament, opts := f.observedTournament(layer) + view, err := layer.contract.TournamentStanding(opts) + f.r.NoError(err) + f.r.Equal(expected, tournament.Snapshot.Standing) + f.r.NotNil(tournament.Snapshot.WinnerCommitment) + f.r.Equal(layer.trees[0].root(), *tournament.Snapshot.WinnerCommitment) + f.r.Equal(tournament.Snapshot.WinnerCommitment, tournament.Snapshot.Candidate) + f.r.NotNil(tournament.Snapshot.FinalStateHash) + f.r.Equal(layer.trees[0].final[0], *tournament.Snapshot.FinalStateHash) + f.r.Equal(view.FinishedAt, tournament.Snapshot.FinishedAtBlock) + f.r.Positive(view.FinishedAt) + f.r.Equal(view.WinnerExpiresAt, tournament.Snapshot.WinnerExpiresAt) + f.r.False(tournament.Snapshot.AcceptsJoins) + if layer.descriptor.Level > 0 { + inner, err := layer.contract.InnerResult(opts) + f.r.NoError(err) + f.r.NotNil(tournament.Snapshot.InnerResult) + f.r.Equal(InnerTournamentWinner, tournament.Snapshot.InnerResult.Disposition) + f.r.NotNil(tournament.Snapshot.InnerResult.ParentCommitment) + f.r.Equal(common.Hash(inner.ParentCommitment), *tournament.Snapshot.InnerResult.ParentCommitment) + f.r.Equal(inner.PausedAllowance, tournament.Snapshot.InnerResult.PausedAllowance) + f.r.Greater(view.WinnerExpiresAt, opts.BlockNumber.Uint64()+uint64(layer.descriptor.Level)) + } +} + +func (f *passiveDisputeFixture) assertRecoveredRoot() { + root := f.layers[0] + tournament, opts := f.observedTournament(root) + f.r.Equal(TournamentStandingRootWinner, tournament.Snapshot.Standing) + f.r.Equal(BondDispositionRecovered, tournament.Snapshot.BondRecovery.Disposition) + f.r.Nil(tournament.Snapshot.BondRecovery.Claimer) + f.r.Nil(tournament.Snapshot.BondRecovery.Payment) + f.assertCommitmentClocks(root, opts) + var response api.SingleResponse[*Commitment] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getCommitment", api.GetCommitmentParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: root.address.Hex(), Commitment: root.trees[0].root().Hex()}, &response)) + f.r.Equal(common.Address{}, response.Data.Snapshot.Claimer) + f.r.Equal(f.actors[0].From, response.Data.SubmitterAddress) +} + +func (f *passiveDisputeFixture) assertInnerWinnersExpire() { + var expiry uint64 + deletedMatches := make(map[common.Address]*Match, len(f.layers)-1) + for _, layer := range f.layers[1:] { + standing, err := layer.contract.TournamentStanding(&bind.CallOpts{Context: f.ctx}) + f.r.NoError(err) + f.r.Positive(standing.WinnerExpiresAt, "retain the winner long enough to propagate and accept") + expiry = max(expiry, standing.WinnerExpiresAt) + deletedMatches[layer.address] = f.observedMatch(layer) + } + // No transaction emits a log for this transition. The observer must still + // refresh current views and remove winner fields which no longer apply. + f.mineTo(expiry) + for _, layer := range f.layers[1:] { + tournament, _ := f.observedTournament(layer) + snapshot := tournament.Snapshot + deleted := deletedMatches[layer.address] + f.r.Greater(snapshot.AsOfBlock, deleted.Snapshot.AsOfBlock, "observe a later window after match deletion") + f.r.Equal(deleted, f.observedMatch(layer), "keep the certified snapshot, deletion facts, and row timestamps") + f.r.Equal(TournamentStandingInnerEliminableWinnerExpired, snapshot.Standing) + f.r.NotNil(snapshot.Candidate) + f.r.Equal(layer.trees[0].root(), *snapshot.Candidate) + f.r.Nil(snapshot.WinnerCommitment) + f.r.Nil(snapshot.FinalStateHash) + f.r.Nil(snapshot.ParentCommitment) + f.r.Zero(snapshot.WinnerExpiresAt) + f.r.Positive(snapshot.FinishedAtBlock) + f.r.NotNil(snapshot.InnerResult) + f.r.Equal(InnerTournamentEliminable, snapshot.InnerResult.Disposition) + f.r.Nil(snapshot.InnerResult.ParentCommitment) + f.r.Zero(snapshot.InnerResult.PausedAllowance) + f.r.Equal(BondDispositionRecoverable, snapshot.BondRecovery.Disposition, + "winner expiry must not hide the unrecovered bond") + } +} + +func (f *passiveDisputeFixture) assertLocalDivergence() { + var response api.SingleResponse[*Application] + f.r.NoError(pollUntil(f.ctx, passiveObserverPoll, func() (bool, error) { + if err := f.rpc.Call(f.ctx, "cartesi_getApplication", api.GetApplicationParams{Application: f.appName}, &response); err != nil { + return false, err + } + return response.Data != nil && response.Data.Status == ApplicationStatus_Diverged, nil + }), "mark a losing local commitment as diverged after publishing the winner") + f.r.True(response.Data.Enabled, "passive observation must continue while the application is diverged") + f.r.NotNil(response.Data.Reason) + f.r.Contains(*response.Data.Reason, "inconsistent commitment") + epoch, err := readEpoch(f.ctx, f.appName, 0) + f.r.NoError(err) + f.r.Equal(EpochStatus_ClaimComputed, epoch.Status) + f.r.Nil(epoch.ClaimTransactionHash) + f.r.Nil(epoch.StagedAtBlock, "the incorrect winner must never use a fabricated machine-state proof") + f.r.Equal(f.epoch.Commitment, epoch.Commitment) +} + +func (f *passiveDisputeFixture) listAdvances(layer *passiveDisputeLayer) []*MatchAdvanced { + var response api.ListResponse[*MatchAdvanced] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_listMatchAdvances", api.ListMatchAdvancesParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), IDHash: layer.matchHash.Hex(), Limit: 100}, &response)) + f.r.Equal(layer.descriptor.Height-1, uint64(len(response.Data))) + f.r.Equal(layer.descriptor.Height-1, response.Pagination.TotalCount) + for _, event := range response.Data { + var single api.SingleResponse[*MatchAdvanced] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getMatchAdvance", api.GetMatchAdvanceParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), IDHash: layer.matchHash.Hex(), + TxHash: event.TxHash.Hex(), LogIndex: hexutil.EncodeUint64(event.LogIndex)}, &single)) + f.r.Equal(event, single.Data, "get and list must expose identical advance content") + } + return response.Data +} + +func passiveEventIdentity(hash common.Hash, index uint64) string { + return fmt.Sprintf("%s/%d", hash, index) +} diff --git a/test/integration/prt_passive_observer_config_test.go b/test/integration/prt_passive_observer_config_test.go new file mode 100644 index 000000000..fbc832468 --- /dev/null +++ b/test/integration/prt_passive_observer_config_test.go @@ -0,0 +1,142 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + passiveClaimSubmissionField = "ClaimSubmissionEnabled" + passiveDefaultBlockField = "DefaultBlock" + + observerCleanupExitEnv = "CARTESI_TEST_PASSIVE_OBSERVER_CLEANUP_EXIT_AT" + observerCleanupTestPattern = "^TestPassiveObserverCleanupPreservesRestoreOrder$" +) + +// This test-only codec has no integration build tag. Its pure tests must run +// without TestMain starting a node or connecting to the development database. +func patchPassiveObserverConfig(raw []byte, overrides map[string]any) ([]byte, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, fmt.Errorf("decode passive observer config: %w", err) + } + for key, value := range overrides { + if _, exists := fields[key]; !exists { + return nil, fmt.Errorf("passive observer config is missing field %s", key) + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode passive observer config field %s: %w", key, err) + } + fields[key] = encoded + } + return json.Marshal(fields) +} + +func registerPassiveObserverCleanup(t *testing.T, stop, restore, restart func()) { + t.Helper() + t.Cleanup(func() { + restore() + restart() + }) + // Cleanup runs in reverse registration order. A fatal shutdown assertion + // must not skip restoration, so shutdown needs its own cleanup callback. + t.Cleanup(stop) +} + +func TestPassiveObserverCleanupPreservesRestoreOrder(t *testing.T) { + const ( + noFailure = "none" + setupStep = "setup" + stopStep = "stop" + restoreStep = "restore" + restartStep = "restart" + closeStep = "close repository" + ) + if exitAt := os.Getenv(observerCleanupExitEnv); exitAt != "" { + require.Contains(t, []string{noFailure, setupStep, stopStep, restoreStep}, exitAt) + var calls []string + // Report the sequence after all cleanup callbacks, including after Fatal. + t.Cleanup(func() { t.Logf("cleanup calls: %q", calls) }) + t.Cleanup(func() { calls = append(calls, closeStep) }) + step := func(name string) func() { + return func() { + calls = append(calls, name) + if name == exitAt { + t.Fatalf("injected cleanup failure at %s", name) + } + } + } + registerPassiveObserverCleanup(t, step(stopStep), step(restoreStep), step(restartStep)) + if exitAt == setupStep { + // An initial stop can fail before the reader starts. Cleanup must + // still restore the default fixture. + t.Fatalf("injected cleanup failure at %s", setupStep) + } + return + } + + executable, err := os.Executable() + require.NoError(t, err) + for _, exitAt := range []string{noFailure, setupStep, stopStep, restoreStep} { + t.Run(exitAt, func(t *testing.T) { + // Use a child test process so a real Fatal does not fail the parent. + // TestMain bypasses node startup for this exact child test selection. + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "-test.run="+observerCleanupTestPattern, "-test.v") + cmd.Env = append(os.Environ(), observerCleanupExitEnv+"="+exitAt) + output, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "cleanup child did not finish: %s", output) + if exitAt == noFailure { + require.NoError(t, err, "%s", output) + } else { + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr, "%s", output) + require.Equal(t, 1, exitErr.ExitCode(), "%s", output) + require.Contains(t, string(output), "injected cleanup failure at "+exitAt) + } + want := []string{stopStep, restoreStep, restartStep, closeStep} + if exitAt == restoreStep { + want = []string{stopStep, restoreStep, closeStep} + } + require.Contains(t, string(output), fmt.Sprintf("cleanup calls: %q\n", want)) + require.NotContains(t, string(output), "--- SKIP:") + }) + } +} + +func TestPassiveObserverConfigPreservesOriginalAndUnrelatedFields(t *testing.T) { + raw := []byte(`{"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true,` + + `"ChainID":18446744073709551615,"Other":{"value":18446744073709551616}}`) + original := bytes.Clone(raw) + updated, err := patchPassiveObserverConfig(raw, map[string]any{passiveDefaultBlockField: "LATEST", passiveClaimSubmissionField: false}) + require.NoError(t, err) + require.Equal(t, original, raw, "cleanup must retain the original payload unchanged") + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(updated, &fields)) + require.Equal(t, `"LATEST"`, string(fields[passiveDefaultBlockField])) + require.Equal(t, "false", string(fields[passiveClaimSubmissionField])) + require.Equal(t, "18446744073709551615", string(fields["ChainID"])) + require.Equal(t, `{"value":18446744073709551616}`, string(fields["Other"])) +} + +func TestPassiveObserverConfigRejectsMissingFieldsAndInvalidJSON(t *testing.T) { + for _, raw := range []string{"null", "[]", "{", `{"ChainID":31337}`} { + t.Run(raw, func(t *testing.T) { + _, err := patchPassiveObserverConfig([]byte(raw), map[string]any{passiveDefaultBlockField: "LATEST"}) + require.Error(t, err) + }) + } +} diff --git a/test/integration/prt_passive_observer_events_test.go b/test/integration/prt_passive_observer_events_test.go new file mode 100644 index 000000000..dbec1289e --- /dev/null +++ b/test/integration/prt_passive_observer_events_test.go @@ -0,0 +1,171 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" +) + +const passiveRefundEvent = "PartialBondRefund" + +func (f *passiveDisputeFixture) assertAllEvents() { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + f.r.NoError(err) + seen := make(map[string]int) + for _, layer := range f.layers { + advances := f.listAdvances(layer) + advanceByLog := make(map[string]*MatchAdvanced, len(advances)) + for _, event := range advances { + advanceByLog[passiveEventIdentity(event.TxHash, event.LogIndex)] = event + } + tournamentAddress := layer.address.Hex() + var bonds api.ListResponse[*BondEvent] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_listBondEvents", api.ListBondEventsParams{Application: f.appName, + TournamentAddress: &tournamentAddress, Limit: 100}, &bonds)) + bondByLog := make(map[string]*BondEvent, len(bonds.Data)) + for _, event := range bonds.Data { + bondByLog[passiveEventIdentity(event.TxHash, event.LogIndex)] = event + var single api.SingleResponse[*BondEvent] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getBondEvent", api.GetBondEventParams{Application: f.appName, + TxHash: event.TxHash.Hex(), LogIndex: hexutil.EncodeUint64(event.LogIndex)}, &single)) + f.r.Equal(event, single.Data) + } + match := f.observedMatch(layer) + for _, receipt := range f.receipts { + for _, raw := range receipt.Logs { + if raw.Address != layer.address { + continue + } + f.r.NotEmpty(raw.Topics) + definition, err := contractABI.EventByID(raw.Topics[0]) + f.r.NoError(err) + seen[definition.Name]++ + switch definition.Name { + case "CommitmentJoined": + f.assertJoinedEvent(layer, raw) + case "MatchCreated": + event, err := layer.contract.ParseMatchCreated(*raw) + f.r.NoError(err) + f.r.Equal(common.Hash(event.MatchIdHash), match.IDHash) + f.r.Equal(common.Hash(event.One), match.CommitmentOne) + f.r.Equal(common.Hash(event.Two), match.CommitmentTwo) + f.r.Equal(common.Hash(event.LeftOfTwo), match.LeftOfTwo) + f.r.Equal(event.EliminableAt, match.EliminableAt) + f.assertLogIdentity(raw, match.BlockNumber, match.TxHash, match.LogIndex) + case "MatchAdvanced": + event, err := layer.contract.ParseMatchAdvanced(*raw) + f.r.NoError(err) + key := passiveEventIdentity(raw.TxHash, uint64(raw.Index)) + row := advanceByLog[key] + f.r.NotNil(row) + f.r.Equal(common.Hash(event.MatchIdHash), row.IDHash) + f.r.Equal(common.Hash(event.OtherParent), row.OtherParent) + f.r.Equal(common.Hash(event.LeftNode), row.LeftNode) + f.r.Zero(event.SegmentStartPosition.Cmp(row.SegmentStartPosition.ToBig())) + f.r.Equal(event.EliminableAt, row.EliminableAt) + f.assertLogIdentity(raw, row.BlockNumber, row.TxHash, row.LogIndex) + delete(advanceByLog, key) + case "LeafMatchSealed": + event, err := layer.contract.ParseLeafMatchSealed(*raw) + f.r.NoError(err) + f.r.NotNil(match.LeafSeal) + f.r.Equal(event.EliminableAt, match.LeafSeal.EliminableAt) + f.assertLogIdentity(raw, match.LeafSeal.BlockNumber, match.LeafSeal.TxHash, match.LeafSeal.LogIndex) + case "MatchDeleted": + event, err := layer.contract.ParseMatchDeleted(*raw) + f.r.NoError(err) + f.r.Equal(common.Hash(event.MatchIdHash), match.IDHash) + f.r.Equal(uint8(1), event.WinnerCommitment) + f.r.Equal(WinnerCommitment_ONE, match.Winner) + f.r.NotNil(match.DeletionTxHash) + f.r.NotNil(match.DeletionLogIndex) + f.assertLogIdentity(raw, match.DeletionBlockNumber, *match.DeletionTxHash, *match.DeletionLogIndex) + case "NewInnerTournament": + f.assertChildCreationEvent(layer, raw) + case passiveRefundEvent, "BondRecovered": + key := passiveEventIdentity(raw.TxHash, uint64(raw.Index)) + f.assertBondEvent(layer, definition.Name, raw, bondByLog[key]) + delete(bondByLog, key) + default: + f.r.FailNow("unexpected official tournament event", "%s", definition.Name) + } + } + } + f.r.Empty(advanceByLog, "every API advance must come from a receipt log") + f.r.Empty(bondByLog, "every API bond event must come from a receipt log") + } + f.r.Equal(map[string]int{ + "CommitmentJoined": 6, "MatchCreated": 3, "MatchAdvanced": 89, "LeafMatchSealed": 1, + "MatchDeleted": 3, "NewInnerTournament": 2, passiveRefundEvent: 95, "BondRecovered": 1, + }, seen, "the live fixture must exercise all eight public tournament event types") +} + +func (f *passiveDisputeFixture) assertLogIdentity(raw *types.Log, block uint64, hash common.Hash, index uint64) { + f.t.Helper() + f.r.Equal(raw.BlockNumber, block) + f.r.Equal(raw.TxHash, hash) + f.r.Equal(uint64(raw.Index), index) +} + +func (f *passiveDisputeFixture) assertJoinedEvent(layer *passiveDisputeLayer, raw *types.Log) { + event, err := layer.contract.ParseCommitmentJoined(*raw) + f.r.NoError(err) + var response api.SingleResponse[*Commitment] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getCommitment", api.GetCommitmentParams{Application: f.appName, + EpochIndex: "0x0", TournamentAddress: layer.address.Hex(), Commitment: common.Hash(event.Commitment).Hex()}, &response)) + f.r.NotNil(response.Data) + f.r.Equal(common.Hash(event.FinalStateHash), response.Data.FinalStateHash) + f.r.Equal(event.Submitter, response.Data.SubmitterAddress) + f.assertLogIdentity(raw, response.Data.BlockNumber, response.Data.TxHash, response.Data.LogIndex) +} + +func (f *passiveDisputeFixture) assertChildCreationEvent(layer *passiveDisputeLayer, raw *types.Log) { + event, err := layer.contract.ParseNewInnerTournament(*raw) + f.r.NoError(err) + var response api.SingleResponse[*Tournament] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getTournament", api.GetTournamentParams{Application: f.appName, + Address: event.ChildTournament.Hex()}, &response)) + f.r.NotNil(response.Data) + child := response.Data + f.r.NotNil(child.CreationEvent) + f.r.Equal(&layer.address, child.ParentTournamentAddress) + f.r.NotNil(child.ParentMatchIDHash) + f.r.Equal(common.Hash(event.MatchIdHash), *child.ParentMatchIDHash) + f.assertLogIdentity(raw, child.CreationEvent.BlockNumber, child.CreationEvent.TxHash, child.CreationEvent.LogIndex) +} + +func (f *passiveDisputeFixture) assertBondEvent(layer *passiveDisputeLayer, name string, raw *types.Log, row *BondEvent) { + f.r.NotNil(row) + f.r.Equal(layer.address, row.TournamentAddress) + f.r.Zero(row.EpochIndex) + f.assertLogIdentity(raw, row.BlockNumber, row.TxHash, row.LogIndex) + if name == passiveRefundEvent { + event, err := layer.contract.ParsePartialBondRefund(*raw) + f.r.NoError(err) + f.r.Equal(BondEventPartialRefund, row.Type) + f.r.NotNil(row.Refund) + f.r.Nil(row.Recovery) + f.r.Equal(event.Recipient, row.Refund.Recipient) + f.r.Equal(event.Success, row.Refund.Success) + f.r.Zero(event.Value.Cmp(row.Refund.Value.ToBig())) + } else { + event, err := layer.contract.ParseBondRecovered(*raw) + f.r.NoError(err) + f.r.Equal(BondEventRecovered, row.Type) + f.r.Nil(row.Refund) + f.r.NotNil(row.Recovery) + f.r.Equal(common.Hash(event.Commitment), row.Recovery.Commitment) + f.r.Equal(event.Claimer, row.Recovery.Claimer) + f.r.Equal(f.actors[0].From, row.Recovery.Claimer) + f.r.Zero(event.Payment.Cmp(row.Recovery.Payment.ToBig())) + f.r.Zero(event.Burned.Cmp(row.Recovery.Burned.ToBig())) + } +} diff --git a/test/integration/prt_passive_observer_test.go b/test/integration/prt_passive_observer_test.go new file mode 100644 index 000000000..68473d580 --- /dev/null +++ b/test/integration/prt_passive_observer_test.go @@ -0,0 +1,311 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "fmt" + "math/big" + "regexp" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + jsonrpcclient "github.com/cartesi/rollups-node/pkg/jsonrpc/client" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +const ( + passiveObserverTimeout = 10 * time.Minute + passiveObserverPoll = 250 * time.Millisecond + passiveActorOneIndex = 12 + passiveActorTwoIndex = 13 + passiveLeafJoinDelay = 64 +) + +// TestPrtPassiveDisputeObserver uses external actors, not node dispute logic. +// One actor submits exactly the node's empty-epoch commitment; the other changes +// only its final leaf. Both use valid membership proofs through the official +// three levels. Actor A wins by timeout; no VM step witness is fabricated. +// The node must expose all phases, both when its local commitment wins and when +// it loses. Only the matching winner is staged with a real machine-state proof. +func TestPrtPassiveDisputeObserver(t *testing.T) { + if !isNodeSelfManaged() { + t.Skip("passive dispute fixture requires a test-managed node to disable claim submission") + } + for _, scenario := range []struct { + name string + localWins bool + }{ + {name: "matching_external_winner", localWins: true}, + {name: "losing_local_commitment", localWins: false}, + } { + t.Run(scenario.name, func(t *testing.T) { runPassiveDisputeObserver(t, scenario.localWins) }) + } +} + +func runPassiveDisputeObserver(t *testing.T, localWins bool) { + ctx, cancel := context.WithTimeout(context.Background(), passiveObserverTimeout) + defer cancel() + client := newIntegrationEthClient(ctx, t) + defer client.Close() + startReaderNode(ctx, t) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) + defer cleanupCancel() + require.NoError(t, anvilRPC(cleanupCtx, "evm_setAutomine", true)) + require.NoError(t, anvilRPC(cleanupCtx, "evm_setIntervalMining", 1)) + }) + setAnvilIntervalMining(ctx, t, 0) + setAnvilAutomine(ctx, t, true) + var logs LogChecker + logs.StartLogCapture() + logs.SetExpectedLogs(t, prtBlockOutOfRangeAllowlist) + t.Cleanup(func() { logs.CheckLogs(t) }) + fixture := newPassiveDisputeFixture(ctx, t, client, localWins) + if !localWins { + reason := regexp.QuoteMeta(fmt.Sprintf("Epoch 0 has inconsistent commitment between off-chain (%s) and on-chain (%s)", + *fixture.epoch.Commitment, fixture.layers[0].trees[0].root())) + logs.SetExpectedLogs(t, + prtBlockOutOfRangeAllowlist, + ExpectedLog{ + Pattern: regexp.MustCompile("marking application as diverged.*application=" + regexp.QuoteMeta(fixture.appName) + + ".*" + reason), + Level: LevelError, Required: true, + Reason: "the external timeout winner differs from this application's locally computed commitment", + }, + ExpectedLog{ + Pattern: regexp.MustCompile("Tick.*service=prt.*validating PRT application " + + regexp.QuoteMeta(fixture.appAddress.Hex()) + ": " + reason), + Level: LevelError, Required: true, + Reason: "the PRT service reports this application's expected divergence transition to the service loop", + }, + ) + } + fixture.run() +} + +type passiveDisputeFixture struct { + t *testing.T + r *require.Assertions + ctx context.Context + client *ethclient.Client + rpc *jsonrpcclient.Client + appName string + appAddress common.Address + localWins bool + epoch *Epoch + consensus *idaveconsensus.IDaveConsensus + actors [2]*bind.TransactOpts + layers []*passiveDisputeLayer + receipts []*types.Receipt +} + +type passiveDisputeLayer struct { + address common.Address + contract *itournament.ITournament + descriptor itournament.ITournamentTournamentDescriptor + trees [2]*sparseDisputeCommitment + matchID itournament.MatchId + matchHash common.Hash +} + +func newPassiveDisputeFixture(ctx context.Context, t *testing.T, client *ethclient.Client, localWins bool) *passiveDisputeFixture { + t.Helper() + f := &passiveDisputeFixture{t: t, r: require.New(t), ctx: ctx, client: client, + rpc: jsonrpcclient.NewClient(envOrDefault("CARTESI_JSONRPC_API_URL", "http://localhost:10011/rpc")), + appName: uniqueAppName("prt-passive-dispute"), localWins: localWins} + f.actors[0] = transactorForMnemonicIndex(ctx, t, client, passiveActorOneIndex) + f.actors[1] = transactorForMnemonicIndex(ctx, t, client, passiveActorTwoIndex) + for _, actor := range f.actors { + f.r.NoError(anvilSetBalance(ctx, actor.From.Hex(), "0x3635c9adc5dea00000"), "fund external dispute actor") + } + _, err := deployApplication(ctx, f.appName, envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp"), + prtFlag, claimStagingPeriodFlag, "5", "--salt", uniqueSalt()) + f.r.NoError(err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) + defer cleanupCancel() + f.r.NoError(disableApplication(cleanupCtx, f.appName)) + }) + _, err = waitForEpochStatus(ctx, t, f.appName, 0, EpochStatus_ClaimComputed) + f.r.NoError(err, "compute the empty epoch without node submission") + dsn, err := config.GetDatabaseConnection() + f.r.NoError(err) + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + f.r.NoError(err) + defer repo.Close() + f.epoch, err = repo.GetEpoch(ctx, f.appName, 0) + f.r.NoError(err) + f.r.NotNil(f.epoch) + f.r.NotNil(f.epoch.MachineHash) + f.r.NotNil(f.epoch.Commitment) + f.r.NotNil(f.epoch.TournamentAddress) + f.r.Equal(f.epoch.InputIndexLowerBound, f.epoch.InputIndexUpperBound, "epoch 0 must be empty") + app, err := repo.GetApplication(ctx, f.appName) + f.r.NoError(err) + f.r.NotNil(app) + f.appAddress = app.IApplicationAddress + f.consensus, err = idaveconsensus.NewIDaveConsensus(app.IConsensusAddress, client) + f.r.NoError(err) + f.newLayer(*f.epoch.TournamentAddress) + localActor := 0 + if !localWins { + localActor = 1 + } + f.r.Equal(*f.epoch.Commitment, f.layers[0].trees[localActor].root(), "one actor must submit the node's exact commitment") + f.r.Equal(*f.epoch.MachineHash, common.Hash(f.layers[0].descriptor.InitialHash), "empty epoch state must remain unchanged") + return f +} + +func (f *passiveDisputeFixture) run() { + for level := 0; level < 3; level++ { + layer := f.layers[level] + f.joinPair(layer) + f.assertCurrent(layer, MatchPhaseBisecting) + f.bisect(layer) + f.assertCurrent(layer, MatchPhaseReadyToSeal) + child := f.seal(layer) + f.assertCurrent(layer, MatchPhaseSealed) + if child != (common.Address{}) { + f.newLayer(child) + } + } + f.winLeafByTimeout() + for level := len(f.layers) - 2; level >= 0; level-- { + parent, child := f.layers[level], f.layers[level+1] + f.assertWinner(child, TournamentStandingInnerWinner) + left, right, err := parent.trees[0].rightmostChildren(parent.descriptor.Height) + f.r.NoError(err) + tx, err := parent.contract.WinInnerTournament(f.actors[0], child.address, left, right) + f.record(tx, err) + f.assertCurrent(parent, MatchPhaseUninitialized) + } + f.assertWinner(f.layers[0], TournamentStandingRootWinner) + if f.localWins { + f.stageAndAccept() + } else { + f.assertLocalDivergence() + } + // The completed root remains observable after acceptance or divergence. + tx, err := f.layers[0].contract.TryRecoveringBond(f.actors[0]) + f.record(tx, err) + f.assertRecoveredRoot() + // The root can now retire. Its children must still receive no-event + // snapshot updates when their winner carryover windows expire. + f.assertInnerWinnersExpire() + f.assertAllEvents() + f.assertOnlyExternalTransactions() + if !f.localWins { + f.assertLocalDivergence() + } +} + +func (f *passiveDisputeFixture) newLayer(address common.Address) { + contract, err := itournament.NewITournament(address, f.client) + f.r.NoError(err) + descriptor, err := contract.TournamentDescriptor(&bind.CallOpts{Context: f.ctx}) + f.r.NoError(err) + heights := []uint64{48, 17, 27} + strides := []uint64{44, 27, 0} + level := len(f.layers) + f.r.Less(level, len(heights)) + f.r.Equal(uint64(level), descriptor.Level) + f.r.Equal(heights[level], descriptor.Height) + f.r.Equal(strides[level], descriptor.Log2Stride) + f.r.Greater(descriptor.Allowance, descriptor.Height+passiveLeafJoinDelay, + "official allowance must leave time for the complete fixture") + state := *f.epoch.MachineHash + falseState := crypto.Keccak256Hash(state[:], []byte("external incorrect final state")) + correct, err := newSparseDisputeCommitment(descriptor.Height, state, state) + f.r.NoError(err) + incorrect, err := newSparseDisputeCommitment(descriptor.Height, state, falseState) + f.r.NoError(err) + oneTree, twoTree := correct, incorrect + if !f.localWins { + oneTree, twoTree = incorrect, correct + } + one, two := oneTree.root(), twoTree.root() + f.layers = append(f.layers, &passiveDisputeLayer{address: address, contract: contract, descriptor: descriptor, + trees: [2]*sparseDisputeCommitment{oneTree, twoTree}, + matchID: itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}, matchHash: crypto.Keccak256Hash(one[:], two[:])}) +} + +func (f *passiveDisputeFixture) record(tx *types.Transaction, err error) *types.Receipt { + f.t.Helper() + f.r.NoError(err, "send external participant transaction") + f.r.NotNil(tx) + ctx, cancel := context.WithTimeout(f.ctx, time.Minute) + defer cancel() + receipt := waitReceipt(ctx, f.t, f.client, tx) + f.r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + f.receipts = append(f.receipts, receipt) + return receipt +} + +func (f *passiveDisputeFixture) mineTo(target uint64) { + f.t.Helper() + head, err := f.client.BlockNumber(f.ctx) + f.r.NoError(err) + if target <= head { + return + } + f.r.LessOrEqual(target-head, uint64(maxBlocksToMine)) + f.r.NoError(anvilRPC(f.ctx, "anvil_mine", hexutil.EncodeUint64(target-head)), "mine a bounded timeout or staging interval") +} + +func (f *passiveDisputeFixture) stageAndAccept() { + epoch := f.epoch + f.r.NotNil(epoch.IflagsYDataBlock) + f.r.NotNil(epoch.HtifTohostDataBlock) + f.r.NotNil(epoch.TxBufferDataBlock) + leaf := func(data common.Hash, siblings []common.Hash) idaveconsensus.LeafProof { + proof := make([][32]byte, len(siblings)) + for i, sibling := range siblings { + proof[i] = sibling + } + return idaveconsensus.LeafProof{DataBlock: data, Siblings: proof} + } + proof := idaveconsensus.MachineValidityProof{ + IflagsYProof: leaf(*epoch.IflagsYDataBlock, epoch.IflagsYProof), + HtifTohostProof: leaf(*epoch.HtifTohostDataBlock, epoch.HtifTohostProof), + TxBufferProof: leaf(*epoch.TxBufferDataBlock, epoch.TxBufferProof), + } + tx, err := f.consensus.StageTournamentResult(f.actors[0], new(big.Int), proof) + stagedReceipt := f.record(tx, err) + staged, err := waitForEpochStatus(f.ctx, f.t, f.appName, 0, EpochStatus_ClaimStaged) + f.r.NoError(err) + f.r.NotNil(staged.StagedAtBlock) + f.r.Equal(stagedReceipt.BlockNumber.Uint64(), *staged.StagedAtBlock) + period, err := f.consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: f.ctx}) + f.r.NoError(err) + f.r.True(period.IsUint64()) + f.mineTo(*staged.StagedAtBlock + period.Uint64()) + tx, err = f.consensus.AcceptStagedTournamentResult(f.actors[0], new(big.Int)) + acceptedReceipt := f.record(tx, err) + accepted, err := waitForEpochStatus(f.ctx, f.t, f.appName, 0, EpochStatus_ClaimAccepted) + f.r.NoError(err) + f.r.Equal(epoch.MachineHash, accepted.MachineHash) + f.r.Equal(epoch.Commitment, accepted.Commitment) + f.r.NotNil(accepted.ClaimTransactionHash) + f.r.Equal(acceptedReceipt.TxHash, *accepted.ClaimTransactionHash) + var app api.SingleResponse[*Application] + f.r.NoError(f.rpc.Call(f.ctx, "cartesi_getApplication", api.GetApplicationParams{Application: f.appName}, &app)) + f.r.NotNil(app.Data) + f.r.Equal(ApplicationStatus_OK, app.Data.Status, "an identical external winner must not cause divergence") + f.r.True(app.Data.Enabled) +} diff --git a/test/integration/reader_node_helpers_test.go b/test/integration/reader_node_helpers_test.go new file mode 100644 index 000000000..d64433d84 --- /dev/null +++ b/test/integration/reader_node_helpers_test.go @@ -0,0 +1,80 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/claimer" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prt" + "github.com/cartesi/rollups-node/internal/repository/factory" +) + +// startReaderNode uses matching saved/requested settings in the isolated test +// database. This is fixture setup, not a supported production mode transition. +// Cleanup stops the reader, restores the exact saved records, and restarts the +// default node before the next test. Latest supports fixtures that pause mining. +func startReaderNode(ctx context.Context, t *testing.T) { + t.Helper() + dsn, err := config.GetDatabaseConnection() + require.NoError(t, err) + repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) + require.NoError(t, err) + t.Cleanup(repo.Close) + overrides := []struct { + key string + fields map[string]any + }{ + {prt.PrtConfigKey, map[string]any{passiveDefaultBlockField: model.DefaultBlock_Latest, passiveClaimSubmissionField: false}}, + {evmreader.EvmReaderConfigKey, map[string]any{passiveDefaultBlockField: model.DefaultBlock_Latest}}, + {claimer.ClaimerConfigKey, map[string]any{passiveDefaultBlockField: model.DefaultBlock_Latest, passiveClaimSubmissionField: false}}, + } + type savedConfig struct { + key string + raw, updated []byte + } + saved := make([]savedConfig, 0, len(overrides)) + registerPassiveObserverCleanup(t, func() { + if sharedNode != nil { + stopSharedNode(t) + } + }, func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + restored := true + for _, original := range saved { + if err := repo.SaveNodeConfigRaw(cleanupCtx, original.key, original.raw); err != nil { + t.Errorf("restore %s persistent config: %v", original.key, err) + restored = false + } + } + require.True(t, restored, "do not restart the default node with a partially restored config") + }, func() { startSharedNode(t) }) + // Register cleanup before stopping: an already-exited child can fail the + // stop assertion after clearing sharedNode, but still needs a default restart. + stopSharedNode(t) + for _, override := range overrides { + raw, _, _, err := repo.LoadNodeConfigRaw(ctx, override.key) + require.NoError(t, err, "the healthy test-managed node must have initialized %s config", override.key) + updated, err := patchPassiveObserverConfig(raw, override.fields) + require.NoError(t, err) + saved = append(saved, savedConfig{override.key, append([]byte(nil), raw...), updated}) + } + // Validate all originals before the first write. Cleanup is already registered + // even if a write succeeds but its reply fails. Never change the parent env. + for _, override := range saved { + require.NoError(t, repo.SaveNodeConfigRaw(ctx, override.key, override.updated)) + } + startSharedNodeWithEnv(t, "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED=false", + "CARTESI_BLOCKCHAIN_DEFAULT_BLOCK=latest") +} diff --git a/test/integration/refund_lifecycle_test.go b/test/integration/refund_lifecycle_test.go new file mode 100644 index 000000000..48c5b5e9c --- /dev/null +++ b/test/integration/refund_lifecycle_test.go @@ -0,0 +1,435 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/outputs" +) + +const ( + refundPayerIndex uint32 = 9 + refundDepositAmount uint64 = 37 + refundSelectorBytes = 4 +) + +// TestRefundLifecycle stops every submitter before the second deposit exists. +// No claim prepared before shutdown can finalize that deposit. Foreclosure is +// mined before restarting, so the finalization boundary does not depend on a +// race against the background claimer, epoch timing, or local input status. +func TestRefundLifecycle(t *testing.T) { + if !isNodeSelfManaged() { + t.Skip("refund finalization control requires a test-managed node") + } + for _, consensus := range []withdrawalConsensus{ + withdrawalConsensusAuthority, withdrawalConsensusQuorum, withdrawalConsensusPRT, + } { + t.Run(string(consensus), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + client := newIntegrationEthClient(ctx, t) + t.Cleanup(client.Close) + chainID, err := client.ChainID(ctx) + require.NoError(t, err) + + // Reuse the existing lifecycle helpers without running its suite. + s := &WithdrawalLifecycleSuite{ctx: ctx, client: client, chainID: chainID} + s.SetT(t) + s.StartLogCapture() + if consensus == withdrawalConsensusPRT { + // PRT settlement mines blocks rapidly while the node is running. + s.SetExpectedLogs(t, prtBlockOutOfRangeAllowlist) + } + t.Cleanup(func() { s.CheckLogs(t) }) + // Keep recovery separate: a fatal cleanup assertion must not leave + // the shared node stopped for subsequent integration tests. + t.Cleanup(func() { + if sharedNode == nil { + startSharedNode(t) + } + }) + t.Cleanup(func() { + if s.appName != "" { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) + defer cleanupCancel() + require.NoError(t, disableApplication(cleanupCtx, s.appName)) + } + }) + runRefundLifecycle(s, consensus) + }) + } +} + +type refundLifecycleFixture struct { + s *WithdrawalLifecycleSuite + app withdrawalAppDeployment + contract *iapplication.IApplication + portal common.Address + token common.Address + first *iinputbox.IInputBoxInputAdded + second *iinputbox.IInputBoxInputAdded +} + +func runRefundLifecycle(s *WithdrawalLifecycleSuite, consensus withdrawalConsensus) { + r := s.Require() + dappPath := envOrDefault("CARTESI_TEST_ERC20_WITHDRAWAL_DAPP_PATH", "applications/erc20-withdrawal-dapp") + f := refundLifecycleFixture{ + s: s, + portal: devnetAddress(s.T(), "CARTESI_DEVNET_ERC20_PORTAL_ADDRESS", defaultDevnetERC20PortalAddress), + token: devnetAddress(s.T(), "CARTESI_DEVNET_TEST_ERC20_ADDRESS", defaultDevnetTestERC20Address), + } + depositor := mnemonicAddress(s.T(), withdrawalUserIndex) + payer := mnemonicAddress(s.T(), refundPayerIndex) + guardian := mnemonicAddress(s.T(), withdrawalGuardianIndex) + r.NotEqual(depositor, payer) + r.NotEqual(guardian, payer) + r.NotEqual(guardian, depositor) + r.NoError(anvilSetBalance(s.ctx, depositor.Hex(), oneEtherWei)) + r.NoError(anvilSetBalance(s.ctx, payer.Hex(), oneEtherWei)) + initialDepositorBalance := s.tokenBalance(f.token, depositor) + s.mintTestToken(f.token, withdrawalUserIndex, tokenAmount(withdrawalDepositAmount+refundDepositAmount)) + f.app = s.deployWithdrawalApp(consensus, dappPath) + s.appName = f.app.appName + var err error + f.contract, err = iapplication.NewIApplication(f.app.appAddress, s.client) + r.NoError(err) + if consensus != withdrawalConsensusPRT { + s.waitForIConsensusInputCursor(s.appName) + } + + f.first = f.deposit(withdrawalDepositAmount) + firstInput := s.waitForAcceptedInput(s.appName, f.first.Index.Uint64()) + s.finalizeWithdrawalEpoch(consensus, f.app, firstInput.EpochIndex) + f.requireFinalized(f.first, true) + firstFile := f.exportInput(f.first) + + stopSharedNode(s.T()) + f.second = f.deposit(refundDepositAmount) + f.requireFinalized(f.first, true) + f.requireFinalized(f.second, false) + s.requireTokenBalance(f.token, depositor, initialDepositorBalance, "both deposits left the depositor") + s.requireTokenBalance(f.token, f.app.appAddress, + tokenAmount(withdrawalDepositAmount+refundDepositAmount), "application holds both deposits") + before := f.state() + r.Zero(before.RefundCount.Sign()) + r.False(before.FirstRefunded) + r.False(before.SecondRefunded) + + // The node has not indexed this deposit yet. Use the actual InputAdded + // bytes for the pre-foreclosure rejection, then export them through the + // existing node API after restart for the successful CLI invocation. + eventFile := f.inputFile("event-input.hex", hexutil.Encode(f.second.Input)) + f.reject(s.appName, f.second.Index.String(), eventFile, "NotForeclosed", before) + forecloseJSON, err := runCLIWithEnv(s.ctx, + []string{fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", withdrawalGuardianIndex)}, + "foreclose", s.appName, "--yes", "--json") + r.NoError(err) + forecloseReceipt := f.cliReceipt(forecloseJSON) + foreclosed, err := f.contract.IsForeclosed(&bind.CallOpts{Context: s.ctx, BlockNumber: forecloseReceipt.BlockNumber}) + r.NoError(err) + r.True(foreclosed) + f.requireFinalized(f.first, true) + f.requireFinalized(f.second, false) + startSharedNode(s.T()) + forecloseCtx, forecloseCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + r.NoError(waitForApplicationForeclosed(forecloseCtx, s.T(), s.appName)) + forecloseCancel() + secondInput := s.waitForAcceptedInput(s.appName, f.second.Index.Uint64()) + r.Equal(model.InputCompletionStatus_Accepted, secondInput.Status, + "local execution acceptance does not make an input finalized by consensus") + secondFile := f.exportInput(f.second) + f.requireFinalized(f.second, false) + r.Equal(before, f.state(), "foreclosure and node replay do not transfer tokens or issue refunds") + + // Tampering must be checked before a successful refund sets the replay + // bitmap; afterwards even bad bytes would hit RefundAlreadyIssued first. + tampered := bytes.Clone(f.second.Input) + tampered[len(tampered)-1] ^= 1 + tamperedFile := f.inputFile("tampered-input.hex", hexutil.Encode(tampered)) + f.reject(s.appName, f.second.Index.String(), tamperedFile, "InvalidInputHash", before) + f.reject(f.app.appAddress.Hex(), f.first.Index.String(), firstFile, "CannotRefundFinalizedInput", before) + + // A manual gas limit bypasses estimation, not receipt checks. The invalid + // refund must mine, fail, and leave the contract and token state unchanged. + nonceBefore, err := s.client.NonceAt(s.ctx, payer, nil) + r.NoError(err) + _, err = f.refund(f.app.appAddress.Hex(), f.first.Index.String(), firstFile, "--gas-limit", "500000") + r.ErrorContains(err, "receipt status 0") + nonceAfter, err := s.client.NonceAt(s.ctx, payer, nil) + r.NoError(err) + r.Equal(nonceBefore+1, nonceAfter, "the failed refund was mined, not rejected by estimation") + r.Equal(before, f.state(), "a mined failing refund must preserve token balances and refund state") + + appReference := s.appName + var transactionFlags []string + if consensus == withdrawalConsensusQuorum { + appReference = f.app.appAddress.Hex() + transactionFlags = []string{"--no-wait", "--gas-limit", "500000"} + } + result, err := f.refund(appReference, hexutil.EncodeBig(f.second.Index), secondFile, transactionFlags...) + r.NoError(err, "refund CLI must submit the original exported input") + var submitted struct { + Status string `json:"status"` + } + r.NoError(json.Unmarshal([]byte(result), &submitted)) + if consensus == withdrawalConsensusQuorum { + r.Equal("broadcast", submitted.Status) + } else { + r.Equal("mined", submitted.Status) + } + receipt := f.cliReceipt(result) + f.requireRefundReceipt(receipt) + after := f.state() + r.Equal(tokenBalanceWithDelta(initialDepositorBalance, refundDepositAmount), after.DepositorBalance) + r.Equal(tokenAmount(withdrawalDepositAmount), after.ApplicationBalance, + "the finalized deposit remains in the application") + r.Equal(before.PayerBalance, after.PayerBalance, "the gas payer does not receive the deposited tokens") + r.Equal(tokenAmount(1), after.RefundCount) + r.False(after.FirstRefunded) + r.True(after.SecondRefunded) + r.Equal(before.ExecutedOutputCount, after.ExecutedOutputCount) + r.Equal(before.WithdrawalCount, after.WithdrawalCount) + f.requireFinalized(f.first, true) + f.requireFinalized(f.second, false) + f.reject(f.app.appAddress.Hex(), f.second.Index.String(), secondFile, "RefundAlreadyIssued", after) +} + +func (f *refundLifecycleFixture) deposit(amount uint64) *iinputbox.IInputBoxInputAdded { + s, r := f.s, f.s.Require() + start, err := s.client.BlockNumber(s.ctx) + r.NoError(err) + inputBoxAddr := inputBoxAddress(s.T()) + index := inputBoxInputCount(s.ctx, s.T(), s.client, inputBoxAddr, f.app.appAddress) + s.depositERC20(s.appName, f.portal, f.token, withdrawalUserIndex, amount) + end, err := s.client.BlockNumber(s.ctx) + r.NoError(err) + inputBox, err := iinputbox.NewIInputBox(inputBoxAddr, s.client) + r.NoError(err) + events, err := inputBox.FilterInputAdded(&bind.FilterOpts{Context: s.ctx, Start: start, End: &end}, + []common.Address{f.app.appAddress}, []*big.Int{new(big.Int).SetUint64(index)}) + r.NoError(err) + defer func() { r.NoError(events.Close()) }() + r.True(events.Next(), "deposit must emit InputAdded") + event := events.Event + r.Equal(f.app.appAddress, event.AppContract) + r.Equal(index, event.Index.Uint64()) + r.NotEmpty(event.Input) + r.False(events.Next(), "one portal deposit must emit exactly one scoped input") + r.NoError(events.Error()) + return event +} + +func (f *refundLifecycleFixture) exportInput(event *iinputbox.IInputBoxInputAdded) string { + s, r := f.s, f.s.Require() + out, err := runCLI(s.ctx, "read", "inputs", s.appName, event.Index.String(), "--jsonrpc") + r.NoError(err, "export the original input through JSON-RPC") + var response api.SingleResponse[struct { + RawData string `json:"raw_data"` + }] + r.NoError(json.Unmarshal([]byte(out), &response)) + r.Equal(hexutil.Encode(event.Input), response.Data.RawData, "data.raw_data must equal the full InputAdded.input") + return f.inputFile("exported-input.hex", response.Data.RawData) +} + +func (f *refundLifecycleFixture) inputFile(name, input string) string { + file := filepath.Join(f.s.T().TempDir(), name) + f.s.Require().NoError(os.WriteFile(file, []byte(input+"\n"), 0600)) + return file +} + +func (f *refundLifecycleFixture) requireFinalized(event *iinputbox.IInputBoxInputAdded, want bool) { + s, r := f.s, f.s.Require() + head, err := s.client.BlockNumber(s.ctx) + r.NoError(err) + opts := &bind.CallOpts{Context: s.ctx, BlockNumber: new(big.Int).SetUint64(head)} + validatorAddress, err := f.contract.GetOutputsMerkleRootValidator(opts) + r.NoError(err) + // wasInputFinalized has the same released interface for Authority, + // Quorum, and Dave, though their finalization frontiers differ. + validator, err := iconsensus.NewIConsensus(validatorAddress, s.client) + r.NoError(err) + finalized, err := validator.WasInputFinalized(opts, f.app.appAddress, event.Index, + new(big.Int).SetUint64(event.Raw.BlockNumber)) + r.NoError(err) + r.Equal(want, finalized, "input %s finalization at block %d", event.Index, head) +} + +type refundChainState struct { + DepositorBalance, ApplicationBalance, PayerBalance *big.Int + RefundCount, ExecutedOutputCount, WithdrawalCount *big.Int + FirstRefunded, SecondRefunded bool +} + +func (f *refundLifecycleFixture) state() refundChainState { + s, r := f.s, f.s.Require() + state := refundChainState{ + DepositorBalance: s.tokenBalance(f.token, mnemonicAddress(s.T(), withdrawalUserIndex)), + ApplicationBalance: s.tokenBalance(f.token, f.app.appAddress), + PayerBalance: s.tokenBalance(f.token, mnemonicAddress(s.T(), refundPayerIndex)), + } + opts := &bind.CallOpts{Context: s.ctx} + var err error + state.RefundCount, err = f.contract.GetNumberOfIssuedRefunds(opts) + r.NoError(err) + state.ExecutedOutputCount, err = f.contract.GetNumberOfExecutedOutputs(opts) + r.NoError(err) + state.WithdrawalCount, err = f.contract.GetNumberOfWithdrawals(opts) + r.NoError(err) + state.FirstRefunded, err = f.contract.WasRefundForInputIssued(opts, f.first.Index) + r.NoError(err) + state.SecondRefunded, err = f.contract.WasRefundForInputIssued(opts, f.second.Index) + r.NoError(err) + return state +} + +func (f *refundLifecycleFixture) refund(application, index, inputFile string, flags ...string) (string, error) { + args := []string{"refund", application, index, "--input-file", inputFile, "--yes", "--json"} + args = append(args, flags...) + return runCLIWithEnv(f.s.ctx, + []string{fmt.Sprintf("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=%d", refundPayerIndex)}, + args...) +} + +func (f *refundLifecycleFixture) reject(application, index, inputFile, revertName string, want refundChainState) { + _, err := f.refund(application, index, inputFile) + f.s.Require().Error(err) + f.s.Require().ErrorContains(err, "decoded revert: "+revertName) + f.s.Require().Equal(want, f.state(), "rejected refund must preserve balances, counters, and replay flags") +} + +func (f *refundLifecycleFixture) cliReceipt(out string) *types.Receipt { + s, r := f.s, f.s.Require() + var result struct { + TransactionHash common.Hash `json:"transaction_hash"` + ApplicationAddress common.Address `json:"application_address"` + Status string `json:"status"` + BlockNumber string `json:"block_number"` + } + r.NoError(json.Unmarshal([]byte(out), &result)) + r.Equal(f.app.appAddress, result.ApplicationAddress) + r.NotEqual(common.Hash{}, result.TransactionHash) + if result.Status == "mined" { + // Waiting is now the CLI default: its successful result must already + // have a successful receipt. Do not hide a missing wait with polling. + receipt, err := s.client.TransactionReceipt(s.ctx, result.TransactionHash) + r.NoError(err) + r.NotNil(receipt) + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + r.Equal(result.TransactionHash, receipt.TxHash) + r.Equal(hexutil.EncodeBig(receipt.BlockNumber), result.BlockNumber) + return receipt + } + r.Equal("broadcast", result.Status) + r.Empty(result.BlockNumber, "broadcast-only output must not claim a mined block") + ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) + defer cancel() + var receipt *types.Receipt + err := pollUntil(ctx, time.Second, func() (bool, error) { + var err error + receipt, err = s.client.TransactionReceipt(ctx, result.TransactionHash) + if errors.Is(err, ethereum.NotFound) { + return false, nil + } + return receipt != nil, err + }) + r.NoError(err) + r.Equal(result.TransactionHash, receipt.TxHash) + r.Equal(types.ReceiptStatusSuccessful, receipt.Status) + return receipt +} + +func (f *refundLifecycleFixture) requireRefundReceipt(receipt *types.Receipt) { + s, r := f.s, f.s.Require() + tx, pending, err := s.client.TransactionByHash(s.ctx, receipt.TxHash) + r.NoError(err) + r.False(pending) + sender, err := types.Sender(types.LatestSignerForChainID(s.chainID), tx) + r.NoError(err) + r.Equal(mnemonicAddress(s.T(), refundPayerIndex), sender, "a distinct gas payer signed the actual transaction") + r.Equal(&f.app.appAddress, tx.To()) + appABI, err := iapplication.IApplicationMetaData.GetAbi() + r.NoError(err) + wantCall, err := appABI.Pack("issueRefund", f.second.Index, f.second.Input) + r.NoError(err) + r.Equal(wantCall, tx.Data()) + + token, err := ierc20metadata.NewIERC20Metadata(f.token, s.client) + r.NoError(err) + tokenABI, err := ierc20metadata.IERC20MetadataMetaData.GetAbi() + r.NoError(err) + refunds, transfers := 0, 0 + for _, raw := range receipt.Logs { + r.NotNil(raw) + if raw.Address == f.app.appAddress && len(raw.Topics) > 0 && raw.Topics[0] == appABI.Events["RefundIssued"].ID { + event, err := f.contract.ParseRefundIssued(*raw) + r.NoError(err) + r.Equal(f.second.Index, event.InputIndex) + r.Equal(f.second.Input, event.Input) + r.Equal(receipt.TxHash, event.Raw.TxHash) + r.Equal(receipt.BlockNumber.Uint64(), event.Raw.BlockNumber) + f.requireRefundOutput(event.Output, receipt.BlockNumber) + refunds++ + } + if raw.Address == f.token && len(raw.Topics) > 0 && raw.Topics[0] == tokenABI.Events["Transfer"].ID { + event, err := token.ParseTransfer(*raw) + r.NoError(err) + r.Equal(f.app.appAddress, event.From) + r.Equal(mnemonicAddress(s.T(), withdrawalUserIndex), event.To) + r.Equal(tokenAmount(refundDepositAmount), event.Value) + transfers++ + } + } + r.Equal(1, refunds, "exactly one RefundIssued must be emitted") + r.Equal(1, transfers, "exactly one ERC-20 transfer must pay the original depositor") +} + +func (f *refundLifecycleFixture) requireRefundOutput(raw []byte, block *big.Int) { + s, r := f.s, f.s.Require() + outputABI, err := outputs.OutputsMetaData.GetAbi() + r.NoError(err) + output, err := api.DecodeOutput(&model.Output{RawData: raw}, outputABI) + r.NoError(err) + r.Equal("DelegateCallVoucher", output.DecodedData.Type) + destination := common.HexToAddress(output.DecodedData.Destination) + code, err := s.client.CodeAt(s.ctx, destination, block) + r.NoError(err) + r.NotEmpty(code, "the canonical ERC-20 refund delegates to a deployed safe-transfer contract") + // ISafeErc20Transfer.safeTransfer(token, to, value), as encoded by the + // released LibErc20Deposit.buildRefund. Only the deployed destination is + // taken from the event; token, recipient, amount, and full encoding are checked. + payload := crypto.Keccak256([]byte("safeTransfer(address,address,uint256)"))[:refundSelectorBytes] + payload = append(payload, common.LeftPadBytes(f.token.Bytes(), common.HashLength)...) + payload = append(payload, common.LeftPadBytes(mnemonicAddress(s.T(), withdrawalUserIndex).Bytes(), common.HashLength)...) + payload = append(payload, common.LeftPadBytes(tokenAmount(refundDepositAmount).Bytes(), common.HashLength)...) + r.Equal(hexutil.Encode(payload), output.DecodedData.Payload) + want, err := outputABI.Pack("DelegateCallVoucher", destination, payload) + r.NoError(err) + r.Equal(want, raw, "RefundIssued.output must contain exactly the canonical transfer output") +} diff --git a/test/integration/reject_exception_prt_test.go b/test/integration/reject_exception_prt_test.go index 74868135b..8ff18a58d 100644 --- a/test/integration/reject_exception_prt_test.go +++ b/test/integration/reject_exception_prt_test.go @@ -87,8 +87,8 @@ func (s *RejectExceptionPrtSuite) TestRejectInputPrt() { ExtraDeployArgs: []string{"--prt"}, EpochIndex: &prtEpoch, PreClaimHook: func(ctx context.Context, t testing.TB, require *require.Assertions, appName string) { - settleTournament(ctx, t, require, ethClient, appName, 0) - settleTournament(ctx, t, require, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 1) }, }) } diff --git a/test/integration/restart_test.go b/test/integration/restart_test.go index 9d6e28e93..26f2a7592 100644 --- a/test/integration/restart_test.go +++ b/test/integration/restart_test.go @@ -13,11 +13,13 @@ package integration import ( "context" + "os/exec" "regexp" "testing" "time" "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -74,6 +76,29 @@ func (s *RestartSuite) TearDownTest() { s.CheckLogs(s.T()) } +func (s *RestartSuite) TestExitedNodeFailsReadiness() { + for _, script := range []string{"exit 0", "exit 7"} { + s.Run(script, func() { + t := s.T() + r := require.New(t) + cmd := exec.CommandContext(t.Context(), "sh", "-c", script) + proc, err := startNodeSubprocess("startup-probe", cmd) + r.NoError(err) + t.Cleanup(func() { + if !proc.isDone() { + _ = cmd.Process.Kill() + } + r.True(proc.wait(time.Second)) + }) + r.True(proc.wait(time.Second)) + ctx, cancel := context.WithTimeout(s.ctx, time.Second) + defer cancel() + node := &nodeProcess{nodeSubprocess: proc} + r.ErrorContains(node.waitForHealth(ctx, t), "startup-probe exited unexpectedly") + }) + } +} + // restartConfig configures the shared restart test flow. type restartConfig struct { // ExtraDeployArgs are additional CLI flags for deploy (e.g., "--prt"). @@ -98,6 +123,7 @@ func (s *RestartSuite) runRestartTest(cfg restartConfig) { // === Phase 1: Deploy two apps and process inputs === s.T().Log("--- Phase 1: Deploy two apps and process inputs before restart ---") + var app1Address common.Address func() { defer timed(s.T(), "deploy two echo-dapps")() @@ -111,6 +137,7 @@ func (s *RestartSuite) runRestartTest(cfg restartConfig) { addr1, err := deployApplication( s.ctx, s.app1Name, dappPath, deployArgs1...) require.NoError(err, "deploy app-1") + app1Address = common.HexToAddress(addr1) s.T().Logf(" app-1 deployed at %s", addr1) s.T().Logf(" deploying app-2: name=%s", s.app2Name) @@ -127,8 +154,7 @@ func (s *RestartSuite) runRestartTest(cfg restartConfig) { }() s.T().Log("Sending one input to each app before restart...") - idx1, _, err := sendInput(s.ctx, s.app1Name, "pre-restart-1") - require.NoError(err, "send input to app-1") + idx1, _, _ := sendInputThroughRelay(s.ctx, s.T(), app1Address, "pre-restart-1") s.T().Logf(" app-1: input sent (index=%d)", idx1) idx2, _, err := sendInput(s.ctx, s.app2Name, "pre-restart-2") @@ -286,7 +312,7 @@ func (s *RestartSuite) TestRestartMultiAppAuthority() { // TestRestartMultiAppPrt tests restart with PRT (Dave) consensus. func (s *RestartSuite) TestRestartMultiAppPrt() { - // PRT settlement mines hundreds of blocks rapidly, which can cause + // PRT finalization mines hundreds of blocks rapidly, which can cause // transient BlockOutOfRangeError in the EVM reader. s.SetExpectedLogs(s.T(), ExpectedLog{ Pattern: regexp.MustCompile(`BlockOutOfRangeError`), @@ -304,19 +330,19 @@ func (s *RestartSuite) TestRestartMultiAppPrt() { defer ethClient.Close() s.runRestartTest(restartConfig{ - ExtraDeployArgs: []string{"--prt"}, + ExtraDeployArgs: []string{prtFlag}, PreClaimHook: func( ctx context.Context, t testing.TB, require *require.Assertions, _ string, ) { - // Settle tournaments for BOTH apps together. Mining blocks + // Finalize tournaments for BOTH apps together. Mining blocks // for one app's tournament timeout also advances the shared // chain, so we must ensure all apps' commitments are joined // before mining — otherwise the other app's tournament can // time out without a commitment ("finished without winners"). apps := []string{s.app1Name, s.app2Name} for _, epochIdx := range []uint64{0, 1} { - var tournaments []*model.Tournament + tournaments := make([]*model.Tournament, 0, len(apps)) for _, name := range apps { t.Logf("Waiting for %s epoch %d "+ "tournament and commitment...", @@ -336,11 +362,13 @@ func (s *RestartSuite) TestRestartMultiAppPrt() { blocksMined, apps[i], epochIdx) } } - for _, name := range apps { + for i, name := range apps { waitForTournamentWinner( - ctx, t, require, name, epochIdx) + ctx, t, require, ethClient, name, epochIdx) + waitForPrtEpochAcceptedAndBondRecovered( + ctx, t, require, ethClient, name, epochIdx, tournaments[i].Address) } - t.Logf(" epoch %d settled for both apps", epochIdx) + t.Logf(" epoch %d finalized for both apps", epochIdx) } }, }) diff --git a/test/integration/same_block_inputs_test.go b/test/integration/same_block_inputs_test.go index e96348038..e95e36ae8 100644 --- a/test/integration/same_block_inputs_test.go +++ b/test/integration/same_block_inputs_test.go @@ -16,6 +16,7 @@ import ( "math/big" "net/http" "os" + "strconv" "strings" "testing" "time" @@ -304,20 +305,25 @@ func (s *SameBlockInputsSuite) runMultipleInputsOneBlock(extraDeployArgs []strin // new open epoch — the case where the two epochs overlap on one block // (sealed.LastBlock == open.FirstBlock). // -// The seal is DaveConsensus.settle(), which the node also issues on its own. -// We submit our own settle inside the batch (reusing the validator's computed -// outputs root + proof from the DB) and bracket the two inputs with extreme gas -// prices: the pre-seal input is mined first and the post-seal input last, so -// whichever settle the block orders between them captures an input-index -// boundary of exactly one. A duplicate node settle in the same block reverts -// harmlessly, leaving the same boundary. +// The seal is DaveConsensus.acceptStagedTournamentResult(), which the node also +// issues on its own. We first let the node stage the finished tournament. We +// then stop the node before the acceptance window. We submit our own acceptance +// inside the batch and bracket it with inputs that use extreme gas prices. The +// pre-seal input is mined first. The post-seal input is mined last. The stopped +// node cannot race the controlled acceptance transaction. func (s *SameBlockInputsSuite) TestInputsBeforeAndAfterEpochSealedSameBlock() { r := s.Require() + if !isNodeSelfManaged() { + s.T().Skip("skipping: same-block seal test requires a test-managed node") + } s.SetExpectedLogs(s.T(), prtBlockOutOfRangeAllowlist) s.appName = uniqueAppName("same-block-seal-prt") + const claimStagingPeriod uint64 = 300 dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp") - appAddrStr, err := deployApplication(s.ctx, s.appName, dappPath, "--salt", uniqueSalt(), "--prt") + appAddrStr, err := deployApplication(s.ctx, s.appName, dappPath, + "--salt", uniqueSalt(), "--prt", + claimStagingPeriodFlag, strconv.FormatUint(claimStagingPeriod, 10)) r.NoError(err, "deploy PRT app") appAddr := common.HexToAddress(appAddrStr) @@ -326,11 +332,11 @@ func (s *SameBlockInputsSuite) TestInputsBeforeAndAfterEpochSealedSameBlock() { "fresh PRT app should have no inputs yet (epoch 0 is sealed empty at deploy)") // Epoch 0 is sealed empty at deploy. Wait for the node to join its root - // tournament and for the validator to compute the outputs root + proof that - // settle(0) needs. This happens before we pass the timeout, so the node has - // not settled epoch 0 itself yet. + // tournament and for the validator to compute the machine-validity proof. + // This happens before we pass the timeout, so the node has not staged epoch + // 0 yet. tournament := s.mineUntilTournamentReady(0) - root, proof, consensusAddr := s.readEpochSettlementData(0) + consensusAddr := s.readPrtConsensusAddress() inputBox, err := iinputbox.NewIInputBox(inputBoxAddr, s.client) r.NoError(err, "bind input box") @@ -347,32 +353,66 @@ func (s *SameBlockInputsSuite) TestInputsBeforeAndAfterEpochSealedSameBlock() { return &opts } - // Keep mining under our control for the whole settle window so neither the - // timeout blocks nor the node's own settle get auto-mined out from under us. - setAnvilAutomine(s.ctx, s.T(), false) - defer setAnvilAutomine(s.ctx, s.T(), true) - setAnvilIntervalMining(s.ctx, s.T(), 0) - defer setAnvilIntervalMining(s.ctx, s.T(), 1) - - // Make settle(0) valid by passing epoch 0's root-tournament timeout. + // Finish the root tournament while automatic mining is still active. The + // node must stage the machine-validity proof before acceptance is possible. _, err = mineForTournamentTimeout(s.ctx, s.client, tournament.Address) r.NoError(err, "mine past epoch 0 tournament timeout") + waitForTournamentWinner(s.ctx, s.T(), r, s.client, s.appName, 0) + + stagedCtx, stagedCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) + stagedEpoch, err := waitForEpochStatus( + stagedCtx, s.T(), s.appName, 0, model.EpochStatus_ClaimStaged) + stagedCancel() + r.NoError(err, "wait for epoch 0 tournament result to be staged") + r.NotNil(stagedEpoch.StagedAtBlock, "staged PRT epoch must record the staging block") + + // Stop the node before entering the acceptance window. Otherwise, the PRT + // service can submit its own valid acceptance while this test assembles the + // controlled input/acceptance/input block. + stopSharedNode(s.T()) + nodeNeedsRestart := true + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + if err := anvilRPC(cleanupCtx, "evm_setAutomine", true); err != nil { + s.T().Errorf("restore Anvil automine: %v", err) + } + if err := anvilRPC(cleanupCtx, "evm_setIntervalMining", 1); err != nil { + s.T().Errorf("restore Anvil interval mining: %v", err) + } + if nodeNeedsRestart && sharedNode == nil { + startSharedNode(s.T()) + } + }() + + // Keep mining under our control for the acceptance window so neither the + // inputs nor our acceptance are mined before the complete batch is ready. + setAnvilAutomine(s.ctx, s.T(), false) + setAnvilIntervalMining(s.ctx, s.T(), 0) - root32 := [32]byte(root) - proof32 := make([][32]byte, len(proof)) - for i := range proof { - proof32[i] = [32]byte(proof[i]) + // Mine up to the block before the staging period boundary. The batch below + // creates the first block in which acceptance is valid. + currentBlock, err := s.client.BlockNumber(s.ctx) + r.NoError(err, "read block before PRT acceptance batch") + acceptanceBlock := *stagedEpoch.StagedAtBlock + claimStagingPeriod + r.Less(currentBlock, acceptanceBlock, + "epoch must remain staged before the claim staging period ends") + if currentBlock+1 < acceptanceBlock { + blocksToMine := acceptanceBlock - currentBlock - 1 + r.LessOrEqual(blocksToMine, claimStagingPeriod, + "blocks to mine must fit within the configured staging period") + r.NoError(anvilMine(s.ctx, int(blocksToMine)), //nolint:gosec // Bounded by claimStagingPeriod. + "mine to the block before the PRT staging boundary") } - // One block: pre-seal input (top gas → first), seal (mid gas), post-seal - // input (bottom gas → last). Distinct senders avoid same-account nonce - // coupling. We do not assert the seal receipt status — if the node's own - // settle wins the in-block ordering, ours reverts, but the boundary is the - // same because both settles execute between the two inputs. + // One block: pre-seal input (top gas), accept (mid gas), post-seal input + // (bottom gas → last). Distinct senders avoid same-account nonce + // coupling. The block before this one is still inside the staging window, so + // the node cannot submit an earlier valid acceptance. preTx, err := inputBox.AddInput(nextOpts(2, 1000), appAddr, []byte("before-seal")) //nolint:mnd r.NoError(err, "submit pre-seal input") - _, err = consensus.Settle(nextOpts(3, 500), big.NewInt(0), root32, proof32) //nolint:mnd - r.NoError(err, "submit settle for epoch 0") + acceptTx, err := consensus.AcceptStagedTournamentResult(nextOpts(3, 500), big.NewInt(0)) + r.NoError(err, "submit acceptance for epoch 0") postTx, err := inputBox.AddInput(nextOpts(4, 1), appAddr, []byte("after-seal")) r.NoError(err, "submit post-seal input") @@ -381,16 +421,37 @@ func (s *SameBlockInputsSuite) TestInputsBeforeAndAfterEpochSealedSameBlock() { // Both inputs must share the seal block, in order. receiptCtx, receiptCancel := context.WithTimeout(s.ctx, 30*time.Second) preReceipt := waitReceipt(receiptCtx, s.T(), s.client, preTx) + acceptReceipt := waitReceipt(receiptCtx, s.T(), s.client, acceptTx) postReceipt := waitReceipt(receiptCtx, s.T(), s.client, postTx) receiptCancel() r.Equal(uint64(1), preReceipt.Status, "pre-seal input transaction must succeed") + r.Equal(uint64(1), acceptReceipt.Status, "epoch acceptance transaction must succeed") r.Equal(uint64(1), postReceipt.Status, "post-seal input transaction must succeed") r.Equal(preReceipt.BlockNumber.Uint64(), postReceipt.BlockNumber.Uint64(), "both inputs must be mined in the same block as the seal") + r.Equal(preReceipt.BlockNumber.Uint64(), acceptReceipt.BlockNumber.Uint64(), + "acceptance transaction must be mined in the input batch") + r.Equal(acceptanceBlock, acceptReceipt.BlockNumber.Uint64(), + "acceptance must execute at the first valid staging block") + r.Less(preReceipt.TransactionIndex, acceptReceipt.TransactionIndex, + "pre-seal input must be ordered before acceptance") + r.Less(acceptReceipt.TransactionIndex, postReceipt.TransactionIndex, + "acceptance must be ordered before the post-seal input") r.Less(preReceipt.TransactionIndex, postReceipt.TransactionIndex, "pre-seal input must be ordered before post-seal input") sealBlock := preReceipt.BlockNumber.Uint64() + // Resume normal mining and restart the node. The reader must reconcile the + // externally accepted epoch. The PRT service must then recover its root bond. + setAnvilAutomine(s.ctx, s.T(), true) + setAnvilIntervalMining(s.ctx, s.T(), 1) + startSharedNode(s.T()) + nodeNeedsRestart = false + acceptedEpoch := waitForPrtEpochAcceptedAndBondRecovered( + s.ctx, s.T(), r, s.client, s.appName, 0, tournament.Address) + r.Equal(stagedEpoch.StagedAtBlock, acceptedEpoch.StagedAtBlock, + "acceptance must retain the previously observed staging block") + // The pre-seal input belongs to the just-sealed epoch 1; the post-seal input // belongs to the new open epoch 2. preCtx, preCancel := context.WithTimeout(s.ctx, inputProcessingTimeout) @@ -457,13 +518,9 @@ func (s *SameBlockInputsSuite) mineUntilTournamentReady(epochIndex uint64) *mode return tournament } -// readEpochSettlementData reads, from the node database, the outputs merkle root -// and proof the validator computed for the epoch (used to drive settle ourselves) -// and the application's DaveConsensus address. It waits until the root has been -// computed. -func (s *SameBlockInputsSuite) readEpochSettlementData( - epochIndex uint64, -) (root common.Hash, proof []common.Hash, consensusAddr common.Address) { +// readPrtConsensusAddress reads the deployed application's DaveConsensus +// address from the repository. +func (s *SameBlockInputsSuite) readPrtConsensusAddress() common.Address { r := s.Require() dsn, err := config.GetDatabaseConnection() r.NoError(err, "get database connection") @@ -474,24 +531,7 @@ func (s *SameBlockInputsSuite) readEpochSettlementData( app, err := repo.GetApplication(s.ctx, s.appName) r.NoError(err, "get application") r.NotNil(app, "application must exist") - consensusAddr = app.IConsensusAddress - - ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout) - defer cancel() - err = pollUntil(ctx, 2*time.Second, func() (bool, error) { - epoch, err := repo.GetEpoch(ctx, s.appName, epochIndex) - if err != nil { - return false, err - } - if epoch == nil || epoch.TxBufferDataBlock == nil { - return false, nil - } - root = *epoch.TxBufferDataBlock - proof = epoch.TxBufferProof - return true, nil - }) - r.NoError(err, "wait for epoch %d settlement data (outputs merkle root)", epochIndex) - return root, proof, consensusAddr + return app.IConsensusAddress } // deploySpambox deploys the Spambox helper contract and fails the test on any diff --git a/test/integration/snapshot_policy_test.go b/test/integration/snapshot_policy_test.go index 02ad3ed74..d061a6131 100644 --- a/test/integration/snapshot_policy_test.go +++ b/test/integration/snapshot_policy_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -38,12 +39,6 @@ var snapshotRestartExpectedLogs = []ExpectedLog{ Level: LevelError, Reason: "transient Anvil error during rapid block mining or post-restart catchup", }, - { - Pattern: regexp.MustCompile(`service=evm-reader.*context canceled`), - Level: LevelError, - Reason: "benign shutdown noise from restarting the node mid-tick; " + - "retryablehttp wraps the cancellation as `Post \"\": context canceled`", - }, } func TestSnapshotPolicy(t *testing.T) { @@ -159,9 +154,8 @@ func (s *SnapshotPolicySuite) runSnapshotPolicyTest(cfg snapshotPolicyConfig) { // === Send first input === s.T().Logf("Sending first input (snap-%s-1)...", policyStr) - idx1, _, err := sendInput( - s.ctx, s.appName, "snap-"+policyStr+"-1") - require.NoError(err, "send first input") + idx1, _, _ := sendInputThroughRelay( + s.ctx, s.T(), common.HexToAddress(addr), "snap-"+policyStr+"-1") s.T().Logf(" input sent (index=%d)", idx1) func() { @@ -311,8 +305,8 @@ func (s *SnapshotPolicySuite) TestSnapshotPolicyEveryInputPrt() { ctx context.Context, t testing.TB, require *require.Assertions, appName string, ) { - settleTournament(ctx, t, require, ethClient, appName, 0) - settleTournament(ctx, t, require, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 1) }, }) } @@ -337,8 +331,8 @@ func (s *SnapshotPolicySuite) TestSnapshotPolicyEveryEpochPrt() { ctx context.Context, t testing.TB, require *require.Assertions, appName string, ) { - settleTournament(ctx, t, require, ethClient, appName, 0) - settleTournament(ctx, t, require, ethClient, appName, 1) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 0) + finalizePrtEpoch(ctx, t, require, ethClient, appName, 1) }, }) } diff --git a/test/integration/terminal_machine_states_test.go b/test/integration/terminal_machine_states_test.go index 46fc4c21a..1d39c1b07 100644 --- a/test/integration/terminal_machine_states_test.go +++ b/test/integration/terminal_machine_states_test.go @@ -8,7 +8,6 @@ package integration import ( "context" "fmt" - "regexp" "testing" "time" @@ -43,12 +42,6 @@ const ( terminalObservationRPCTimeout = 2 * time.Second ) -var terminalMachineRestartExpectedLog = ExpectedLog{ - Pattern: regexp.MustCompile(`service=(?:claimer|evm-reader).*context canceled`), - Level: LevelError, - Reason: "benign service cancellation while deliberately restarting the node", -} - func TestTerminalMachineStates(t *testing.T) { if !isNodeSelfManaged() { t.Skip("skipping: durable terminal-state test requires a test-managed node restart") @@ -125,7 +118,7 @@ func (s *TerminalMachineStatesSuite) TestUnexpectedYieldSurvivesRestart() { func (s *TerminalMachineStatesSuite) runTerminalMachineState(tc terminalMachineStateCase) { s.T().Helper() - s.SetExpectedLogs(s.T(), terminalExecutionExpectedLog, terminalMachineRestartExpectedLog) + s.SetExpectedLogs(s.T(), terminalExecutionExpectedLog) require := s.Require() s.appName = uniqueAppName(tc.namePrefix) dappPath := envOrDefault(tc.dappPathEnv, tc.defaultDappPath) diff --git a/test/integration/testdata/InputRelay.sol b/test/integration/testdata/InputRelay.sol new file mode 100644 index 000000000..f71a38181 --- /dev/null +++ b/test/integration/testdata/InputRelay.sol @@ -0,0 +1,21 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +pragma solidity ^0.8.30; + +interface IInputBoxForInputRelay { + function addInput(address app, bytes calldata payload) external returns (bytes32); +} + +contract InputRelay { + IInputBoxForInputRelay public immutable inputBox; + + constructor(address inputBox_) { + inputBox = IInputBoxForInputRelay(inputBox_); + } + + function addInput(address app, bytes calldata payload) external returns (bytes32) { + return inputBox.addInput(app, payload); + } + + fallback() external payable {} +} diff --git a/test/integration/testdata/input_relay_abi.json b/test/integration/testdata/input_relay_abi.json new file mode 100644 index 000000000..77ab39754 --- /dev/null +++ b/test/integration/testdata/input_relay_abi.json @@ -0,0 +1,6 @@ +[ + {"type":"constructor","inputs":[{"name":"inputBox_","type":"address","internalType":"address"}],"stateMutability":"nonpayable"}, + {"type":"fallback","stateMutability":"payable"}, + {"type":"function","name":"addInput","inputs":[{"name":"app","type":"address","internalType":"address"},{"name":"payload","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"nonpayable"}, + {"type":"function","name":"inputBox","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IInputBoxForInputRelay"}],"stateMutability":"view"} +] diff --git a/test/integration/testdata/input_relay_bytecode.hex b/test/integration/testdata/input_relay_bytecode.hex new file mode 100644 index 000000000..749558f3d --- /dev/null +++ b/test/integration/testdata/input_relay_bytecode.hex @@ -0,0 +1 @@ +0x60a060405234801561000f575f5ffd5b5060405161055b38038061055b833981810160405281019061003191906100c9565b8073ffffffffffffffffffffffffffffffffffffffff1660808173ffffffffffffffffffffffffffffffffffffffff1681525050506100f4565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6100988261006f565b9050919050565b6100a88161008e565b81146100b2575f5ffd5b50565b5f815190506100c38161009f565b92915050565b5f602082840312156100de576100dd61006b565b5b5f6100eb848285016100b5565b91505092915050565b6080516104496101125f395f81816098015261013e01526104495ff3fe60806040526004361061002c575f3560e01c80631789cd631461002f57806344e906111461006b5761002d565b5b005b34801561003a575f5ffd5b5061005560048036038101906100509190610223565b610095565b6040516100629190610298565b60405180910390f35b348015610076575f5ffd5b5061007f61013c565b60405161008c919061030c565b60405180910390f35b5f7f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff16631789cd638585856040518463ffffffff1660e01b81526004016100f39392919061038e565b6020604051808303815f875af115801561010f573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061013391906103e8565b90509392505050565b7f000000000000000000000000000000000000000000000000000000000000000081565b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61019182610168565b9050919050565b6101a181610187565b81146101ab575f5ffd5b50565b5f813590506101bc81610198565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f8401126101e3576101e26101c2565b5b8235905067ffffffffffffffff811115610200576101ff6101c6565b5b60208301915083600182028301111561021c5761021b6101ca565b5b9250929050565b5f5f5f6040848603121561023a57610239610160565b5b5f610247868287016101ae565b935050602084013567ffffffffffffffff81111561026857610267610164565b5b610274868287016101ce565b92509250509250925092565b5f819050919050565b61029281610280565b82525050565b5f6020820190506102ab5f830184610289565b92915050565b5f819050919050565b5f6102d46102cf6102ca84610168565b6102b1565b610168565b9050919050565b5f6102e5826102ba565b9050919050565b5f6102f6826102db565b9050919050565b610306816102ec565b82525050565b5f60208201905061031f5f8301846102fd565b92915050565b61032e81610187565b82525050565b5f82825260208201905092915050565b828183375f83830152505050565b5f601f19601f8301169050919050565b5f61036d8385610334565b935061037a838584610344565b61038383610352565b840190509392505050565b5f6040820190506103a15f830186610325565b81810360208301526103b4818486610362565b9050949350505050565b6103c781610280565b81146103d1575f5ffd5b50565b5f815190506103e2816103be565b92915050565b5f602082840312156103fd576103fc610160565b5b5f61040a848285016103d4565b9150509291505056fea2646970667358221220356222b2245360ca49c941c70259d3fddc283ef304fdf2ad9a238df74b79742664736f6c634300081e0033 diff --git a/test/integration/withdrawal_lifecycle_test.go b/test/integration/withdrawal_lifecycle_test.go index 93dddbfba..e07f9eb00 100644 --- a/test/integration/withdrawal_lifecycle_test.go +++ b/test/integration/withdrawal_lifecycle_test.go @@ -46,9 +46,9 @@ const ( withdrawalPreForecloseAmount uint64 = 25 withdrawalPostForecloseAmount uint64 = withdrawalDepositAmount - withdrawalPreForecloseAmount - defaultDevnetERC20PortalAddress = "0x22E57511C30CcE6CDaa742E13CE3b774fDC663b1" - defaultDevnetTestERC20Address = "0x88A2120B7068E78692C8fd12E751d610B6377E4d" - defaultDevnetWithdrawalOutputBuilderAddress = "0x0745787835A019cd4dae8EDB541Fbc0647793d63" + defaultDevnetERC20PortalAddress = "0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419" + defaultDevnetTestERC20Address = "0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" + defaultDevnetWithdrawalOutputBuilderAddress = "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" accountsDriveLog2MaxNumOfAccounts = uint8(17) accountsDriveLog2LeavesPerAccount = uint8(0) @@ -241,7 +241,7 @@ func (s *WithdrawalLifecycleSuite) deployWithdrawalApp( "deploy", "quorum", "--json", "--salt", uniqueSalt(), - "--claim-staging-period", strconv.FormatUint(quorumClaimStagingPeriod, 10), + claimStagingPeriodFlag, strconv.FormatUint(quorumClaimStagingPeriod, 10), } for _, validator := range validators { quorumArgs = append(quorumArgs, "--validator", validator.Hex()) @@ -314,7 +314,7 @@ func (s *WithdrawalLifecycleSuite) finalizeWithdrawalEpoch( return finalEpoch case withdrawalConsensusPRT: for i := uint64(0); i <= targetEpochIndex; i++ { - settleTournament(s.ctx, s.T(), r, s.client, deployment.appName, i) + finalizePrtEpoch(s.ctx, s.T(), r, s.client, deployment.appName, i) } claimCtx, claimCancel := context.WithTimeout(s.ctx, claimAcceptedTimeout) epoch, err := waitForEpochStatus(claimCtx, s.T(), deployment.appName, targetEpochIndex, model.EpochStatus_ClaimAccepted) @@ -351,8 +351,8 @@ func (s *WithdrawalLifecycleSuite) finalizeQuorumEpoch( case model.EpochStatus_ClaimStaged: return s.waitForQuorumAccepted(deployment.appName, epochIndex) case model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted: - s.submitQuorumClaim(deployment, epoch, quorumValidatorIndexA, *epoch.TxBufferDataBlock) - s.submitQuorumClaim(deployment, epoch, quorumValidatorIndexB, *epoch.TxBufferDataBlock) + s.submitQuorumClaim(deployment, epoch, quorumValidatorIndexA) + s.submitQuorumClaim(deployment, epoch, quorumValidatorIndexB) return s.waitForQuorumAccepted(deployment.appName, epochIndex) default: s.Require().FailNowf("unexpected quorum epoch status", @@ -377,7 +377,7 @@ func (s *WithdrawalLifecycleSuite) waitForQuorumEpochWithClaim(appName string, e } return false, fmt.Errorf("poll epoch %d claim: %w", epochIndex, err) } - if epoch.TxBufferDataBlock != nil && epoch.MachineHash != nil && isQuorumClaimReadyStatus(epoch.Status) { + if epoch.HasCompleteStateProof() && isQuorumClaimReadyStatus(epoch.Status) { result = epoch return true, nil } @@ -395,11 +395,10 @@ func (s *WithdrawalLifecycleSuite) submitQuorumClaim( deployment withdrawalAppDeployment, epoch *model.Epoch, accountIndex uint32, - outputsMerkleRoot [32]byte, ) { r := s.Require() - r.NotNil(epoch.TxBufferDataBlock, "epoch %d missing outputs merkle root", epoch.Index) r.NotNil(deployment.quorum, "quorum binding is required") + machineRoot, proof := quorumMachineValidityProof(s.T(), epoch) key, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, accountIndex) r.NoError(err, "derive validator key %d", accountIndex) @@ -411,8 +410,8 @@ func (s *WithdrawalLifecycleSuite) submitQuorumClaim( opts, deployment.appAddress, new(big.Int).SetUint64(epoch.LastBlock), - outputsMerkleRoot, - merkleProofToBytes32(epoch.TxBufferProof), + machineRoot, + proof, ) r.NoError(err, "validator %d submit quorum claim", accountIndex) receipt, err := bind.WaitMined(s.ctx, s.client, tx) diff --git a/test/validator/validator_test.go b/test/validator/validator_test.go index 81359f322..83a3a4ca3 100644 --- a/test/validator/validator_test.go +++ b/test/validator/validator_test.go @@ -106,7 +106,6 @@ func (s *ValidatorRepositoryIntegrationSuite) TestItReturnsPristineClaim() { IConsensusAddress: common.BytesToAddress([]byte("beadbeef")), TemplateHash: common.BytesToHash([]byte("template")), TemplateURI: "/template/path", - DataAvailability: model.DataAvailability_InputBox[:], EpochLength: 10, Enabled: true, Status: model.ApplicationStatus_OK, @@ -173,7 +172,6 @@ func (s *ValidatorRepositoryIntegrationSuite) TestItReturnsPreviousClaim() { IConsensusAddress: common.BytesToAddress([]byte("beadbeef")), TemplateHash: common.BytesToHash([]byte("template")), TemplateURI: "/template/path", - DataAvailability: model.DataAvailability_InputBox[:], EpochLength: 10, Enabled: true, Status: model.ApplicationStatus_OK, @@ -281,7 +279,6 @@ func (s *ValidatorRepositoryIntegrationSuite) TestItReturnsANewClaimAndProofs() IConsensusAddress: common.BytesToAddress([]byte("beadbeef")), TemplateHash: common.BytesToHash([]byte("template")), TemplateURI: "/template/path", - DataAvailability: model.DataAvailability_InputBox[:], EpochLength: 10, Enabled: true, Status: model.ApplicationStatus_OK, @@ -369,7 +366,6 @@ func (s *ValidatorRepositoryIntegrationSuite) TestItReturnsANewClaimAndProofs() IConsensusAddress: common.BytesToAddress([]byte("beadbeef")), TemplateHash: common.BytesToHash([]byte("template")), TemplateURI: "/template/path", - DataAvailability: model.DataAvailability_InputBox[:], EpochLength: 10, Enabled: true, Status: model.ApplicationStatus_OK,