diff --git a/cmd/cartesi-rollups-cli/root/deploy/application.go b/cmd/cartesi-rollups-cli/root/deploy/application.go index db243bd9e..991a3b538 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/application.go +++ b/cmd/cartesi-rollups-cli/root/deploy/application.go @@ -13,7 +13,6 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository/factory" "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" @@ -146,7 +145,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { chainId, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainId) cobra.CheckErr(err) // pre deployment checks @@ -238,7 +237,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { if verboseParam || !asJSONParam { fmt.Fprint(os.Stderr, "deploying...") } - _, result, err := deployment.Deploy(ctx, client, txOpts) + _, result, err := deployment.Deploy(ctx, client, ethutil.NewStaticTransactOptsFactory(txOpts)) // 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. diff --git a/cmd/cartesi-rollups-cli/root/deploy/authority.go b/cmd/cartesi-rollups-cli/root/deploy/authority.go index 061eca3e3..9955cb93b 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/authority.go +++ b/cmd/cartesi-rollups-cli/root/deploy/authority.go @@ -10,7 +10,6 @@ import ( "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -73,7 +72,7 @@ func runDeployAuthority(cmd *cobra.Command, args []string) { chainId, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainId) cobra.CheckErr(err) deployment, err := buildAuthorityDeployment(cmd, txOpts) diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum.go b/cmd/cartesi-rollups-cli/root/deploy/quorum.go index c9ca798e5..698125248 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/quorum.go +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum.go @@ -10,7 +10,6 @@ import ( "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/common" @@ -73,7 +72,7 @@ func runDeployQuorum(cmd *cobra.Command, args []string) { chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainID) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) deployment, err := buildQuorumDeployment(cmd) diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit.go b/cmd/cartesi-rollups-cli/root/deposit/deposit.go index b7303bb76..cbaac1c31 100644 --- a/cmd/cartesi-rollups-cli/root/deposit/deposit.go +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit.go @@ -13,7 +13,6 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/ierc20errors" "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" @@ -114,7 +113,7 @@ func runERC20(cmd *cobra.Command, args []string) { cobra.CheckErr(err) chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainID) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) if !skipConfirmation { @@ -138,7 +137,7 @@ func runERC20(cmd *cobra.Command, args []string) { if approveParam { token, err := ierc20metadata.NewIERC20Metadata(tokenAddr, client) cobra.CheckErr(err) - approveOpts, err := auth.GetTransactOpts(ctx, chainID) + approveOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) tx, err := token.Approve(approveOpts, portalAddr, amount) cobra.CheckErr(cli.DecorateRevert(err, @@ -154,7 +153,7 @@ func runERC20(cmd *cobra.Command, args []string) { portal, err := ierc20portal.NewIERC20Portal(portalAddr, client) cobra.CheckErr(err) - depositOpts, err := auth.GetTransactOpts(ctx, chainID) + depositOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) tx, err := portal.DepositERC20Tokens(depositOpts, tokenAddr, appAddr, amount, execData) // The revert can come from three layers: the portal itself diff --git a/cmd/cartesi-rollups-cli/root/execute/execute.go b/cmd/cartesi-rollups-cli/root/execute/execute.go index 38c403750..451abe678 100644 --- a/cmd/cartesi-rollups-cli/root/execute/execute.go +++ b/cmd/cartesi-rollups-cli/root/execute/execute.go @@ -13,7 +13,6 @@ import ( "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/internal/repository/factory" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -99,7 +98,7 @@ func run(cmd *cobra.Command, args []string) { chainId, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainId) cobra.CheckErr(err) if !skipConfirmation { @@ -117,7 +116,7 @@ func run(cmd *cobra.Command, args []string) { txHash, err := ethutil.ExecuteOutput( ctx, client, - txOpts, + ethutil.NewStaticTransactOptsFactory(txOpts), app.IApplicationAddress, outputIndex, output.RawData, diff --git a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go index 800d2dfbb..2917e4e8b 100644 --- a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go +++ b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go @@ -16,7 +16,6 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" ) @@ -90,7 +89,7 @@ func run(cmd *cobra.Command, args []string) { chainId, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainId) cobra.CheckErr(err) appContract, err := iapplication.NewIApplication(appAddr, client) diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go index 46482fc53..3a2714e5d 100644 --- a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go @@ -17,7 +17,6 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" ) @@ -102,7 +101,7 @@ func run(cmd *cobra.Command, args []string) { chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainID) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) appContract, err := iapplication.NewIApplication(appAddr, client) diff --git a/cmd/cartesi-rollups-cli/root/root.go b/cmd/cartesi-rollups-cli/root/root.go index cd2a2289d..58ec16a07 100644 --- a/cmd/cartesi-rollups-cli/root/root.go +++ b/cmd/cartesi-rollups-cli/root/root.go @@ -34,6 +34,7 @@ var ( verbose bool databaseConnection string blockchainEndpoint string + gasLimit uint64 inputBoxAddress string ) @@ -56,6 +57,12 @@ func init() { cobra.CheckErr(viper.BindPFlag(config.BLOCKCHAIN_HTTP_ENDPOINT, Cmd.PersistentFlags().Lookup("blockchain-http-endpoint"))) cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("blockchain-http-endpoint")) + // Blockchain gas limit + Cmd.PersistentFlags().Uint64Var(&gasLimit, "gas-limit", 0, + "Blockchain gas limit") + cobra.CheckErr(viper.BindPFlag(config.BLOCKCHAIN_GAS_LIMIT, Cmd.PersistentFlags().Lookup("gas-limit"))) + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("gas-limit")) + // Input box address flag Cmd.PersistentFlags().StringVar(&inputBoxAddress, "inputbox", "", "Input Box contract address") diff --git a/cmd/cartesi-rollups-cli/root/send/send.go b/cmd/cartesi-rollups-cli/root/send/send.go index 615d19677..c96af0395 100644 --- a/cmd/cartesi-rollups-cli/root/send/send.go +++ b/cmd/cartesi-rollups-cli/root/send/send.go @@ -12,7 +12,6 @@ import ( "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/internal/repository/factory" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" @@ -143,9 +142,11 @@ func run(cmd *cobra.Command, args []string) { chainId, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainId) cobra.CheckErr(err) + txOptsFactory := ethutil.NewStaticTransactOptsFactory(txOpts) + // Ask for confirmation unless --yes flag is set if !skipConfirmation { fmt.Printf("Preparing to send input to application %v (%v) with account %v\n", @@ -159,7 +160,7 @@ func run(cmd *cobra.Command, args []string) { } if asyncMode { - txHash, err := ethutil.AddInputAsync(ctx, client, txOpts, iboxAddr, app.IApplicationAddress, payload) + 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{ @@ -175,7 +176,7 @@ func run(cmd *cobra.Command, args []string) { return } - inputIndex, blockNumber, txHash, err := ethutil.AddInput(ctx, client, txOpts, iboxAddr, app.IApplicationAddress, payload) + inputIndex, blockNumber, txHash, err := ethutil.AddInput(ctx, client, txOptsFactory, iboxAddr, app.IApplicationAddress, payload) cobra.CheckErr(cli.DecorateRevert(err, iinputbox.IInputBoxMetaData, iapplication.IApplicationMetaData)) if asJSONParam { diff --git a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go index 6f9dc75ad..4ce1b6085 100644 --- a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go +++ b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go @@ -19,7 +19,6 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" - "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/ethutil" ) @@ -109,7 +108,7 @@ func run(cmd *cobra.Command, args []string) { chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := auth.GetTransactOpts(ctx, chainID) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) appContract, err := iapplication.NewIApplication(appAddr, client) diff --git a/internal/claimer/accept.go b/internal/claimer/accept.go index 9499a1278..780ec0f25 100644 --- a/internal/claimer/accept.go +++ b/internal/claimer/accept.go @@ -378,7 +378,9 @@ func (s *Service) broadcastAcceptClaimOrReconcileRevert( return claimRetryLater(err) } - txHash, err := s.blockchain.acceptClaimOnBlockchain(app, currEpoch) + txCtx, cancel := context.WithTimeout(s.Context, s.submissionTimeout) + defer cancel() + txHash, err := s.blockchain.acceptClaimOnBlockchain(txCtx, app, currEpoch) if err != nil { outcome, stateErr := s.handleAcceptClaimRevert(err, app, currEpoch) switch outcome { diff --git a/internal/claimer/accept_test.go b/internal/claimer/accept_test.go index 7084c3630..c4d7bc12c 100644 --- a/internal/claimer/accept_test.go +++ b/internal/claimer/accept_test.go @@ -4,10 +4,12 @@ package claimer import ( + "context" "fmt" "math/big" "strings" "testing" + "time" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" @@ -20,7 +22,7 @@ import ( ) func TestAcceptFirstClaim(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -40,7 +42,7 @@ func TestAcceptFirstClaim(t *testing.T) { } func TestAcceptClaimWithAntecessor(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -67,7 +69,7 @@ func TestAcceptClaimWithAntecessor(t *testing.T) { // Failure func TestFindClaimAcceptedEventAndSuccFailure0(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -89,7 +91,7 @@ func TestFindClaimAcceptedEventAndSuccFailure0(t *testing.T) { } func TestFindClaimAcceptedEventAndSuccFailure1(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -113,7 +115,7 @@ func TestFindClaimAcceptedEventAndSuccFailure1(t *testing.T) { // !claimAcceptedMatch(prevClaim, prevEvent) func TestAcceptClaimWithAntecessorMismatch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -144,7 +146,7 @@ func TestAcceptClaimWithAntecessorMismatch(t *testing.T) { // !claimAcceptedMatch(currClaim, currEvent) func TestAcceptClaimWithEventMismatch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -169,7 +171,7 @@ func TestAcceptClaimWithEventMismatch(t *testing.T) { // !checkClaimsConstraint(prevClaim, currClaim) func TestAcceptClaimWithAntecessorOutOfOrder(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -188,7 +190,7 @@ func TestAcceptClaimWithAntecessorOutOfOrder(t *testing.T) { } func TestErrAcceptedMissingEvent(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -211,7 +213,7 @@ func TestErrAcceptedMissingEvent(t *testing.T) { } func TestUpdateEpochWithAcceptedClaimFailed(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -236,7 +238,7 @@ func TestUpdateEpochWithAcceptedClaimFailed(t *testing.T) { } func TestConsensusAddressChangedOnAcceptedClaims(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -258,7 +260,7 @@ func TestConsensusAddressChangedOnAcceptedClaims(t *testing.T) { } func TestAcceptStagedFrontRunner(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -282,7 +284,7 @@ func TestAcceptStagedFrontRunner(t *testing.T) { } func TestAcceptStagedBroadcastsWhenClaimStillStaged(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -297,7 +299,7 @@ func TestAcceptStagedBroadcastsWhenClaimStillStaged(t *testing.T) { Return(app.IConsensusAddress, nil).Once() b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() - b.On("acceptClaimOnBlockchain", app, currEpoch). + b.On("acceptClaimOnBlockchain", mock.Anything, app, currEpoch). Return(txHash, nil).Once() transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -312,7 +314,7 @@ func TestAcceptStagedBroadcastsWhenClaimStillStaged(t *testing.T) { } func TestAcceptStagedFrontRunnerOutputsMismatchSetsDiverged(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -342,7 +344,7 @@ func TestAcceptStagedFrontRunnerOutputsMismatchSetsDiverged(t *testing.T) { // enum is 0/1/2) must escalate the app to FAILED rather than skip silently // every tick. No acceptClaim is broadcast. func TestAcceptStagedUnmodeledClaimStatusFailsClosed(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -367,7 +369,7 @@ func TestAcceptStagedUnmodeledClaimStatusFailsClosed(t *testing.T) { } func TestAcceptStagedForeclosesForeclosedApp(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -406,7 +408,7 @@ func TestAcceptStagedForeclosesForeclosedApp(t *testing.T) { // guard fires before any health-status write, so the drain is not stuck behind // a re-enable loop. func TestAcceptStagedForeclosesForeclosedAppOnUnstaged(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -443,7 +445,7 @@ func TestAcceptStagedForeclosesForeclosedAppOnUnstaged(t *testing.T) { // to call acceptClaim, the next entry into the per-epoch budget exhausts it // and the app is marked FAILED without another broadcast. func TestAcceptStagedCapEnforced(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -474,7 +476,7 @@ func TestAcceptStagedCapEnforced(t *testing.T) { } func TestAcceptStagedUnknownBroadcastErrorsIncrementAttemptsUntilCap(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -491,7 +493,7 @@ func TestAcceptStagedUnknownBroadcastErrorsIncrementAttemptsUntilCap(t *testing. Return(app.IConsensusAddress, nil).Once() b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() - b.On("acceptClaimOnBlockchain", app, currEpoch). + b.On("acceptClaimOnBlockchain", mock.Anything, app, currEpoch). Return(common.Hash{}, broadcastErr).Once() transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -522,7 +524,7 @@ func TestAcceptStagedUnknownBroadcastErrorsIncrementAttemptsUntilCap(t *testing. } func TestAcceptClaimNotStagedAcceptedRechecksOutputsMismatch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -538,7 +540,7 @@ func TestAcceptClaimNotStagedAcceptedRechecksOutputsMismatch(t *testing.T) { Return(app.IConsensusAddress, nil).Once() b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() - b.On("acceptClaimOnBlockchain", app, currEpoch). + b.On("acceptClaimOnBlockchain", mock.Anything, app, currEpoch). Return(common.Hash{}, claimNotStagedError(claimStatusAccepted)).Once() b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). Return(mismatch, nil).Once() @@ -553,7 +555,7 @@ func TestAcceptClaimNotStagedAcceptedRechecksOutputsMismatch(t *testing.T) { // TestAcceptStagedPeriodNotElapsed — current block too low; no tx issued. func TestAcceptStagedPeriodNotElapsed(t *testing.T) { - m, _, b := newServiceMock() + m, _, b := newServiceMock(t) defer b.AssertExpectations(t) app := makeApplication() @@ -575,7 +577,7 @@ func TestAcceptStagedPeriodNotElapsed(t *testing.T) { // is ever issued even when the period has elapsed. Caller waits for // someone else to call acceptClaim (observed via the ClaimAccepted scan). func TestAcceptStagedReaderMode(t *testing.T) { - m, _, b := newServiceMock() + m, _, b := newServiceMock(t) defer b.AssertExpectations(t) m.submissionEnabled = false @@ -599,7 +601,7 @@ func TestAcceptStagedReaderMode(t *testing.T) { // app without rewriting the epoch to CLAIM_REJECTED. Under Quorum this is an // invariant violation, not the normal outvoted path. func TestAcceptanceDivergence_QuorumStagedDoesNotRejectEpoch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -632,7 +634,7 @@ func TestAcceptanceDivergence_QuorumStagedDoesNotRejectEpoch(t *testing.T) { } func TestAcceptanceDivergence_QuorumComputedRejectsEpoch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -664,7 +666,7 @@ func TestAcceptanceDivergence_QuorumComputedRejectsEpoch(t *testing.T) { } func TestAcceptanceDivergence_AuthorityComputedSetsDivergedWithoutRejectingEpoch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -695,7 +697,7 @@ func TestAcceptanceDivergence_AuthorityComputedSetsDivergedWithoutRejectingEpoch } func TestAcceptanceDivergence_AuthorityDoesNotRejectEpoch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -730,7 +732,7 @@ func TestAcceptanceDivergence_AuthorityDoesNotRejectEpoch(t *testing.T) { // stage's broadcast path is unconditionally skipped, so we don't even need func TestAcceptanceDivergenceReaderMode_Quorum(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) m.submissionEnabled = false @@ -764,3 +766,89 @@ func TestAcceptanceDivergenceReaderMode_Quorum(t *testing.T) { // TestHandleAcceptClaimRevert — exhaustive dispatch matrix for the typed // reverts handleAcceptClaimRevert recognises. The classifier never mutates + +func TestAcceptClaimTimeout(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + m.submissionTimeout = 100 * time.Millisecond + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + attemptKey := acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index} + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + b.On("acceptClaimOnBlockchain", mock.Anything, app, currEpoch). + Run(func(args mock.Arguments) { + ctx := args.Get(0).(context.Context) + select { + case <-ctx.Done(): + assert.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + case <-time.After(2 * time.Second): + assert.Fail(t, "context provided did not have the expected timeout") + } + }). + Return(common.Hash{}, context.DeadlineExceeded).Once() + + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.Equal(t, 0, transitions) + require.Equal(t, 1, len(errs)) + assert.ErrorIs(t, errs[0], context.DeadlineExceeded) + assert.Equal(t, uint64(1), m.acceptAttempts[attemptKey]) + assert.Equal(t, 0, len(m.acceptsInFlight)) +} + +func TestAcceptClaimContextCanceled(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + svcCtx, cancel := context.WithCancel(t.Context()) + defer cancel() + m.Context = svcCtx + m.submissionTimeout = 2 * time.Second + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + attemptKey := acceptAttemptKey{currEpoch.ApplicationID, currEpoch.Index} + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + b.On("acceptClaimOnBlockchain", mock.Anything, app, currEpoch). + Run(func(args mock.Arguments) { + ctx := args.Get(0).(context.Context) + select { + case <-ctx.Done(): + assert.ErrorIs(t, ctx.Err(), context.Canceled) + case <-time.After(2 * time.Second): + assert.Fail(t, "context provided was not canceled") + } + }). + Return(common.Hash{}, context.Canceled).Once() + + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + transitions, errs := m.acceptStagedClaimsAndIssueAcceptTx(makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.Equal(t, 0, transitions) + require.Equal(t, 1, len(errs)) + assert.ErrorIs(t, errs[0], context.Canceled) + assert.Equal(t, uint64(1), m.acceptAttempts[attemptKey]) + assert.Equal(t, 0, len(m.acceptsInFlight)) +} diff --git a/internal/claimer/blockchain.go b/internal/claimer/blockchain.go index a87beb59f..29c59b19b 100644 --- a/internal/claimer/blockchain.go +++ b/internal/claimer/blockchain.go @@ -52,12 +52,14 @@ type iclaimerBlockchain interface { ) submitClaimToBlockchain( + ctx context.Context, ic *iconsensus.IConsensus, application *model.Application, epoch *model.Epoch, ) (common.Hash, error) acceptClaimOnBlockchain( + ctx context.Context, application *model.Application, epoch *model.Epoch, ) (common.Hash, error) @@ -100,27 +102,28 @@ type iclaimerBlockchain interface { } type claimerBlockchain struct { - client *ethclient.Client - txOpts *bind.TransactOpts - logger *slog.Logger - defaultBlock config.DefaultBlock + client *ethclient.Client + txOptsFactory ethutil.TransactOptsFactory + logger *slog.Logger + defaultBlock config.DefaultBlock } func (cb *claimerBlockchain) claimSubmitterAddress() (common.Address, bool) { - if cb.txOpts == nil { + if cb.txOptsFactory == nil { return common.Address{}, false } - return cb.txOpts.From, true + return cb.txOptsFactory.From(), true } func (cb *claimerBlockchain) submitClaimToBlockchain( + ctx context.Context, ic *iconsensus.IConsensus, application *model.Application, epoch *model.Epoch, ) (common.Hash, error) { txHash := common.Hash{} - if cb.txOpts == nil { - return txHash, fmt.Errorf("txOpts is required for claim submission") + if cb.txOptsFactory == nil { + return txHash, fmt.Errorf("txOptsFactory is required for claim submission") } if epoch.OutputsMerkleRoot == nil { return txHash, fmt.Errorf( @@ -140,8 +143,12 @@ func (cb *claimerBlockchain) submitClaimToBlockchain( for i, h := range epoch.OutputsMerkleProof { 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(cb.txOpts, application.IApplicationAddress, + tx, err := ic.SubmitClaim(txOpts, application.IApplicationAddress, lastBlockNumber, *epoch.OutputsMerkleRoot, proof) if err != nil { cb.logger.Warn("submitClaimToBlockchain:failed", @@ -406,11 +413,12 @@ func (cb *claimerBlockchain) getConsensusAddress( // ClaimStagingPeriodNotOverYet if the math is off; the caller handles that // revert via handleAcceptClaimRevert. func (cb *claimerBlockchain) acceptClaimOnBlockchain( + ctx context.Context, application *model.Application, epoch *model.Epoch, ) (common.Hash, error) { txHash := common.Hash{} - if cb.txOpts == nil { + if cb.txOptsFactory == nil { return txHash, fmt.Errorf("txOpts is required for claim acceptance") } if epoch.MachineHash == nil { @@ -422,8 +430,12 @@ func (cb *claimerBlockchain) acceptClaimOnBlockchain( if err != nil { return txHash, fmt.Errorf("creating IConsensus binding for acceptClaim: %w", err) } + txOpts, err := cb.txOptsFactory.NewTransactOpts(ctx) + if err != nil { + return txHash, fmt.Errorf("creating transaction options for claim acceptance: %w", err) + } lastBlockNumber := new(big.Int).SetUint64(epoch.LastBlock) - tx, err := ic.AcceptClaim(cb.txOpts, application.IApplicationAddress, + tx, err := ic.AcceptClaim(txOpts, application.IApplicationAddress, lastBlockNumber, *epoch.MachineHash) if err != nil { cb.logger.Warn("acceptClaimOnBlockchain:failed", diff --git a/internal/claimer/claim_status_test.go b/internal/claimer/claim_status_test.go index d9d79c364..a3be45c9d 100644 --- a/internal/claimer/claim_status_test.go +++ b/internal/claimer/claim_status_test.go @@ -10,7 +10,7 @@ import ( ) func TestUpdateEpochStagedFromClaimStatus_NilStagingBlock_ReturnsError(t *testing.T) { - m, r, _ := newServiceMock() + m, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := makeApplication() diff --git a/internal/claimer/claimer_test.go b/internal/claimer/claimer_test.go index 4835832a1..3b7f72a8a 100644 --- a/internal/claimer/claimer_test.go +++ b/internal/claimer/claimer_test.go @@ -20,7 +20,7 @@ import ( ) func TestDoNothing(t *testing.T) { - m, r, _ := newServiceMock() + m, r, _ := newServiceMock(t) defer r.AssertExpectations(t) prevEpochs := makeEpochMap() @@ -32,7 +32,7 @@ func TestDoNothing(t *testing.T) { } func TestTickInterleavesStagesWithPinnedBlockAndReschedulesOnProgress(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) diff --git a/internal/claimer/divergence_test.go b/internal/claimer/divergence_test.go index fd26390aa..3a4890358 100644 --- a/internal/claimer/divergence_test.go +++ b/internal/claimer/divergence_test.go @@ -16,7 +16,7 @@ import ( ) func TestVerifyClaimOutputsMismatch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) diff --git a/internal/claimer/fixtures_test.go b/internal/claimer/fixtures_test.go index 0e5d814a9..9b102991f 100644 --- a/internal/claimer/fixtures_test.go +++ b/internal/claimer/fixtures_test.go @@ -52,7 +52,7 @@ func newTestEthClient(t *testing.T, chainID uint64) *ethclient.Client { return client } -func newServiceMock() (*Service, *claimerRepositoryMock, *claimerBlockchainMock) { +func newServiceMock(t *testing.T) (*Service, *claimerRepositoryMock, *claimerBlockchainMock) { opts := &tint.Options{ Level: slog.LevelDebug, AddSource: true, @@ -68,7 +68,8 @@ func newServiceMock() (*Service, *claimerRepositoryMock, *claimerBlockchainMock) claimer := &Service{ Service: service.Service{ - Logger: slog.New(handler), + Context: t.Context(), + Logger: slog.New(handler), }, submissionEnabled: true, claimsInFlight: map[int64]inFlightTx{}, diff --git a/internal/claimer/foreclosed_apps_test.go b/internal/claimer/foreclosed_apps_test.go index 0a88324bd..9e0e099d1 100644 --- a/internal/claimer/foreclosed_apps_test.go +++ b/internal/claimer/foreclosed_apps_test.go @@ -42,7 +42,7 @@ func foreclosedAppHelper(id int64, block uint64, consensus model.Consensus) *mod // their own post-foreclosure path, so the claimer asks the repository for only // Authority and Quorum apps. func TestListEnabledForeclosedNonPRTApps_UsesAuthorityQuorumFilter(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) auth := foreclosedAppHelper(1, 100, model.Consensus_Authority) @@ -83,7 +83,7 @@ func TestListEnabledForeclosedNonPRTApps_UsesAuthorityQuorumFilter(t *testing.T) // every tick. The protection lives in the selecting filter, which restricts // Statuses to OK, so terminal apps are never handed to processForeclosedApps. func TestListEnabledForeclosedNonPRTApps_ExcludesTerminalStatuses(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) r.On("ListApplications", @@ -111,7 +111,7 @@ func TestListEnabledForeclosedNonPRTApps_ExcludesTerminalStatuses(t *testing.T) // dispute; firing before claim reconciliation completes would leave the local // DB final state divergent from the chain. func TestProcessForeclosedApps_DefersWhenUnreconciled(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := foreclosedAppHelper(1, 100, model.Consensus_Authority) @@ -141,7 +141,7 @@ func TestProcessForeclosedApps_DefersWhenUnreconciled(t *testing.T) { // failing the same gate, both errors are surfaced (proving the loop did not // stop after the first). func TestProcessForeclosedApps_DrainCheckErrorsAppendAndContinue(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) s.Context = context.Background() @@ -173,7 +173,7 @@ func TestProcessForeclosedApps_DrainCheckErrorsAppendAndContinue(t *testing.T) { // testify/mock fails the test on an unexpected call, so any regression that // re-introduces a terminal-state transition trips this test loudly. func TestProcessForeclosedApps_NoTransitionWhenDrained(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := foreclosedAppHelper(1, 100, model.Consensus_Authority) @@ -205,7 +205,7 @@ func TestProcessForeclosedApps_NoTransitionWhenDrained(t *testing.T) { // ForecloseUnacceptedEpochsAtOrAfterBlock expectation is the regression guard: // testify/mock fails on the unexpected call if terminalization runs too early. func TestProcessForeclosedApps_DefersWhenInputsUndrained(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := foreclosedAppHelper(1, 100, model.Consensus_Authority) @@ -227,7 +227,7 @@ func TestProcessForeclosedApps_DefersWhenInputsUndrained(t *testing.T) { // straddling/after epochs that can never be accepted are terminalized to // CLAIM_FORECLOSED, then reconciliation completes. func TestProcessForeclosedApps_TerminalizesUnacceptedOverlapAfterDrain(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := foreclosedAppHelper(1, 100, model.Consensus_Authority) @@ -257,7 +257,7 @@ func TestProcessForeclosedApps_TerminalizesUnacceptedOverlapAfterDrain(t *testin // could feed an app with a zero ForecloseBlock here; the loop must skip it // silently rather than treat block 0 as a real foreclosure marker. func TestProcessForeclosedApps_SkipsZeroForecloseBlock(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := &model.Application{ID: 99, ConsensusType: model.Consensus_Authority} @@ -281,7 +281,7 @@ func TestProcessForeclosedApps_SkipsZeroForecloseBlock(t *testing.T) { // UpdateApplicationStatus has an `.On` registered; testify/mock panics on an // unexpected call, so any reach attempt fails the test loudly. func TestProcessForeclosedApps_DefersWhenStillBackfilling(t *testing.T) { - s, r, _ := newServiceMock() + s, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := foreclosedAppHelper(1, 100, model.Consensus_Authority) diff --git a/internal/claimer/inflight_test.go b/internal/claimer/inflight_test.go index cd46cb886..42f1bfaf5 100644 --- a/internal/claimer/inflight_test.go +++ b/internal/claimer/inflight_test.go @@ -22,7 +22,7 @@ import ( ) func TestInFlightCompleted(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -64,7 +64,7 @@ func TestInFlightCompleted(t *testing.T) { // caller falls back to UpdateEpochWithSubmittedClaim. Epoch transitions // COMPUTED → SUBMITTED (not STAGED). func TestInFlightCompleted_QuorumNonDeciding(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -101,7 +101,7 @@ func TestInFlightCompleted_QuorumNonDeciding(t *testing.T) { } func TestInFlightReverted(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -129,7 +129,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.HexToHash("0x10"), nil).Once() _, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -138,7 +138,7 @@ func TestInFlightReverted(t *testing.T) { } func TestClaimInFlightMissingFromCurrClaims(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -157,7 +157,7 @@ func TestClaimInFlightMissingFromCurrClaims(t *testing.T) { } func TestClaimInFlightPollErrorKeepsTrackingAndStopsDuplicateSubmit(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -185,7 +185,7 @@ func TestClaimInFlightPollErrorKeepsTrackingAndStopsDuplicateSubmit(t *testing.T } func TestClaimInFlightPollErrorsDoNotStopOtherApps(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -220,7 +220,7 @@ func TestClaimInFlightPollErrorsDoNotStopOtherApps(t *testing.T) { } func TestClaimInFlightReceiptNotFoundBeforeTimeoutKeepsTrackingAndStopsDuplicateSubmit(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -248,7 +248,7 @@ func TestClaimInFlightReceiptNotFoundBeforeTimeoutKeepsTrackingAndStopsDuplicate } func TestClaimInFlightReceiptNotFoundAfterTimeoutClearsAndRetries(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(newTxHash, nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -285,7 +285,7 @@ func TestClaimInFlightReceiptNotFoundAfterTimeoutClearsAndRetries(t *testing.T) } func TestAcceptInFlightPollErrorKeepsTracking(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -310,7 +310,7 @@ func TestAcceptInFlightPollErrorKeepsTracking(t *testing.T) { } func TestAcceptInFlightErrorsDoNotStopOtherAppsOrDropPollErrors(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -352,7 +352,7 @@ func TestAcceptInFlightErrorsDoNotStopOtherAppsOrDropPollErrors(t *testing.T) { } func TestAcceptInFlightReceiptNotFoundAfterTimeoutClearsTracking(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -379,7 +379,7 @@ func TestAcceptInFlightReceiptNotFoundAfterTimeoutClearsTracking(t *testing.T) { } func TestAcceptInFlightSuccessUpdatesEpochAndClearsTracking(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -412,7 +412,7 @@ func TestAcceptInFlightSuccessUpdatesEpochAndClearsTracking(t *testing.T) { } func TestAcceptInFlightRevertedAcceptedReconcilesEpoch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -447,7 +447,7 @@ func TestAcceptInFlightRevertedAcceptedReconcilesEpoch(t *testing.T) { } func TestAcceptInFlightRevertedUnstagedMarksApplicationFailed(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -486,7 +486,7 @@ func TestAcceptInFlightRevertedUnstagedMarksApplicationFailed(t *testing.T) { // symmetric C1 guard on the in-flight path (inflight.go:311), mirroring the // pre-accept guard on the accept path. func TestAcceptInFlightRevertedForeclosedTerminalizes(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -519,7 +519,7 @@ func TestAcceptInFlightRevertedForeclosedTerminalizes(t *testing.T) { } func TestCleanupOrphanedInFlight(t *testing.T) { - m, _, _ := newServiceMock() + m, _, _ := newServiceMock(t) liveApp := makeApplication() // ID = 0 stagedApp := repotest.NewApplicationBuilder(). diff --git a/internal/claimer/mocks_test.go b/internal/claimer/mocks_test.go index 576ff79b6..965258284 100644 --- a/internal/claimer/mocks_test.go +++ b/internal/claimer/mocks_test.go @@ -297,20 +297,22 @@ func (m *claimerBlockchainMock) findClaimAcceptedEventAndSucc( } func (m *claimerBlockchainMock) submitClaimToBlockchain( + ctx context.Context, instance *iconsensus.IConsensus, app *model.Application, epoch *model.Epoch, ) (common.Hash, error) { - args := m.Called(instance, app, epoch) - return args.Get(0).(common.Hash), args.Error(1) + rets := m.Called(ctx, instance, app, epoch) + return rets.Get(0).(common.Hash), rets.Error(1) } func (m *claimerBlockchainMock) acceptClaimOnBlockchain( + ctx context.Context, app *model.Application, epoch *model.Epoch, ) (common.Hash, error) { - args := m.Called(app, epoch) - return args.Get(0).(common.Hash), args.Error(1) + rets := m.Called(ctx, app, epoch) + return rets.Get(0).(common.Hash), rets.Error(1) } func (m *claimerBlockchainMock) findClaimStagedEventAndSucc( diff --git a/internal/claimer/reverts_test.go b/internal/claimer/reverts_test.go index 058986d49..c88ae17ce 100644 --- a/internal/claimer/reverts_test.go +++ b/internal/claimer/reverts_test.go @@ -82,7 +82,7 @@ func TestDecodeClaimNotStagedStatus(t *testing.T) { // ////////////////////////////////////////////////////////////////////////////// func TestNotFirstClaimHandledGracefully(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -92,7 +92,7 @@ func TestNotFirstClaimHandledGracefully(t *testing.T) { expectPreSubmitPath(b, app, currEpoch, endBlock) // submitClaim reverts with NotFirstClaim (caught by eth_estimateGas). - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.Hash{}, notFirstClaimError()).Once() _, errs := m.submitClaimsAndUpdateDatabase( @@ -107,7 +107,7 @@ func TestNotFirstClaimHandledGracefully(t *testing.T) { // Quorum raises NotFirstClaim for any prior validator vote in the epoch, // including a duplicate vote for the same machine root. func TestNotFirstClaimQuorumRetriesForEventSync(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -117,7 +117,7 @@ func TestNotFirstClaimQuorumRetriesForEventSync(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.Hash{}, notFirstClaimError()).Once() _, errs := m.submitClaimsAndUpdateDatabase( @@ -132,7 +132,7 @@ func TestNotFirstClaimQuorumRetriesForEventSync(t *testing.T) { // next tick can retry while the EVM reader records foreclosure and future // claim broadcasts are skipped. func TestApplicationForeclosedIsTransient(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -141,7 +141,7 @@ func TestApplicationForeclosedIsTransient(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.Hash{}, consensusRevertError("ApplicationForeclosed")).Once() currEpochs := makeEpochMap(currEpoch) @@ -157,7 +157,7 @@ func TestApplicationForeclosedIsTransient(t *testing.T) { // proof-size revert is treated as local data corruption — the app moves // to CORRUPTED. func TestInvalidOutputsMerkleRootProofSizeSetsCorrupted(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -166,7 +166,7 @@ func TestInvalidOutputsMerkleRootProofSizeSetsCorrupted(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). + 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() @@ -183,7 +183,7 @@ func TestInvalidOutputsMerkleRootProofSizeSetsCorrupted(t *testing.T) { // failure is treated as a recoverable operator-config error: FAILED, not a // terminal DIVERGED/CORRUPTED status. func TestCallerIsNotValidatorSetsFailed(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -193,7 +193,7 @@ func TestCallerIsNotValidatorSetsFailed(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.Hash{}, consensusRevertError("CallerIsNotValidator")).Once() r.On("UpdateApplicationStatus", mock.Anything, int64(0), model.ApplicationStatus_Failed, mock.Anything). Return(nil).Once() @@ -212,7 +212,7 @@ func TestCallerIsNotValidatorSetsFailed(t *testing.T) { // a block newer/different from the claimer's pinned reads, so the epoch stays // in the work map for the next tick and no status changes. func TestNotPastBlockRetriesLater(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -223,7 +223,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.Hash{}, notPastBlockError(currEpoch.LastBlock, currEpoch.LastBlock-1)).Once() currEpochs := makeEpochMap(currEpoch) @@ -271,7 +271,7 @@ func TestSubmitClaimRevertsSetApplicationFailed(t *testing.T) { } for _, tc := range cases { t.Run(tc.revertName, func(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -280,7 +280,7 @@ func TestSubmitClaimRevertsSetApplicationFailed(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.Hash{}, tc.err).Once() r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.MatchedBy(func(reason *string) bool { @@ -320,7 +320,7 @@ func TestSubmitClaimRevertsSetApplicationFailed(t *testing.T) { // when the FAILED status write itself fails: the handler still returns // AppHalted, so the epoch is dropped and the DB error surfaces. func TestSubmitClaimFailedRevertWithDBError(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -329,7 +329,7 @@ func TestSubmitClaimFailedRevertWithDBError(t *testing.T) { currEpoch := makeComputedEpoch(app, 3) expectPreSubmitPath(b, app, currEpoch, endBlock) - b.On("submitClaimToBlockchain", mock.Anything, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.Hash{}, consensusRevertError("ApplicationReverted")).Once() r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). Return(fmt.Errorf("db down")).Once() @@ -410,7 +410,7 @@ func TestHandleAcceptClaimRevert(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - m, _, _ := newServiceMock() + m, _, _ := newServiceMock(t) app := makeApplication() epoch := makeStagedEpoch(app, 3, 50) outcome, stateErr := m.handleAcceptClaimRevert(tc.err, app, epoch) @@ -451,7 +451,7 @@ func TestDecodeNotPastBlockBounds(t *testing.T) { // do not model" (a contract newer than this node) from "decode failed" (a // transient malformed response), which still retries. func TestClaimNotStagedUnmodeledStatusFailsClosed(t *testing.T) { - m, r, _ := newServiceMock() + m, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := makeApplication() epoch := makeStagedEpoch(app, 3, 50) @@ -479,7 +479,7 @@ func TestAcceptClaimRevertsSetApplicationFailed(t *testing.T) { "NotEpochFinalBlock", } { t.Run(revertName, func(t *testing.T) { - m, r, _ := newServiceMock() + m, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := makeApplication() epoch := makeStagedEpoch(app, 3, 50) @@ -518,7 +518,7 @@ func TestAcceptClaimRevertsSetApplicationFailed(t *testing.T) { // 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() + m, r, _ := newServiceMock(t) defer r.AssertExpectations(t) app := makeApplication() epoch := makeComputedEpoch(app, 3) @@ -579,7 +579,7 @@ func TestHandleSubmitClaimRevert(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - m, _, _ := newServiceMock() + m, _, _ := newServiceMock(t) app := makeApplication() // Authority is the default; NotFirstClaim returns // AlreadyOnChain for it. Quorum-specific routing is diff --git a/internal/claimer/service.go b/internal/claimer/service.go index 8495f4b15..1a1399ed3 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -8,14 +8,15 @@ import ( "errors" "fmt" "log/slog" + "time" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" "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/accounts/abi/bind" "github.com/ethereum/go-ethereum/ethclient" ) @@ -61,6 +62,7 @@ type Service struct { consensusAddressChecks map[consensusAddressCheckKey]error submissionEnabled bool + submissionTimeout time.Duration } // defaultMaxAcceptAttempts is used only when config is not supplied, mainly in @@ -126,9 +128,13 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { s.maxAcceptAttempts = defaultMaxAcceptAttempts } - var txOpts *bind.TransactOpts = nil + var txOptsFactory ethutil.TransactOptsFactory if s.submissionEnabled { - txOpts, err = auth.GetTransactOpts(ctx, chainId) + s.submissionTimeout = c.Config.BlockchainHttpRequestTimeout + if s.submissionTimeout == 0 { + return nil, fmt.Errorf("BlockchainHttpRequestTimeout must be different from zero") + } + txOptsFactory, err = auth.GetTransactOptsFactory(ctx, chainId) if err != nil { return nil, fmt.Errorf("getting transaction options: %w", err) } @@ -136,10 +142,10 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { s.repository = c.Repository s.blockchain = &claimerBlockchain{ - logger: s.Logger, - client: c.EthConn, - txOpts: txOpts, - defaultBlock: nodeConfig.DefaultBlock, + logger: s.Logger, + client: c.EthConn, + txOptsFactory: txOptsFactory, + defaultBlock: nodeConfig.DefaultBlock, } return s, nil diff --git a/internal/claimer/stage_test.go b/internal/claimer/stage_test.go index e5c8f64db..c5ce626a4 100644 --- a/internal/claimer/stage_test.go +++ b/internal/claimer/stage_test.go @@ -21,7 +21,7 @@ import ( ) func TestStagingFastPathDivergence(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -68,7 +68,7 @@ func TestStagingFastPathDivergence(t *testing.T) { // tracking + computedEpochs entry intact so the next tick polls the // receipt again and retries the atomic write. func TestStagingFastPathDBPending(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -115,7 +115,7 @@ func TestStagingFastPathDBPending(t *testing.T) { // buildClaimStagedLog builds a types.Log for a ClaimStaged event with func TestStageByObservation(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -137,7 +137,7 @@ func TestStageByObservation(t *testing.T) { } func TestStageForeclosesSubmittedForeclosedApp(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -162,7 +162,7 @@ func TestStageForeclosesSubmittedForeclosedApp(t *testing.T) { // with a machineMerkleRoot != ours → CLAIM_REJECTED and DIVERGED with // quorum_divergence_at_staging. func TestStagingDivergence_Quorum(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -196,7 +196,7 @@ func TestStagingDivergence_Quorum(t *testing.T) { } func TestStagingDivergence_AuthorityDoesNotRejectEpoch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -225,7 +225,7 @@ func TestStagingDivergence_AuthorityDoesNotRejectEpoch(t *testing.T) { } func TestStagingMatcherPreconditionFailureMarksApplicationCorrupted(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -253,7 +253,7 @@ func TestStagingMatcherPreconditionFailureMarksApplicationCorrupted(t *testing.T // returns ACCEPTED (status=2) before our acceptClaim → reconcile to func TestStagingDivergenceReaderMode_Quorum(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) m.submissionEnabled = false diff --git a/internal/claimer/submit.go b/internal/claimer/submit.go index c2c2c490f..335b286b3 100644 --- a/internal/claimer/submit.go +++ b/internal/claimer/submit.go @@ -425,7 +425,9 @@ func (s *Service) broadcastComputedClaim( "outputs_merkle_root", hashToHex(currEpoch.OutputsMerkleRoot), "last_block", currEpoch.LastBlock, ) - txHash, err := s.blockchain.submitClaimToBlockchain(ic, app, currEpoch) + txCtx, cancel := context.WithTimeout(s.Context, s.submissionTimeout) + defer cancel() + txHash, err := s.blockchain.submitClaimToBlockchain(txCtx, ic, app, currEpoch) if err != nil { switch outcome, stateErr := s.handleSubmitClaimRevert(err, app, currEpoch); outcome { case submitClaimAlreadyOnChain: diff --git a/internal/claimer/submit_test.go b/internal/claimer/submit_test.go index 951edd865..37cc84613 100644 --- a/internal/claimer/submit_test.go +++ b/internal/claimer/submit_test.go @@ -4,8 +4,10 @@ package claimer import ( + "context" "math/big" "testing" + "time" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" @@ -18,7 +20,7 @@ import ( ) func TestSubmitFirstClaim(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -34,7 +36,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.HexToHash("0x10"), nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -48,7 +50,7 @@ func TestSubmitFirstClaim(t *testing.T) { // checkForForeclosure has run on a foreclosed application. func TestSubmitClaimForeclosesUnstagedForeclosedApp(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -80,7 +82,7 @@ func TestSubmitClaimForeclosesUnstagedForeclosedApp(t *testing.T) { } func TestSubmitClaimForeclosesUnstagedForeclosedAppWhenSubmissionDisabled(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -125,7 +127,7 @@ func TestSubmitClaimForeclosesUnstagedForeclosedAppWhenSubmissionDisabled(t *tes // SKIPPED so we don't burn gas on a guaranteed ApplicationForeclosed // revert. func TestSubmitClaimForecloseMidFlight(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -143,7 +145,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, app, epochN). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, epochN). Return(tick1TxHash, nil).Once() transitions1, errs1 := m.submitClaimsAndUpdateDatabase( @@ -193,7 +195,7 @@ func TestSubmitClaimForecloseMidFlight(t *testing.T) { // application would leave its last successful epoch stuck at // CLAIM_COMPUTED — diverging from chain reality. func TestSubmitClaimReconcilesAcceptedForForeclosedApp(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -224,7 +226,7 @@ func TestSubmitClaimReconcilesAcceptedForForeclosedApp(t *testing.T) { } func TestSubmitClaimReconcilesStagedBeforeBroadcast(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -256,7 +258,7 @@ func TestSubmitClaimReconcilesStagedBeforeBroadcast(t *testing.T) { } func TestReconcileBeforeSubmitAcceptedOutputsMismatchSetsDiverged(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -285,7 +287,7 @@ func TestReconcileBeforeSubmitAcceptedOutputsMismatchSetsDiverged(t *testing.T) } func TestSubmitClaimWithAntecessor(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -302,7 +304,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.HexToHash("0x10"), nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -312,7 +314,7 @@ func TestSubmitClaimWithAntecessor(t *testing.T) { } func TestSubmitClaimWithAcceptedAntecessorWithoutClaimTransactionHash(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -330,7 +332,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(common.HexToHash("0x10"), nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase( @@ -341,7 +343,7 @@ func TestSubmitClaimWithAcceptedAntecessorWithoutClaimTransactionHash(t *testing } func TestSkipSubmitClaimWithStagedAntecessor(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -366,7 +368,7 @@ func TestSkipSubmitClaimWithStagedAntecessor(t *testing.T) { } func TestSkipSubmitFirstClaim(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -390,7 +392,7 @@ func TestSkipSubmitFirstClaim(t *testing.T) { } func TestSkipSubmitClaimWithAntecessor(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -414,7 +416,7 @@ func TestSkipSubmitClaimWithAntecessor(t *testing.T) { } func TestUpdateFirstClaim(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -439,7 +441,7 @@ func TestUpdateFirstClaim(t *testing.T) { } func TestUpdateClaimWithAntecessor(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -464,7 +466,7 @@ func TestUpdateClaimWithAntecessor(t *testing.T) { } func TestQuorumSubmittedEventsIgnoresForeignDifferentOutputsAndUpdatesMatchingEvent(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -496,7 +498,7 @@ func TestQuorumSubmittedEventsIgnoresForeignDifferentOutputsAndUpdatesMatchingEv } func TestQuorumDifferentOutputSubmittedEventStillSubmitsLocalClaim(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -518,7 +520,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(txHash, nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -528,7 +530,7 @@ func TestQuorumDifferentOutputSubmittedEventStillSubmitsLocalClaim(t *testing.T) } func TestQuorumForeignMatchingSubmittedEventStillSubmitsLocalClaim(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -546,7 +548,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(txHash, nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -556,7 +558,7 @@ func TestQuorumForeignMatchingSubmittedEventStillSubmitsLocalClaim(t *testing.T) } func TestQuorumReaderModeRecordsForeignMatchingSubmittedEvent(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) m.submissionEnabled = false @@ -583,7 +585,7 @@ func TestQuorumReaderModeRecordsForeignMatchingSubmittedEvent(t *testing.T) { } func TestQuorumSubmittedEventsIgnoresForeignAdversarialProofAndSubmitsLocalClaim(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -613,7 +615,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(txHash, nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -623,7 +625,7 @@ func TestQuorumSubmittedEventsIgnoresForeignAdversarialProofAndSubmitsLocalClaim } func TestQuorumSubmittedEventsOwnMismatchSetsDiverged(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -656,7 +658,7 @@ func TestQuorumSubmittedEventsOwnMismatchSetsDiverged(t *testing.T) { } func TestQuorumReaderModeIgnoresNonMatchingSubmittedEvent(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) m.submissionEnabled = false @@ -687,7 +689,7 @@ func TestQuorumReaderModeIgnoresNonMatchingSubmittedEvent(t *testing.T) { } func TestSubmitClaimWithAntecessorMismatch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -722,7 +724,7 @@ func TestSubmitClaimWithAntecessorMismatch(t *testing.T) { // !claimMatchesEvent(currClaim, currEvent) func TestSubmitClaimWithEventMismatch(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -751,7 +753,7 @@ func TestSubmitClaimWithEventMismatch(t *testing.T) { } func TestQuorumPreviousSubmittedEventsIgnoresForeignMismatchAndSubmitsCurrentClaim(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -777,7 +779,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, app, currEpoch). + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). Return(txHash, nil).Once() transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(prevEpoch), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) @@ -787,7 +789,7 @@ func TestQuorumPreviousSubmittedEventsIgnoresForeignMismatchAndSubmitsCurrentCla } func TestQuorumPreviousSubmittedEventsOwnMismatchSetsDiverged(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -822,7 +824,7 @@ func TestQuorumPreviousSubmittedEventsOwnMismatchSetsDiverged(t *testing.T) { // !checkClaimsConstraint(prevClaim, currClaim) // epoch pair has its blocks out of order func TestSubmitClaimWithAntecessorOutOfOrder(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -850,7 +852,7 @@ func TestCheckEpochSequenceConstraintAllowsAcceptedPredecessorWithoutClaimTransa } func TestErrSubmittedMissingEvent(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -874,7 +876,7 @@ func TestErrSubmittedMissingEvent(t *testing.T) { } func TestConsensusAddressChangedOnSubmittedClaims(t *testing.T) { - m, r, b := newServiceMock() + m, r, b := newServiceMock(t) defer r.AssertExpectations(t) defer b.AssertExpectations(t) @@ -895,7 +897,7 @@ func TestConsensusAddressChangedOnSubmittedClaims(t *testing.T) { } func TestCheckConsensusForAddressChangeUsesTickBlock(t *testing.T) { - m, _, b := newServiceMock() + m, _, b := newServiceMock(t) defer b.AssertExpectations(t) app := makeApplication() @@ -912,7 +914,7 @@ func TestCheckConsensusForAddressChangeUsesTickBlock(t *testing.T) { } func TestCheckConsensusForAddressChangeCachesTickResult(t *testing.T) { - m, _, b := newServiceMock() + m, _, b := newServiceMock(t) defer b.AssertExpectations(t) app := makeApplication() @@ -929,4 +931,86 @@ func TestCheckConsensusForAddressChangeCachesTickResult(t *testing.T) { require.NoError(t, err) } -//////////////////////////////////////////////////////////////////////////////// +func TestSubmitClaimTimeout(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + m.submissionTimeout = 100 * time.Millisecond + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var prevEvent *iconsensus.IConsensusClaimSubmitted = nil + var currEvent *iconsensus.IConsensusClaimSubmitted = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + Run(func(args mock.Arguments) { + ctx := args.Get(0).(context.Context) + select { + case <-ctx.Done(): + assert.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + case <-time.After(2 * time.Second): + assert.Fail(t, "context provided did not have the expected timeout") + } + }). + Return(common.Hash{}, context.DeadlineExceeded).Once() + + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.ErrorIs(t, errs[0], context.DeadlineExceeded) + assert.Equal(t, 0, len(m.claimsInFlight)) + assert.Equal(t, 0, transitions, "submitting a claim counts as a transition") +} + +func TestSubmitClaimContextCanceled(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + svcCtx, cancel := context.WithCancel(t.Context()) + defer cancel() + m.Context = svcCtx + m.submissionTimeout = 2 * time.Second + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var prevEvent *iconsensus.IConsensusClaimSubmitted = nil + var currEvent *iconsensus.IConsensusClaimSubmitted = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + b.On("submitClaimToBlockchain", mock.Anything, mock.Anything, app, currEpoch). + Run(func(args mock.Arguments) { + ctx := args.Get(0).(context.Context) + select { + case <-ctx.Done(): + assert.ErrorIs(t, ctx.Err(), context.Canceled) + case <-time.After(2 * time.Second): + assert.Fail(t, "context provided was not canceled") + } + }). + Return(common.Hash{}, context.Canceled).Once() + + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + transitions, errs := m.submitClaimsAndUpdateDatabase(makeEpochMap(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 1, len(errs)) + assert.ErrorIs(t, errs[0], context.Canceled) + assert.Equal(t, 0, len(m.claimsInFlight)) + assert.Equal(t, 0, transitions, "submitting a claim counts as a transition") +} diff --git a/internal/cli/ethereum.go b/internal/cli/ethereum.go new file mode 100644 index 000000000..e44018c09 --- /dev/null +++ b/internal/cli/ethereum.go @@ -0,0 +1,36 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "errors" + "math/big" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" + "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) + if err != nil { + return nil, err + } + + txOpts, err := factory.NewTransactOpts(ctx) + if err != nil { + return nil, err + } + + gasLimit, err := config.GetBlockchainGasLimit() + if err != nil && !errors.Is(err, config.ErrNotDefined) { + return nil, err + } + + if gasLimit > 0 { + txOpts.GasLimit = gasLimit + } + return txOpts, nil +} diff --git a/internal/cli/ethereum_test.go b/internal/cli/ethereum_test.go new file mode 100644 index 000000000..e3350382a --- /dev/null +++ b/internal/cli/ethereum_test.go @@ -0,0 +1,44 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +const testPrivateKey = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + +func TestGetTransactOptsBlockchainGasLimit(t *testing.T) { + setupPrivateKeyAuth := func(t *testing.T) { + t.Helper() + viper.Reset() + config.SetDefaults() + viper.Set(config.AUTH_KIND, "private_key") + viper.Set(config.AUTH_PRIVATE_KEY, testPrivateKey) + } + + t.Run("default zero leaves gas limit unset", func(t *testing.T) { + setupPrivateKeyAuth(t) + + txOpts, err := GetTransactOpts(context.Background(), big.NewInt(31337)) + require.NoError(t, err) + require.Zero(t, txOpts.GasLimit) + }) + + t.Run("non-zero config sets gas limit", func(t *testing.T) { + setupPrivateKeyAuth(t) + viper.Set(config.BLOCKCHAIN_GAS_LIMIT, "123456") + + txOpts, err := GetTransactOpts(context.Background(), big.NewInt(31337)) + require.NoError(t, err) + require.Equal(t, uint64(123456), txOpts.GasLimit) + }) +} diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index ad19824f1..408f7b13a 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -21,7 +21,7 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" ) -func GetTransactOpts(ctx context.Context, chainId *big.Int) (*bind.TransactOpts, error) { +func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.TransactOptsFactory, error) { authKind, err := GetAuthKind() if err != nil { return nil, err @@ -40,7 +40,11 @@ func GetTransactOpts(ctx context.Context, chainId *big.Int) (*bind.TransactOpts, if err != nil { return nil, err } - return 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() if err != nil { @@ -50,7 +54,11 @@ func GetTransactOpts(ctx context.Context, chainId *big.Int) (*bind.TransactOpts, if err != nil { return nil, err } - return bind.NewKeyedTransactorWithChainID(key, chainId) + txOpts, err := bind.NewKeyedTransactorWithChainID(key, chainId) + if err != nil { + return nil, err + } + return ethutil.NewStaticTransactOptsFactory(txOpts), nil case AuthKindAWS: awsc, err := aws_cfg.LoadDefaultConfig(ctx) if err != nil { @@ -61,7 +69,7 @@ func GetTransactOpts(ctx context.Context, chainId *big.Int) (*bind.TransactOpts, if err != nil { return nil, err } - return signtx.CreateAWSTransactOpts( + return signtx.CreateAWSTransactOptsFactory( ctx, kmsConfig, aws.String(authAwsKmsKeyId.Value), diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index b94b9d8f6..c5d298a78 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -254,6 +254,13 @@ description = """ Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited.""" used-by = ["evmreader", "claimer", "node", "prt"] +[rollups.CARTESI_BLOCKCHAIN_GAS_LIMIT] +default = "0" +go-type = "uint64" +description = """ +Gas limit to be used on chain transactions. Zero for the estimated limit provided by the chain. Default is zero.""" +used-by = ["cli"] + # # Contracts # diff --git a/internal/config/generated.go b/internal/config/generated.go index 9576ec7a8..cab15bdc8 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -71,6 +71,7 @@ const ( JSONRPC_MACHINE_LOG_LEVEL = "CARTESI_JSONRPC_MACHINE_LOG_LEVEL" ADVANCER_INPUT_BATCH_SIZE = "CARTESI_ADVANCER_INPUT_BATCH_SIZE" ADVANCER_POLLING_INTERVAL = "CARTESI_ADVANCER_POLLING_INTERVAL" + BLOCKCHAIN_GAS_LIMIT = "CARTESI_BLOCKCHAIN_GAS_LIMIT" BLOCKCHAIN_HTTP_MAX_RETRIES = "CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES" BLOCKCHAIN_HTTP_REQUEST_TIMEOUT = "CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT" BLOCKCHAIN_HTTP_RETRY_MAX_WAIT = "CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT" @@ -197,6 +198,8 @@ func SetDefaults() { viper.SetDefault(ADVANCER_POLLING_INTERVAL, "3") + viper.SetDefault(BLOCKCHAIN_GAS_LIMIT, "0") + viper.SetDefault(BLOCKCHAIN_HTTP_MAX_RETRIES, "4") viper.SetDefault(BLOCKCHAIN_HTTP_REQUEST_TIMEOUT, "120") @@ -2360,6 +2363,19 @@ func GetAdvancerPollingInterval() (Duration, error) { return notDefinedDuration(), fmt.Errorf("%s: %w", ADVANCER_POLLING_INTERVAL, ErrNotDefined) } +// GetBlockchainGasLimit returns the value for the environment variable CARTESI_BLOCKCHAIN_GAS_LIMIT. +func GetBlockchainGasLimit() (uint64, error) { + s := viper.GetString(BLOCKCHAIN_GAS_LIMIT) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_GAS_LIMIT, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", BLOCKCHAIN_GAS_LIMIT, ErrNotDefined) +} + // GetBlockchainHttpMaxRetries returns the value for the environment variable CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES. func GetBlockchainHttpMaxRetries() (uint64, error) { s := viper.GetString(BLOCKCHAIN_HTTP_MAX_RETRIES) diff --git a/internal/kms/signtx.go b/internal/kms/signtx.go index 4d2ad2fc5..26446dae6 100644 --- a/internal/kms/signtx.go +++ b/internal/kms/signtx.go @@ -27,7 +27,12 @@ import ( /* This is the signature for a function that takes a transaction, passes it * through a signer to obtain a message digest, creates a signature and embeds * it into the transaction itself. */ -type SignTxFn = func(tx *types.Transaction, s types.Signer) (*types.Transaction, error) +type SignTxFn = func(ctx context.Context, tx *types.Transaction, s types.Signer) (*types.Transaction, error) + +type Client interface { + GetPublicKey(context.Context, *kms.GetPublicKeyInput, ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error) + Sign(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error) +} /* AWS sometimes reply with a `r` larger than 32bytes padded on the left with * zeros. Trim it down to a total of 32bytes */ @@ -90,7 +95,7 @@ func assembleSignature(r []byte, s []byte, hash []byte, key []byte) ([]byte, err * the signing key. */ func CreateAWSSignTxFn( ctx context.Context, - client *kms.Client, + client Client, arn *string, ) (SignTxFn, *ecdsa.PublicKey, common.Address, error) { publicKeyBytes, err := GetPublicKeyBytes(ctx, client, arn) @@ -101,7 +106,7 @@ func CreateAWSSignTxFn( if err != nil { return nil, nil, common.Address{}, err } - return func(tx *types.Transaction, signer types.Signer) (*types.Transaction, error) { + return func(ctx context.Context, tx *types.Transaction, signer types.Signer) (*types.Transaction, error) { hash := signer.Hash(tx).Bytes() signOutput, err := client.Sign(ctx, &kms.SignInput{ KeyId: arn, @@ -138,7 +143,7 @@ func CreateAWSSignTxFn( }, publicKey, crypto.PubkeyToAddress(*publicKey), nil } -func GetPublicKeyBytes(ctx context.Context, client *kms.Client, Arn *string) ([]byte, error) { +func GetPublicKeyBytes(ctx context.Context, client Client, Arn *string) ([]byte, error) { publicKeyOutput, err := client.GetPublicKey(ctx, &kms.GetPublicKeyInput{ KeyId: Arn, }) @@ -167,29 +172,44 @@ func GetPublicKeyBytes(ctx context.Context, client *kms.Client, Arn *string) ([] return asn1key.SubjectPublicKey.Bytes, nil } -/* Wrap a KMS SignTx into a geth TransactOpts. - * - * similar to NewKeyedTransactorWithChainID */ -func CreateAWSTransactOpts( +func CreateAWSTransactOptsFactory( ctx context.Context, - client *kms.Client, + client Client, arn *string, signer types.Signer, -) (*bind.TransactOpts, error) { - SignTxFn, _, keyAddress, err := CreateAWSSignTxFn(ctx, client, arn) +) (*AWSTransactOptsFactory, error) { + signTxFn, _, keyAddress, err := CreateAWSSignTxFn(ctx, client, arn) if err != nil { return nil, err } + return &AWSTransactOptsFactory{ + signTxFn: signTxFn, + address: keyAddress, + signer: signer, + }, nil +} + +type AWSTransactOptsFactory struct { + signTxFn SignTxFn + address common.Address + signer types.Signer +} + +func (f *AWSTransactOptsFactory) From() common.Address { + return f.address +} + +func (f *AWSTransactOptsFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { return &bind.TransactOpts{ - From: keyAddress, + From: f.address, Signer: func( address common.Address, tx *types.Transaction, ) (*types.Transaction, error) { - if address != keyAddress { + if address != f.address { return nil, bind.ErrNotAuthorized } - return SignTxFn(tx, signer) + return f.signTxFn(ctx, tx, f.signer) }, Context: ctx, }, nil diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index 9509d06d9..a4d7d422d 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -6,26 +6,30 @@ package kms import ( "context" "crypto/ecdsa" + "crypto/rand" + "encoding/asn1" "math/big" "testing" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" awscfg "github.com/aws/aws-sdk-go-v2/config" awskms "github.com/aws/aws-sdk-go-v2/service/kms" + kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/stretchr/testify/require" ) var ARN = "" /* Create a SignTxFn from a private key. Useful for testing */ func CreateSignTxFnFromPrivateKey(privateKey *ecdsa.PrivateKey) SignTxFn { - return func(tx *types.Transaction, s types.Signer) (*types.Transaction, error) { - return types.SignTx(tx, s, privateKey) + return func(_ context.Context, tx *ethtypes.Transaction, s ethtypes.Signer) (*ethtypes.Transaction, error) { + return ethtypes.SignTx(tx, s, privateKey) } } @@ -51,12 +55,12 @@ func sendFunds( panic(err) } var data []byte - tx := types.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + tx := ethtypes.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) chainID, err := client.NetworkID(context.Background()) if err != nil { panic(err) } - signedTx, err := SignTx(tx, types.NewEIP155Signer(chainID)) + signedTx, err := SignTx(ctx, tx, ethtypes.NewEIP155Signer(chainID)) if err != nil { panic(err) } @@ -95,3 +99,88 @@ func TestSignTx(t *testing.T) { sendFunds(value10, SignTx, context.Background(), KMSAddress, anvilAddress) } + +func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + client := newFakeKMSClient(t, privateKey) + arn := "alias/test-key" + startupCtx, cancelStartup := context.WithCancel(context.Background()) + factory, err := CreateAWSTransactOptsFactory( + startupCtx, + client, + &arn, + ethtypes.NewEIP155Signer(big.NewInt(1)), + ) + require.NoError(t, err) + cancelStartup() + + type contextKey string + submitCtx := context.WithValue(context.Background(), contextKey("phase"), "submit") + opts, err := factory.NewTransactOpts(submitCtx) + require.NoError(t, err) + + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + _, err = opts.Signer(opts.From, tx) + require.NoError(t, err) + require.Equal(t, "submit", client.signContext.Value(contextKey("phase"))) + require.NoError(t, client.signContext.Err()) +} + +type fakeKMSClient struct { + t *testing.T + privateKey *ecdsa.PrivateKey + publicKey []byte + signContext context.Context +} + +func newFakeKMSClient(t *testing.T, privateKey *ecdsa.PrivateKey) *fakeKMSClient { + t.Helper() + publicKey, err := asn1.Marshal(struct { + Algorithm struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier + } + SubjectPublicKey asn1.BitString + }{ + Algorithm: struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier + }{ + Algorithm: asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1}, + Parameters: asn1.ObjectIdentifier{1, 3, 132, 0, 10}, + }, + SubjectPublicKey: asn1.BitString{Bytes: crypto.FromECDSAPub(&privateKey.PublicKey)}, + }) + require.NoError(t, err) + return &fakeKMSClient{t: t, privateKey: privateKey, publicKey: publicKey} +} + +func (f *fakeKMSClient) GetPublicKey( + context.Context, + *awskms.GetPublicKeyInput, + ...func(*awskms.Options), +) (*awskms.GetPublicKeyOutput, error) { + return &awskms.GetPublicKeyOutput{PublicKey: f.publicKey}, nil +} + +func (f *fakeKMSClient) Sign( + ctx context.Context, + input *awskms.SignInput, + _ ...func(*awskms.Options), +) (*awskms.SignOutput, error) { + f.signContext = ctx + r, s, err := ecdsa.Sign(rand.Reader, f.privateKey, input.Message) + require.NoError(f.t, err) + signature, err := asn1.Marshal(struct { + R *big.Int + S *big.Int + }{R: r, S: s}) + require.NoError(f.t, err) + return &awskms.SignOutput{ + KeyId: input.KeyId, + SigningAlgorithm: kmstypes.SigningAlgorithmSpecEcdsaSha256, + Signature: signature, + }, nil +} diff --git a/internal/prt/fixture_test.go b/internal/prt/fixture_test.go new file mode 100644 index 000000000..04a83266e --- /dev/null +++ b/internal/prt/fixture_test.go @@ -0,0 +1,389 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "io" + "log/slog" + "math/big" + "time" + + "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" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" +) + +type prtRepositoryMock struct { + mock.Mock +} + +var _ prtRepository = (*prtRepositoryMock)(nil) + +func (m *prtRepositoryMock) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + p repository.Pagination, + descending bool, +) ([]*model.Application, uint64, error) { + args := m.Called(ctx, f, p, descending) + apps, _ := args.Get(0).([]*model.Application) + return apps, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) UpdateApplicationStatus( + ctx context.Context, + appID int64, + status model.ApplicationStatus, + reason *string, +) error { + args := m.Called(ctx, appID, status, reason) + return args.Error(0) +} + +func (m *prtRepositoryMock) HasUndrainedEpochsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *prtRepositoryMock) HasUnreconciledClaimsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *prtRepositoryMock) UpdateEpochWithForeclosedClaim( + ctx context.Context, + applicationID int64, + index uint64, +) error { + args := m.Called(ctx, applicationID, index) + return args.Error(0) +} + +func (m *prtRepositoryMock) ListEpochs( + ctx context.Context, + nameOrAddress string, + f repository.EpochFilter, + p repository.Pagination, + descending bool, +) ([]*model.Epoch, uint64, error) { + args := m.Called(ctx, nameOrAddress, f, p, descending) + epochs, _ := args.Get(0).([]*model.Epoch) + return epochs, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) GetEpoch( + ctx context.Context, + nameOrAddress string, + index uint64, +) (*model.Epoch, error) { + args := m.Called(ctx, nameOrAddress, index) + epoch, _ := args.Get(0).(*model.Epoch) + return epoch, args.Error(1) +} + +func (m *prtRepositoryMock) UpdateEpochStatus( + ctx context.Context, + nameOrAddress string, + e *model.Epoch, +) error { + args := m.Called(ctx, nameOrAddress, e) + return args.Error(0) +} + +func (m *prtRepositoryMock) CreateTournament( + ctx context.Context, + nameOrAddress string, + t *model.Tournament, +) error { + args := m.Called(ctx, nameOrAddress, t) + return args.Error(0) +} + +func (m *prtRepositoryMock) GetTournament( + ctx context.Context, + nameOrAddress string, + address string, +) (*model.Tournament, error) { + args := m.Called(ctx, nameOrAddress, address) + tournament, _ := args.Get(0).(*model.Tournament) + 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, + f repository.TournamentFilter, + p repository.Pagination, + descending bool, +) ([]*model.Tournament, uint64, error) { + args := m.Called(ctx, nameOrAddress, f, p, descending) + tournaments, _ := args.Get(0).([]*model.Tournament) + return tournaments, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) StoreTournamentEvents( + ctx context.Context, + appID int64, + commitments []*model.Commitment, + matches []*model.Match, + advancedMatches []*model.MatchAdvanced, + deletedMatches []*model.Match, + blockNumber uint64, +) error { + args := m.Called(ctx, appID, commitments, matches, advancedMatches, deletedMatches, blockNumber) + return args.Error(0) +} + +func (m *prtRepositoryMock) GetCommitment( + ctx context.Context, + nameOrAddress string, + epochIndex uint64, + tournamentAddress string, + commitment string, +) (*model.Commitment, error) { + args := m.Called(ctx, nameOrAddress, epochIndex, tournamentAddress, commitment) + c, _ := args.Get(0).(*model.Commitment) + return c, args.Error(1) +} + +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) LoadNodeConfigRaw( + ctx context.Context, + key string, +) ([]byte, time.Time, time.Time, error) { + args := m.Called(ctx, key) + raw, _ := args.Get(0).([]byte) + return raw, args.Get(1).(time.Time), args.Get(2).(time.Time), args.Error(3) +} + +type ethClientMock struct { + mock.Mock +} + +var _ EthClientInterface = (*ethClientMock)(nil) + +func (m *ethClientMock) TransactionReceipt( + ctx context.Context, + txHash common.Hash, +) (*types.Receipt, error) { + args := m.Called(ctx, txHash) + receipt, _ := args.Get(0).(*types.Receipt) + return receipt, args.Error(1) +} + +func (m *ethClientMock) ChainID(ctx context.Context) (*big.Int, error) { + args := m.Called(ctx) + chainID, _ := args.Get(0).(*big.Int) + return chainID, args.Error(1) +} + +func (m *ethClientMock) BlockNumber(ctx context.Context) (uint64, error) { + args := m.Called(ctx) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *ethClientMock) TransactionByHash( + ctx context.Context, + hash common.Hash, +) (*types.Transaction, bool, error) { + args := m.Called(ctx, hash) + tx, _ := args.Get(0).(*types.Transaction) + return tx, args.Bool(1), args.Error(2) +} + +type adapterFactoryMock struct { + mock.Mock +} + +var _ AdapterFactory = (*adapterFactoryMock)(nil) + +func (m *adapterFactoryMock) CreateTournamentAdapter(addr common.Address) (TournamentAdapter, error) { + args := m.Called(addr) + adapter, _ := args.Get(0).(TournamentAdapter) + return adapter, args.Error(1) +} + +func (m *adapterFactoryMock) CreateDaveConsensusAdapter(addr common.Address) (DaveConsensusAdapter, error) { + args := m.Called(addr) + adapter, _ := args.Get(0).(DaveConsensusAdapter) + return adapter, args.Error(1) +} + +type daveConsensusAdapterMock struct { + mock.Mock +} + +var _ DaveConsensusAdapter = (*daveConsensusAdapterMock)(nil) + +func (m *daveConsensusAdapterMock) ParseEpochSealed( + log types.Log, +) (*idaveconsensus.IDaveConsensusEpochSealed, error) { + args := m.Called(log) + event, _ := args.Get(0).(*idaveconsensus.IDaveConsensusEpochSealed) + return event, args.Error(1) +} + +func (m *daveConsensusAdapterMock) CanSettle(opts *bind.CallOpts) (CanSettleResult, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CanSettleResult) + return result, args.Error(1) +} + +func (m *daveConsensusAdapterMock) IsEpochSettled( + opts *bind.CallOpts, + epochNumber uint64, +) (bool, error) { + args := m.Called(opts, epochNumber) + return args.Bool(0), args.Error(1) +} + +func (m *daveConsensusAdapterMock) Settle( + opts *bind.TransactOpts, + epochNumber *big.Int, + outputsMerkleRoot [32]byte, + proof [][32]byte, +) (*types.Transaction, error) { + args := m.Called(opts, epochNumber, outputsMerkleRoot, proof) + 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) + } + return tx, args.Error(1) +} + +type tournamentAdapterMock struct { + mock.Mock +} + +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) { + args := m.Called(opts) + events, _ := args.Get(0).([]*itournament.ITournamentMatchDeleted) + return events, args.Error(1) +} + +func (m *tournamentAdapterMock) RetrieveNewInnerTournamentEvents( + opts *bind.FilterOpts, +) ([]*itournament.ITournamentNewInnerTournament, error) { + args := m.Called(opts) + events, _ := args.Get(0).([]*itournament.ITournamentNewInnerTournament) + return events, args.Error(1) +} + +func (m *tournamentAdapterMock) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { + args := m.Called(opts) + events, _ := args.Get(0).(*TournamentEvents) + return events, 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) Constants(opts *bind.CallOpts) (TournamentConstants, error) { + args := m.Called(opts) + constants, _ := args.Get(0).(TournamentConstants) + return constants, args.Error(1) +} + +func (m *tournamentAdapterMock) TimeFinished(opts *bind.CallOpts) (bool, uint64, error) { + args := m.Called(opts) + return args.Bool(0), args.Get(1).(uint64), args.Error(2) +} + +func (m *tournamentAdapterMock) BondValue(opts *bind.CallOpts) (*big.Int, error) { + args := m.Called(opts) + value, _ := args.Get(0).(*big.Int) + return value, args.Error(1) +} + +func (m *tournamentAdapterMock) IsCommitmentJoined( + opts *bind.CallOpts, + commitmentRoot [32]byte, +) (bool, error) { + args := m.Called(opts, commitmentRoot) + return args.Bool(0), args.Error(1) +} + +func (m *tournamentAdapterMock) JoinTournament( + opts *bind.TransactOpts, + finalState [32]byte, + proof [][32]byte, + leftNode [32]byte, + rightNode [32]byte, +) (*types.Transaction, error) { + args := m.Called(opts, finalState, proof, leftNode, rightNode) + tx, _ := args.Get(0).(*types.Transaction) + if fn, ok := args.Get(1).(func(*bind.TransactOpts, [32]byte, [][32]byte, [32]byte, [32]byte) error); ok { + return tx, fn(opts, finalState, proof, leftNode, rightNode) + } + return tx, args.Error(1) +} + +func newPRTServiceMock() (*Service, *prtRepositoryMock) { + repo := &prtRepositoryMock{} + s := &Service{ + Service: service.Service{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }, + repository: repo, + } + return s, repo +} diff --git a/internal/prt/handle_foreclosed_test.go b/internal/prt/handle_foreclosed_test.go index 3dde90c24..0c6a9b5be 100644 --- a/internal/prt/handle_foreclosed_test.go +++ b/internal/prt/handle_foreclosed_test.go @@ -6,141 +6,16 @@ package prt import ( "context" "errors" - "log/slog" - "math/big" - "os" "testing" - "time" "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/internal/repository" - "github.com/cartesi/rollups-node/pkg/service" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -// prtRepositoryMock is a hand-written mock for the prtRepository interface, -// stubbing only the methods used by handleForeclosedApp. Unused methods -// keep zero-value Return signatures so the surface compiles; if a test -// accidentally invokes them, testify/mock reports an unexpected call. -type prtRepositoryMock struct { - mock.Mock -} - -func (m *prtRepositoryMock) HasUndrainedEpochsBeforeBlock( - ctx context.Context, appID int64, blockBound uint64, -) (bool, error) { - args := m.Called(ctx, appID, blockBound) - return args.Bool(0), args.Error(1) -} - -func (m *prtRepositoryMock) HasUnreconciledClaimsBeforeBlock( - ctx context.Context, appID int64, blockBound uint64, -) (bool, error) { - args := m.Called(ctx, appID, blockBound) - return args.Bool(0), args.Error(1) -} - -func (m *prtRepositoryMock) UpdateEpochWithForeclosedClaim( - ctx context.Context, applicationID int64, index uint64, -) error { - args := m.Called(ctx, applicationID, index) - return args.Error(0) -} - -func (m *prtRepositoryMock) UpdateApplicationStatus( - ctx context.Context, appID int64, status model.ApplicationStatus, reason *string, -) error { - args := m.Called(ctx, appID, status, reason) - return args.Error(0) -} - -// Unused-by-this-suite methods. We satisfy the interface but each panics -// loudly if invoked — handleForeclosedApp must not reach for them. -func (m *prtRepositoryMock) ListApplications( - ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool, -) ([]*model.Application, uint64, error) { - args := m.Called(ctx, f, p, descending) - return args.Get(0).([]*model.Application), args.Get(1).(uint64), args.Error(2) -} -func (m *prtRepositoryMock) ListEpochs( - ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool, -) ([]*model.Epoch, uint64, error) { - args := m.Called(ctx, nameOrAddress, f, p, descending) - return args.Get(0).([]*model.Epoch), args.Get(1).(uint64), args.Error(2) -} -func (m *prtRepositoryMock) GetEpoch(context.Context, string, uint64) (*model.Epoch, error) { - panic("unexpected GetEpoch") -} -func (m *prtRepositoryMock) UpdateEpochStatus(context.Context, string, *model.Epoch) error { - panic("unexpected UpdateEpochStatus") -} -func (m *prtRepositoryMock) CreateTournament(context.Context, string, *model.Tournament) error { - panic("unexpected CreateTournament") -} -func (m *prtRepositoryMock) GetTournament(context.Context, string, string) (*model.Tournament, error) { - panic("unexpected GetTournament") -} -func (m *prtRepositoryMock) UpdateTournament(context.Context, string, *model.Tournament) error { - panic("unexpected UpdateTournament") -} -func (m *prtRepositoryMock) ListTournaments( - context.Context, string, repository.TournamentFilter, repository.Pagination, bool, -) ([]*model.Tournament, uint64, error) { - panic("unexpected ListTournaments") -} -func (m *prtRepositoryMock) StoreTournamentEvents( - context.Context, int64, []*model.Commitment, []*model.Match, - []*model.MatchAdvanced, []*model.Match, uint64, -) error { - panic("unexpected StoreTournamentEvents") -} -func (m *prtRepositoryMock) GetCommitment(context.Context, string, uint64, string, string) (*model.Commitment, error) { - panic("unexpected GetCommitment") -} -func (m *prtRepositoryMock) SaveNodeConfigRaw(context.Context, string, []byte) error { - panic("unexpected SaveNodeConfigRaw") -} -func (m *prtRepositoryMock) LoadNodeConfigRaw(context.Context, string) ([]byte, time.Time, time.Time, error) { - panic("unexpected LoadNodeConfigRaw") -} - -// newPRTServiceMock builds a minimal Service wired to a prtRepositoryMock. -// Only the fields handleForeclosedApp reaches for are populated. -func newPRTServiceMock() (*Service, *prtRepositoryMock) { - repo := &prtRepositoryMock{} - s := &Service{ - Service: service.Service{ - Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})), - }, - repository: repo, - } - return s, repo -} - -type prtEthClientMock struct { - blockNumber uint64 -} - -func (m prtEthClientMock) BlockNumber(context.Context) (uint64, error) { - return m.blockNumber, nil -} - -func (m prtEthClientMock) TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) { - panic("unexpected TransactionReceipt") -} - -func (m prtEthClientMock) ChainID(context.Context) (*big.Int, error) { - panic("unexpected ChainID") -} - -func (m prtEthClientMock) TransactionByHash(context.Context, common.Hash) (*types.Transaction, bool, error) { - panic("unexpected TransactionByHash") -} - func prtForeclosedApp(id int64, block uint64) *model.Application { txHash := common.HexToHash("0xcafe") return &model.Application{ @@ -331,7 +206,9 @@ func TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass(t *test defer r.AssertExpectations(t) app := prtForeclosedApp(1, 100) - s.client = prtEthClientMock{blockNumber: 120} + client := ðClientMock{} + client.On("BlockNumber", mock.Anything).Return(uint64(120), nil).Once() + s.client = client claimTx := common.HexToHash("0xbeef") r.On("HasUndrainedEpochsBeforeBlock", @@ -351,6 +228,7 @@ func TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass(t *test require.NoError(t, s.handleForeclosedApp(context.Background(), app)) r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(1)) + client.AssertExpectations(t) } // TestForecloseComputedEpochs_TerminalizesEachComputedEpoch verifies the diff --git a/internal/prt/prt.go b/internal/prt/prt.go index a03cf41be..f64c77fe1 100644 --- a/internal/prt/prt.go +++ b/internal/prt/prt.go @@ -718,7 +718,16 @@ func (s *Service) trySettle(ctx context.Context, app *Application, mostRecentBlo s.Logger.Info("Sending Settle transaction", "application", app.Name, "epoch_index", epoch.Index, "outputs_merkle_root", epoch.OutputsMerkleRoot.String()) - tx, err := consensus.Settle(s.txOpts, result.EpochNumber, + if s.txOptsFactory == nil { + return fmt.Errorf("txOpts is required for settlement") + } + txCtx, cancel := context.WithTimeout(ctx, s.submissionTimeout) + defer cancel() + txOpts, err := s.txOptsFactory.NewTransactOpts(txCtx) + if err != nil { + return fmt.Errorf("creating transaction options for settlement: %w", err) + } + tx, err := consensus.Settle(txOpts, result.EpochNumber, *epoch.OutputsMerkleRoot, hashSliceToByteSlice(epoch.OutputsMerkleProof)) if err != nil { return s.handleSettleRevert(ctx, app, result.EpochNumber.Uint64(), err) @@ -955,7 +964,16 @@ func (s *Service) reactToTournament(ctx context.Context, app *Application, mostR return err } - txOptsWithValue := *s.txOpts + if s.txOptsFactory == nil { + return fmt.Errorf("txOpts is required for joining tournament") + } + 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) + } + txOptsWithValue := *txOpts txOptsWithValue.Value = bondValue // FIXME move this to constants diff --git a/internal/prt/reverts_test.go b/internal/prt/reverts_test.go index 791771206..e5f49b3f1 100644 --- a/internal/prt/reverts_test.go +++ b/internal/prt/reverts_test.go @@ -15,9 +15,7 @@ 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/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -70,62 +68,6 @@ func tournamentRevertError(name string) error { return &rpcDataError{code: 3, msg: "execution reverted", data: fmt.Sprintf("0x%x", abiErr.ID[:4])} } -// tournamentAdapterMock implements TournamentAdapter for the revert-handler -// tests. Only IsCommitmentJoined is configurable; any other call panics so a -// test cannot silently depend on it. -type tournamentAdapterMock struct { - joined bool - joinedErr error -} - -func (m *tournamentAdapterMock) IsCommitmentJoined(*bind.CallOpts, [32]byte) (bool, error) { - return m.joined, m.joinedErr -} - -func (m *tournamentAdapterMock) RetrieveCommitmentJoinedEvents(*bind.FilterOpts) ([]*itournament.ITournamentCommitmentJoined, error) { - panic("unexpected RetrieveCommitmentJoinedEvents") -} - -func (m *tournamentAdapterMock) RetrieveMatchAdvancedEvents(*bind.FilterOpts) ([]*itournament.ITournamentMatchAdvanced, error) { - panic("unexpected RetrieveMatchAdvancedEvents") -} - -func (m *tournamentAdapterMock) RetrieveMatchCreatedEvents(*bind.FilterOpts) ([]*itournament.ITournamentMatchCreated, error) { - panic("unexpected RetrieveMatchCreatedEvents") -} - -func (m *tournamentAdapterMock) RetrieveMatchDeletedEvents(*bind.FilterOpts) ([]*itournament.ITournamentMatchDeleted, error) { - panic("unexpected RetrieveMatchDeletedEvents") -} - -func (m *tournamentAdapterMock) RetrieveNewInnerTournamentEvents(*bind.FilterOpts) ([]*itournament.ITournamentNewInnerTournament, error) { - panic("unexpected RetrieveNewInnerTournamentEvents") -} - -func (m *tournamentAdapterMock) RetrieveAllEvents(*bind.FilterOpts) (*TournamentEvents, error) { - panic("unexpected RetrieveAllEvents") -} - -func (m *tournamentAdapterMock) Result(*bind.CallOpts) (bool, [32]byte, [32]byte, error) { - panic("unexpected Result") -} - -func (m *tournamentAdapterMock) Constants(*bind.CallOpts) (TournamentConstants, error) { - panic("unexpected Constants") -} - -func (m *tournamentAdapterMock) TimeFinished(*bind.CallOpts) (bool, uint64, error) { - panic("unexpected TimeFinished") -} - -func (m *tournamentAdapterMock) BondValue(*bind.CallOpts) (*big.Int, error) { - panic("unexpected BondValue") -} - -func (m *tournamentAdapterMock) JoinTournament(*bind.TransactOpts, [32]byte, [][32]byte, [32]byte, [32]byte) (*types.Transaction, error) { - panic("unexpected JoinTournament") -} - func prtRevertTestApp() *model.Application { return &model.Application{ ID: 7, @@ -323,9 +265,12 @@ func TestHandleJoinTournamentRevert(t *testing.T) { epoch.TournamentAddress.Hex(), "before re-enabling"))). Return(nil).Once() + adapter := &tournamentAdapterMock{} + adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(false, nil).Once() err := s.handleJoinTournamentRevert(context.Background(), app, epoch, - &tournamentAdapterMock{joined: false}, tournamentRevertError(revertName)) + adapter, tournamentRevertError(revertName)) assert.NoError(t, err) + adapter.AssertExpectations(t) }) } @@ -336,19 +281,27 @@ func TestHandleJoinTournamentRevert(t *testing.T) { // prevent a false FAILED. s, r := newPRTServiceMock() defer r.AssertExpectations(t) - err := s.handleJoinTournamentRevert(context.Background(), prtRevertTestApp(), prtRevertTestEpoch(), - &tournamentAdapterMock{joined: true}, tournamentRevertError("TournamentIsClosed")) + epoch := prtRevertTestEpoch() + adapter := &tournamentAdapterMock{} + adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(true, 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") + adapter.AssertExpectations(t) }) t.Run("TournamentIsClosed_recheckFails_retries", func(t *testing.T) { s, r := newPRTServiceMock() defer r.AssertExpectations(t) + epoch := prtRevertTestEpoch() + adapter := &tournamentAdapterMock{} + adapter.On("IsCommitmentJoined", mock.Anything, [32]byte(*epoch.Commitment)).Return(false, errors.New("rpc down")).Once() boom := tournamentRevertError("TournamentIsClosed") - err := s.handleJoinTournamentRevert(context.Background(), prtRevertTestApp(), prtRevertTestEpoch(), - &tournamentAdapterMock{joinedErr: errors.New("rpc down")}, boom) + err := s.handleJoinTournamentRevert(context.Background(), prtRevertTestApp(), epoch, + adapter, boom) assert.Equal(t, boom, err, "when the re-check fails the original revert must propagate for a retry, not FAILED") + adapter.AssertExpectations(t) }) for _, revertName := range []string{"CommitmentStateMismatch", "CommitmentProofWrongSize"} { diff --git a/internal/prt/service.go b/internal/prt/service.go index 4847b054f..9d9878886 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "math/big" + "time" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" @@ -15,7 +16,6 @@ import ( "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/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" ) @@ -34,8 +34,9 @@ type Service struct { client EthClientInterface adapterFactory AdapterFactory submissionEnabled bool + submissionTimeout time.Duration filter ethutil.Filter - txOpts *bind.TransactOpts + 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 @@ -112,7 +113,11 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { s.joinInFlight = map[int64]*common.Hash{} if s.submissionEnabled { - s.txOpts, err = auth.GetTransactOpts(ctx, chainID) + 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) if err != nil { return nil, err } diff --git a/internal/prt/validation_test.go b/internal/prt/validation_test.go new file mode 100644 index 000000000..2abefc57a --- /dev/null +++ b/internal/prt/validation_test.go @@ -0,0 +1,159 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "io" + "log/slog" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTrySettleOperationDeadlineDoesNotCancelServiceContext(t *testing.T) { + s, app := newValidationService(t) + ctx, cancel := context.WithTimeout(s.Context, 50*time.Millisecond) + defer cancel() + + err := s.trySettle(ctx, app, 1) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NoError(t, s.Context.Err()) +} + +func TestTrySettleShutdownCancelsOperationContext(t *testing.T) { + s, app := newValidationService(t) + ctx, cancel := context.WithTimeout(s.Context, time.Second) + defer cancel() + + time.AfterFunc(50*time.Millisecond, s.Cancel) + 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(s.Context, 50*time.Millisecond) + defer cancel() + + err := s.reactToTournament(ctx, app, 1) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NoError(t, s.Context.Err()) +} + +func TestReactToTournamentShutdownCancelsOperationContext(t *testing.T) { + s, app := newValidationService(t) + ctx, cancel := context.WithTimeout(s.Context, time.Second) + defer cancel() + + time.AfterFunc(50*time.Millisecond, s.Cancel) + start := time.Now() + err := s.reactToTournament(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 newValidationService(t *testing.T) (*Service, *model.Application) { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + app := repotest.NewApplicationBuilder(). + WithEpochLength(10). + Build() + app.IConsensusAddress = common.HexToAddress("0x3") + + epoch := repotest.NewEpochBuilder(app.ID). + WithStatus(model.EpochStatus_ClaimComputed). + WithMachineHash(common.HexToHash("0x6")). + WithOutputsMerkleRoot(common.HexToHash("0x8")). + Build() + tournamentAddress := common.HexToAddress("0x4") + commitment := common.HexToHash("0x5") + epoch.TournamentAddress = &tournamentAddress + epoch.Commitment = &commitment + epoch.CommitmentProof = []common.Hash{common.HexToHash("0x7")} + epoch.OutputsMerkleProof = []common.Hash{} + + repo := &prtRepositoryMock{} + repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), uint64(0)). + Return(epoch, nil) + repo.On("GetCommitment", mock.Anything, app.IApplicationAddress.Hex(), uint64(0), + tournamentAddress.Hex(), commitment.String()). + Return(nil, nil). + 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 { + <-opts.Context.Done() + return opts.Context.Err() + }) + + tournamentAdapter := &tournamentAdapterMock{} + tournamentAdapter.On("IsCommitmentJoined", mock.Anything, [32]byte(commitment)). + Return(false, nil) + tournamentAdapter.On("BondValue", mock.Anything). + Return(big.NewInt(0), nil) + tournamentAdapter.On("JoinTournament", + mock.Anything, [32]byte(*epoch.MachineHash), mock.Anything, mock.Anything, mock.Anything). + Return((*types.Transaction)(nil), func( + opts *bind.TransactOpts, + _ [32]byte, + _ [][32]byte, + _ [32]byte, + _ [32]byte, + ) error { + <-opts.Context.Done() + return opts.Context.Err() + }) + + adapterFactory := &adapterFactoryMock{} + adapterFactory.On("CreateDaveConsensusAdapter", app.IConsensusAddress). + Return(consensusAdapter, nil) + adapterFactory.On("CreateTournamentAdapter", tournamentAddress). + Return(tournamentAdapter, nil) + + s := &Service{ + Service: service.Service{ + Context: ctx, + Cancel: cancel, + 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 + t.Cleanup(cancel) + return s, app +} diff --git a/pkg/ethutil/anvil.go b/pkg/ethutil/anvil.go index c20d49275..d07e31177 100644 --- a/pkg/ethutil/anvil.go +++ b/pkg/ethutil/anvil.go @@ -61,7 +61,7 @@ func CreateAnvilSnapshotAndDeployApp(ctx context.Context, client *ethclient.Clie EpochLength: 10, Salt: [32]byte{}, } - applicationAddress, _, err := deployment.Deploy(ctx, client, txOpts) + applicationAddress, _, err := deployment.Deploy(ctx, client, NewStaticTransactOptsFactory(txOpts)) if err != nil { _ = RevertToAnvilSnapshot(client.Client(), snapshotID) return zero, nil, fmt.Errorf("failed to deploy self-hosted application: %w", err) diff --git a/pkg/ethutil/application.go b/pkg/ethutil/application.go index 6ba79b43e..3e0e7cc5b 100644 --- a/pkg/ethutil/application.go +++ b/pkg/ethutil/application.go @@ -14,7 +14,7 @@ import ( ) type IApplicationDeployment interface { - Deploy(ctx context.Context, client *ethclient.Client, txOpts *bind.TransactOpts) (common.Address, IApplicationDeploymentResult, error) + Deploy(ctx context.Context, client *ethclient.Client, txOptsFactory TransactOptsFactory) (common.Address, IApplicationDeploymentResult, error) GetFactoryAddress() common.Address } type IApplicationDeploymentResult interface{} @@ -71,14 +71,14 @@ func (me *ApplicationDeploymentResult) String() string { func (me *ApplicationDeployment) Deploy( ctx context.Context, client *ethclient.Client, - txOpts *bind.TransactOpts, + txOptsFactory TransactOptsFactory, ) (common.Address, IApplicationDeploymentResult, error) { zero := common.Address{} result := &ApplicationDeploymentResult{} result.Deployment = me factory, err := iapplicationfactory.NewIApplicationFactory(me.FactoryAddress, client) if err != nil { - return zero, nil, fmt.Errorf("failed to instantiate contract: %v", err) + return zero, nil, fmt.Errorf("failed to instantiate contract: %w", err) } if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { @@ -103,14 +103,19 @@ func (me *ApplicationDeployment) Deploy( } // deploy the contracts + txOpts, err := txOptsFactory.NewTransactOpts(ctx) + if err != nil { + 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) if err != nil { - return zero, nil, fmt.Errorf("transaction failed: %v", err) + 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: %v", err) + return zero, nil, fmt.Errorf("failed to wait for transaction mining: %w", err) } if receipt.Status != 1 { diff --git a/pkg/ethutil/authority.go b/pkg/ethutil/authority.go index 3d42799fa..228d24596 100644 --- a/pkg/ethutil/authority.go +++ b/pkg/ethutil/authority.go @@ -44,7 +44,7 @@ func (me *AuthorityDeployment) Deploy( zero := common.Address{} factory, err := iauthorityfactory.NewIAuthorityFactory(me.FactoryAddress, client) if err != nil { - return common.Address{}, fmt.Errorf("failed to instantiate contract: %v", err) + return common.Address{}, fmt.Errorf("failed to instantiate contract: %w", err) } // check if addresses are available (have no code) @@ -64,12 +64,12 @@ func (me *AuthorityDeployment) Deploy( // deploy the contracts tx, err := factory.NewAuthority0(txOpts, me.OwnerAddress, new(big.Int).SetUint64(me.EpochLength), new(big.Int).SetUint64(me.ClaimStagingPeriod), me.Salt) if err != nil { - return common.Address{}, fmt.Errorf("failed to create new authority: %v", err) + 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: %v", err) + return common.Address{}, fmt.Errorf("failed to mine new authority transaction: %w", err) } if receipt.Status != 1 { diff --git a/pkg/ethutil/ethutil.go b/pkg/ethutil/ethutil.go index ce3c6f477..5d31f7e67 100644 --- a/pkg/ethutil/ethutil.go +++ b/pkg/ethutil/ethutil.go @@ -20,9 +20,6 @@ import ( "github.com/ethereum/go-ethereum/ethclient" ) -// Gas limit when sending transactions. -const GasLimit = 30_000_000 - // Dev mnemonic used by Foundry/Anvil. const FoundryMnemonic = "test test test test test test test test test test test junk" @@ -38,7 +35,7 @@ func TrimHex(s string) string { func AddInput( ctx context.Context, client *ethclient.Client, - transactionOpts *bind.TransactOpts, + txOptsFactory TransactOptsFactory, inputBoxAddress common.Address, application common.Address, input []byte, @@ -48,10 +45,10 @@ func AddInput( } inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) if err != nil { - return 0, 0, common.Hash{}, fmt.Errorf("failed to connect to InputBox contract: %v", err) + return 0, 0, common.Hash{}, fmt.Errorf("failed to connect to InputBox contract: %w", err) } receipt, err := sendTransaction( - ctx, client, transactionOpts, big.NewInt(0), GasLimit, + ctx, client, txOptsFactory, big.NewInt(0), func(txOpts *bind.TransactOpts) (*types.Transaction, error) { return inputBox.AddInput(txOpts, application, input) }, @@ -75,7 +72,7 @@ func AddInput( func AddInputAsync( ctx context.Context, client *ethclient.Client, - transactionOpts *bind.TransactOpts, + txOptsFactory TransactOptsFactory, inputBoxAddress common.Address, application common.Address, input []byte, @@ -87,11 +84,13 @@ func AddInputAsync( if err != nil { return common.Hash{}, fmt.Errorf("failed to connect to InputBox contract: %w", err) } - txOpts, err := _prepareTransaction(ctx, client, transactionOpts, big.NewInt(0), GasLimit) + txOpts, err := _prepareTransaction(ctx, client, txOptsFactory, big.NewInt(0)) if err != nil { return common.Hash{}, fmt.Errorf("failed to prepare transaction: %w", err) } - tx, err := inputBox.AddInput(txOpts, application, input) + txOptsCopy := *txOpts + txOptsCopy.Context = ctx + tx, err := inputBox.AddInput(&txOptsCopy, application, input) if err != nil { return common.Hash{}, fmt.Errorf("failed to send transaction: %w", err) } @@ -121,7 +120,7 @@ func getInputIndex( } inputAdded, err := inputBox.ParseInputAdded(*log) if err != nil { - return 0, fmt.Errorf("failed to parse input added event: %v", err) + return 0, fmt.Errorf("failed to parse input added event: %w", err) } // We assume that uint64 will fit all dapp inputs for now return inputAdded.Index.Uint64(), nil @@ -142,7 +141,7 @@ func GetInputFromInputBox( } inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) if err != nil { - return nil, fmt.Errorf("failed to connect to InputBox contract: %v", err) + return nil, fmt.Errorf("failed to connect to InputBox contract: %w", err) } it, err := inputBox.FilterInputAdded( nil, @@ -150,7 +149,7 @@ func GetInputFromInputBox( []*big.Int{new(big.Int).SetUint64(inputIndex)}, ) if err != nil { - return nil, fmt.Errorf("failed to filter input added: %v", err) + return nil, fmt.Errorf("failed to filter input added: %w", err) } defer it.Close() if !it.Next() { @@ -183,7 +182,7 @@ func ValidateOutput( app, err := iapplication.NewIApplication(appAddr, client) if err != nil { - return fmt.Errorf("failed to connect to CartesiDapp contract: %v", err) + return fmt.Errorf("failed to connect to CartesiDapp contract: %w", err) } return app.ValidateOutput(&bind.CallOpts{Context: ctx}, output, proof) } @@ -193,7 +192,7 @@ func ValidateOutput( func ExecuteOutput( ctx context.Context, client *ethclient.Client, - transactionOpts *bind.TransactOpts, + txOptsFactory TransactOptsFactory, appAddr common.Address, index uint64, output []byte, @@ -213,10 +212,10 @@ func ExecuteOutput( app, err := iapplication.NewIApplication(appAddr, client) if err != nil { - return nil, fmt.Errorf("failed to connect to CartesiDapp contract: %v", err) + return nil, fmt.Errorf("failed to connect to CartesiDapp contract: %w", err) } receipt, err := sendTransaction( - ctx, client, transactionOpts, big.NewInt(0), GasLimit, + ctx, client, txOptsFactory, big.NewInt(0), func(txOpts *bind.TransactOpts) (*types.Transaction, error) { return app.ExecuteOutput(txOpts, output, proof) }, @@ -271,12 +270,12 @@ func GetConsensusAt( } app, err := iapplication.NewIApplication(appAddress, client) if err != nil { - return common.Address{}, fmt.Errorf("Failed to instantiate contract: %v", err) + return common.Address{}, fmt.Errorf("Failed to instantiate contract: %w", err) } opts := &bind.CallOpts{Context: ctx, BlockNumber: blockNumber} consensus, err := app.GetOutputsMerkleRootValidator(opts) if err != nil { - return common.Address{}, fmt.Errorf("error retrieving application epoch length: %v", err) + return common.Address{}, fmt.Errorf("error retrieving application epoch length: %w", err) } return consensus, nil } @@ -291,11 +290,11 @@ func GetDataAvailability( } app, err := iapplication.NewIApplication(appAddress, client) if err != nil { - return nil, fmt.Errorf("Failed to instantiate contract: %v", err) + return nil, fmt.Errorf("Failed to instantiate contract: %w", err) } dataAvailability, err := app.GetDataAvailability(&bind.CallOpts{Context: ctx}) if err != nil { - return nil, fmt.Errorf("error retrieving application epoch length: %v", err) + return nil, fmt.Errorf("error retrieving application epoch length: %w", err) } return dataAvailability, nil } @@ -310,11 +309,11 @@ func GetEpochLength( } consensus, err := iconsensus.NewIConsensus(consensusAddr, client) if err != nil { - return 0, fmt.Errorf("Failed to instantiate contract: %v", err) + return 0, fmt.Errorf("Failed to instantiate contract: %w", err) } epochLengthRaw, err := consensus.GetEpochLength(&bind.CallOpts{Context: ctx}) if err != nil { - return 0, fmt.Errorf("error retrieving application epoch length: %v", err) + return 0, fmt.Errorf("error retrieving application epoch length: %w", err) } return epochLengthRaw.Uint64(), nil } @@ -332,11 +331,11 @@ func GetClaimStagingPeriod( } consensus, err := iconsensus.NewIConsensus(consensusAddr, client) if err != nil { - return 0, fmt.Errorf("failed to instantiate contract: %v", err) + return 0, fmt.Errorf("failed to instantiate contract: %w", err) } raw, err := consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: ctx}) if err != nil { - return 0, fmt.Errorf("error retrieving claim staging period: %v", err) + return 0, fmt.Errorf("error retrieving claim staging period: %w", err) } return raw.Uint64(), nil } @@ -355,7 +354,7 @@ func GetInputBoxDeploymentBlock( } block, err := inputbox.GetDeploymentBlockNumber(&bind.CallOpts{Context: ctx}) if err != nil { - return nil, fmt.Errorf("error retrieving inputbox deployment block: %v", err) + return nil, fmt.Errorf("error retrieving inputbox deployment block: %w", err) } return block, nil } diff --git a/pkg/ethutil/ethutil_test.go b/pkg/ethutil/ethutil_test.go index d8fbb9c17..cf9ddebb6 100644 --- a/pkg/ethutil/ethutil_test.go +++ b/pkg/ethutil/ethutil_test.go @@ -6,6 +6,8 @@ package ethutil import ( "context" "crypto/rand" + "errors" + "math/big" "os" "sync" "testing" @@ -15,7 +17,11 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/inputs" "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/require" "github.com/stretchr/testify/suite" ) @@ -29,7 +35,7 @@ type EthUtilSuite struct { cancel context.CancelFunc client *ethclient.Client endpoint config.URL - txOpts *bind.TransactOpts + txOptsFactory TransactOptsFactory inputBoxAddr common.Address selfHostedAppFactory common.Address appAddr common.Address @@ -53,8 +59,9 @@ func (s *EthUtilSuite) SetupTest() { privateKey, err := MnemonicToPrivateKey(FoundryMnemonic, 0) s.Require().Nil(err) - s.txOpts, err = bind.NewKeyedTransactorWithChainID(privateKey, chainId) + txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainId) s.Require().Nil(err) + s.txOptsFactory = NewStaticTransactOptsFactory(txOpts) s.selfHostedAppFactory, err = config.GetContractsSelfHostedApplicationFactoryAddress() s.Require().Nil(err) @@ -82,7 +89,7 @@ func (s *EthUtilSuite) TearDownTest() { func (s *EthUtilSuite) TestAddInput() { - sender := s.txOpts.From + sender := s.txOptsFactory.From() payload := common.Hex2Bytes("deadbeef") indexChan := make(chan uint64) @@ -93,7 +100,7 @@ func (s *EthUtilSuite) TestAddInput() { go func() { waitGroup.Done() - inputIndex, _, _, err := AddInput(s.ctx, s.client, s.txOpts, s.inputBoxAddr, s.appAddr, payload) + inputIndex, _, _, err := AddInput(s.ctx, s.client, s.txOptsFactory, s.inputBoxAddr, s.appAddr, payload) if err != nil { errChan <- err return @@ -140,3 +147,73 @@ func (s *EthUtilSuite) TestMineNewBlock() { func TestEthUtilSuite(t *testing.T) { suite.Run(t, new(EthUtilSuite)) } + +func TestAddInputAsyncUsesContextForBindingTransaction(t *testing.T) { + timeout := 20 * time.Millisecond + + srv := rpc.NewServer() + backend := &addInputAsyncContextBackend{estimateGasTimeout: 10 * timeout} + require.NoError(t, srv.RegisterName("eth", backend)) + defer srv.Stop() + + client := ethclient.NewClient(rpc.DialInProc(srv)) + defer client.Close() + + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, big.NewInt(1)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + _, err = AddInputAsync( + ctx, + client, + NewStaticTransactOptsFactory(txOpts), + common.HexToAddress("0x1000000000000000000000000000000000000001"), + common.HexToAddress("0x2000000000000000000000000000000000000002"), + []byte("payload"), + ) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.True(t, backend.estimateGasCalled, "expected AddInputAsync to reach the binding gas-estimation boundary") +} + +type addInputAsyncContextBackend struct { + estimateGasCalled bool + estimateGasTimeout time.Duration +} + +func (b *addInputAsyncContextBackend) GetTransactionCount( + context.Context, + common.Address, + string, +) (hexutil.Uint64, error) { + return 0, nil +} + +func (b *addInputAsyncContextBackend) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (b *addInputAsyncContextBackend) GetCode( + context.Context, + common.Address, + string, +) (hexutil.Bytes, error) { + return hexutil.Bytes{0x01}, nil +} + +func (b *addInputAsyncContextBackend) EstimateGas( + ctx context.Context, + _ map[string]interface{}, +) (hexutil.Uint64, error) { + b.estimateGasCalled = true + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-time.After(b.estimateGasTimeout): + return 0, errors.New("eth_estimateGas was not called with the AddInputAsync context") + } +} diff --git a/pkg/ethutil/mnemonic.go b/pkg/ethutil/mnemonic.go index 09e2ac607..777e2b9cb 100644 --- a/pkg/ethutil/mnemonic.go +++ b/pkg/ethutil/mnemonic.go @@ -32,7 +32,7 @@ func MnemonicToPrivateKey(mnemonic string, accountIndex uint32) (*ecdsa.PrivateK masterKey, err := bip32.NewMasterKey(seed) if err != nil { - return nil, fmt.Errorf("failed to generate master key: %v", err) + return nil, fmt.Errorf("failed to generate master key: %w", err) } // get key at path m/44'/60'/0'/0/account @@ -48,7 +48,7 @@ func MnemonicToPrivateKey(mnemonic string, accountIndex uint32) (*ecdsa.PrivateK for i, level := range levels { key, err = key.NewChildKey(level) if err != nil { - return nil, fmt.Errorf("failed to get child %v: %v", i, err) + return nil, fmt.Errorf("failed to get child %v: %w", i, err) } } diff --git a/pkg/ethutil/prt.go b/pkg/ethutil/prt.go index bd807d562..128dc17ef 100644 --- a/pkg/ethutil/prt.go +++ b/pkg/ethutil/prt.go @@ -59,7 +59,7 @@ func (me *PRTApplicationDeployment) deployPRT( factory, err := idaveappfactory.NewIDaveAppFactory(me.FactoryAddress, client) if err != nil { - return zero, zero, fmt.Errorf("failed to instantiate contract binding: %v", err) + return zero, zero, fmt.Errorf("failed to instantiate contract binding: %w", err) } if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { @@ -96,12 +96,12 @@ func (me *PRTApplicationDeployment) deployPRT( // deploy the contracts tx, err := factory.NewDaveApp(txOpts, me.TemplateHash, daveWC, me.Salt) if err != nil { - return zero, zero, fmt.Errorf("transaction failed: %v", err) + return zero, zero, fmt.Errorf("transaction failed: %w", err) } receipt, err := bind.WaitMined(ctx, client, tx) if err != nil { - return zero, zero, fmt.Errorf("failed to wait for transaction mining: %v", err) + return zero, zero, fmt.Errorf("failed to wait for transaction mining: %w", err) } if receipt.Status != 1 { @@ -123,13 +123,18 @@ func (me *PRTApplicationDeployment) deployPRT( func (me *PRTApplicationDeployment) Deploy( ctx context.Context, client *ethclient.Client, - txOpts *bind.TransactOpts, + 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) @@ -140,18 +145,18 @@ func (me *PRTApplicationDeployment) Deploy( application, err := iapplication.NewIApplication(appAddress, client) if err != nil { - return zero, nil, fmt.Errorf("failed to instantiate application: %v", err) + return zero, nil, fmt.Errorf("failed to instantiate application: %w", err) } da, err := application.GetDataAvailability(nil) if err != nil { - return zero, nil, fmt.Errorf("failed to retrieve data availability: %v", err) + return zero, nil, fmt.Errorf("failed to retrieve data availability: %w", err) } result.DataAvailability = da result.InputBoxAddress, result.IInputBoxBlock, err = DecodeDA(client, da) if err != nil { - return zero, nil, fmt.Errorf("failed to decode data availability: %v", err) + return zero, nil, fmt.Errorf("failed to decode data availability: %w", err) } if err := VerifyDeployedWithdrawalConfig(ctx, client, appAddress, me.WithdrawalConfig); err != nil { diff --git a/pkg/ethutil/quorum.go b/pkg/ethutil/quorum.go index 36419d7d9..4eafca1bd 100644 --- a/pkg/ethutil/quorum.go +++ b/pkg/ethutil/quorum.go @@ -45,7 +45,7 @@ func (me *QuorumDeployment) Deploy( zero := common.Address{} factory, err := iquorumfactory.NewIQuorumFactory(me.FactoryAddress, client) if err != nil { - return zero, fmt.Errorf("failed to instantiate contract: %v", err) + return zero, fmt.Errorf("failed to instantiate contract: %w", err) } epochLength := new(big.Int).SetUint64(me.EpochLength) @@ -71,12 +71,12 @@ func (me *QuorumDeployment) Deploy( tx, err := factory.NewQuorum(txOpts, me.Validators, epochLength, claimStagingPeriod, me.Salt) if err != nil { - return zero, fmt.Errorf("failed to create new quorum: %v", err) + 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: %v", err) + return zero, fmt.Errorf("failed to mine new quorum transaction: %w", err) } if receipt.Status != 1 { diff --git a/pkg/ethutil/selfhosted.go b/pkg/ethutil/selfhosted.go index d3711d119..89817fd68 100644 --- a/pkg/ethutil/selfhosted.go +++ b/pkg/ethutil/selfhosted.go @@ -74,7 +74,7 @@ func (me *SelfhostedApplicationDeploymentResult) String() string { func (me *SelfhostedApplicationDeployment) Deploy( ctx context.Context, client *ethclient.Client, - txOpts *bind.TransactOpts, + txOptsFactory TransactOptsFactory, ) (common.Address, IApplicationDeploymentResult, error) { zero := common.Address{} result := &SelfhostedApplicationDeploymentResult{} @@ -129,7 +129,7 @@ func (me *SelfhostedApplicationDeployment) Deploy( // deploy the contracts receipt, err := sendTransaction( - ctx, client, txOpts, big.NewInt(0), GasLimit, + ctx, client, txOptsFactory, big.NewInt(0), func(txOpts *bind.TransactOpts) (*types.Transaction, error) { result.ApplicationFactoryAddress, err = factory.GetApplicationFactory(nil) if err != nil { @@ -153,7 +153,7 @@ func (me *SelfhostedApplicationDeployment) Deploy( }, ) if err != nil { - return zero, nil, fmt.Errorf("failed to create a self hosted application: execution reverted") + return zero, nil, fmt.Errorf("failed to create a self hosted application: %w", err) } applicationFactory, err := iapplicationfactory.NewIApplicationFactory(result.ApplicationFactoryAddress, client) diff --git a/pkg/ethutil/transaction.go b/pkg/ethutil/transaction.go index 26c793d35..eadc28897 100644 --- a/pkg/ethutil/transaction.go +++ b/pkg/ethutil/transaction.go @@ -16,24 +16,46 @@ import ( "github.com/ethereum/go-ethereum/ethclient" ) +type TransactOptsFactory interface { + From() common.Address + NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) +} + +type staticTransactOptsFactory struct { + opts *bind.TransactOpts +} + +func (f *staticTransactOptsFactory) From() common.Address { + return f.opts.From +} + +func (f *staticTransactOptsFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts := *f.opts + opts.Context = ctx + return &opts, nil +} + +func NewStaticTransactOptsFactory(txOpts *bind.TransactOpts) TransactOptsFactory { + return &staticTransactOptsFactory{opts: txOpts} +} + const PollInterval = 500 * time.Millisecond // Prepare the transaction, send it, and wait for the receipt. func sendTransaction( ctx context.Context, client *ethclient.Client, - txOpts *bind.TransactOpts, + txOptsFactory TransactOptsFactory, txValue *big.Int, - gasLimit uint64, doSend func(txOpts *bind.TransactOpts) (*types.Transaction, error), ) (*types.Receipt, error) { - txOpts, err := _prepareTransaction(ctx, client, txOpts, txValue, gasLimit) + txOpts, err := _prepareTransaction(ctx, client, txOptsFactory, txValue) if err != nil { - return nil, fmt.Errorf("failed to prepare transaction: %v", err) + return nil, fmt.Errorf("failed to prepare transaction: %w", err) } tx, err := doSend(txOpts) if err != nil { - return nil, fmt.Errorf("failed to send transaction: %v", err) + return nil, fmt.Errorf("failed to send transaction: %w", err) } receipt, err := _waitForTransaction(ctx, client, tx) if err != nil { @@ -46,23 +68,26 @@ func sendTransaction( func _prepareTransaction( ctx context.Context, client *ethclient.Client, - txOpts *bind.TransactOpts, + txOptsFactory TransactOptsFactory, txValue *big.Int, - gasLimit uint64, ) (*bind.TransactOpts, error) { - nonce, err := client.PendingNonceAt(ctx, txOpts.From) + nonce, err := client.PendingNonceAt(ctx, txOptsFactory.From()) if err != nil { - return nil, fmt.Errorf("failed to get nonce: %v", err) + return nil, fmt.Errorf("failed to get nonce: %w", err) } gasPrice, err := client.SuggestGasPrice(ctx) if err != nil { - return nil, fmt.Errorf("failed to get gas price: %v", err) + return nil, fmt.Errorf("failed to get gas price: %w", err) } nonceBigInt := &big.Int{} nonceBigInt.SetUint64(nonce) + + txOpts, err := txOptsFactory.NewTransactOpts(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get transaction options: %w", err) + } txOpts.Nonce = nonceBigInt txOpts.Value = txValue - txOpts.GasLimit = gasLimit txOpts.GasPrice = gasPrice return txOpts, nil } @@ -76,7 +101,7 @@ func _waitForTransaction( for { _, isPending, err := client.TransactionByHash(ctx, tx.Hash()) if err != nil { - return nil, fmt.Errorf("fail to recover transaction: %v", err) + return nil, fmt.Errorf("fail to recover transaction: %w", err) } if !isPending { break @@ -90,12 +115,12 @@ func _waitForTransaction( } receipt, err := client.TransactionReceipt(ctx, tx.Hash()) if err != nil { - return nil, fmt.Errorf("failed to get receipt: %v", err) + return nil, fmt.Errorf("failed to get receipt: %w", err) } if receipt.Status == types.ReceiptStatusFailed { reason, err := _traceTransaction(client, tx.Hash()) if err != nil { - return nil, fmt.Errorf("transaction failed; failed to get reason: %v", err) + return nil, fmt.Errorf("transaction failed; failed to get reason: %w", err) } return nil, fmt.Errorf("transaction failed: %v", reason) }