From 7a3951d90f5ede932555b206e14901d2779ff956 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:22:54 -0300 Subject: [PATCH 01/18] fix(kms): make the AWS KMS signer support dynamic-fee transactions --- Makefile | 3 +- internal/config/auth/auth.go | 28 ++- internal/config/generate/Config.toml | 9 + internal/config/generated.go | 16 ++ internal/kms/signtx_test.go | 111 ++++-------- test/compose/compose.integration.yaml | 20 +++ .../localstack_integration_test.go | 162 ++++++++++++++++++ 7 files changed, 262 insertions(+), 87 deletions(-) create mode 100644 test/integration/localstack_integration_test.go diff --git a/Makefile b/Makefile index 936749638..b47816841 100644 --- a/Makefile +++ b/Makefile @@ -571,7 +571,7 @@ check-license: ## Verify license headers on Go source files # Discovery (integration-test-shard-check) lists tests with a plain Go # toolchain, so the integration package must stay free of the Cartesi CGo # dependency for the check to build on the CI setup runner. -INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal +INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal awskms INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|MultiApp|EchoAuthorityStaging)$$ INTEGRATION_SHARD_quorum := ^Test(EchoQuorum|SameBlockInputs)$$ @@ -579,6 +579,7 @@ INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt)$$ INTEGRATION_SHARD_replay := ^Test(Foreclose|ForecloseReplay|DivergentClaim)$$ INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy)$$ INTEGRATION_SHARD_withdrawal := ^TestWithdrawalLifecycle$$ +INTEGRATION_SHARD_awskms := ^TestLocalStackAWSIntegration$$ # ----------------------------------------------------------------------------- # Node topology axis — orthogonal to shards. diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index 408f7b13a..d58f86810 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -5,6 +5,7 @@ package auth import ( "context" + "errors" "fmt" "math/big" @@ -60,20 +61,35 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran } return ethutil.NewStaticTransactOptsFactory(txOpts), nil case AuthKindAWS: - awsc, err := aws_cfg.LoadDefaultConfig(ctx) + keyId, err := GetAuthAwsKmsKeyId() if err != nil { return nil, err } - kmsConfig := aws_kms.NewFromConfig(awsc) - authAwsKmsKeyId, err := GetAuthAwsKmsKeyId() + awsOpts := make([]func (*aws_cfg.LoadOptions) error, 0, 2) + kmsRegion, err := GetAuthAwsKmsRegion() + if !errors.Is(err, ErrNotDefined) { + if err != nil { + return nil, err + } + awsOpts = append(awsOpts, aws_cfg.WithRegion(kmsRegion.Value)) + } + kmsEndpoint, err := GetAuthAwsKmsEndpoint() + if !errors.Is(err, ErrNotDefined) { + if err != nil { + return nil, err + } + awsOpts = append(awsOpts, aws_cfg.WithBaseEndpoint(kmsEndpoint.Value)) + } + awsCfg, err := aws_cfg.LoadDefaultConfig(ctx, awsOpts...) if err != nil { return nil, err } + kmsClient := aws_kms.NewFromConfig(awsCfg) return signtx.CreateAWSTransactOptsFactory( ctx, - kmsConfig, - aws.String(authAwsKmsKeyId.Value), - types.NewEIP155Signer(chainId), + kmsClient, + aws.String(keyId.Value), + types.LatestSignerForChainID(chainId), ) default: return nil, fmt.Errorf("no valid authentication method found") diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index c5d298a78..39d787b06 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -375,6 +375,15 @@ Must be set alongside `CARTESI_AUTH_AWS_KMS_KEY_ID`.""" omit = true used-by = ["claimer", "node", "cli", "prt"] +[auth.CARTESI_AUTH_AWS_KMS_ENDPOINT] +go-type = "RedactedString" +description = """ +An AWS KMS Endpoint. + +When not provided, the default endpoint for the AWS region defined by `CARTESI_AUTH_AWS_KMS_REGION` is automatically used.""" +omit = true +used-by = ["claimer", "node", "cli", "prt"] + # # Database # diff --git a/internal/config/generated.go b/internal/config/generated.go index cab15bdc8..929b12818 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -22,6 +22,7 @@ func init() { } const ( + AUTH_AWS_KMS_ENDPOINT = "CARTESI_AUTH_AWS_KMS_ENDPOINT" AUTH_AWS_KMS_KEY_ID = "CARTESI_AUTH_AWS_KMS_KEY_ID" AUTH_AWS_KMS_REGION = "CARTESI_AUTH_AWS_KMS_REGION" AUTH_KIND = "CARTESI_AUTH_KIND" @@ -100,6 +101,8 @@ const ( func SetDefaults() { // Set defaults based on the TOML definitions. + // no default for CARTESI_AUTH_AWS_KMS_ENDPOINT + // no default for CARTESI_AUTH_AWS_KMS_KEY_ID // no default for CARTESI_AUTH_AWS_KMS_REGION @@ -1686,6 +1689,19 @@ func (c *NodeConfig) ToValidatorConfig() *ValidatorConfig { } } +// GetAuthAwsKmsEndpoint returns the value for the environment variable CARTESI_AUTH_AWS_KMS_ENDPOINT. +func GetAuthAwsKmsEndpoint() (RedactedString, error) { + s := viper.GetString(AUTH_AWS_KMS_ENDPOINT) + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", AUTH_AWS_KMS_ENDPOINT, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_AWS_KMS_ENDPOINT, ErrNotDefined) +} + // GetAuthAwsKmsKeyId returns the value for the environment variable CARTESI_AUTH_AWS_KMS_KEY_ID. func GetAuthAwsKmsKeyId() (RedactedString, error) { s := viper.GetString(AUTH_AWS_KMS_KEY_ID) diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index a4d7d422d..d26975fb0 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -11,95 +11,15 @@ import ( "math/big" "testing" - "github.com/cartesi/rollups-node/pkg/ethutil" - "github.com/ethereum/go-ethereum/common" 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(_ context.Context, tx *ethtypes.Transaction, s ethtypes.Signer) (*ethtypes.Transaction, error) { - return ethtypes.SignTx(tx, s, privateKey) - } -} - -func sendFunds( - value *big.Int, - SignTx SignTxFn, - ctx context.Context, - sender common.Address, - recipient common.Address, -) { - client, err := ethclient.Dial("http://127.0.0.1:8545") // anvil - if err != nil { - panic(err) - } - - nonce, err := client.PendingNonceAt(context.Background(), sender) - if err != nil { - panic(err) - } - gasLimit := uint64(21000) - gasPrice, err := client.SuggestGasPrice(ctx) - if err != nil { - panic(err) - } - var data []byte - tx := ethtypes.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) - chainID, err := client.NetworkID(context.Background()) - if err != nil { - panic(err) - } - signedTx, err := SignTx(ctx, tx, ethtypes.NewEIP155Signer(chainID)) - if err != nil { - panic(err) - } - err = client.SendTransaction(context.Background(), signedTx) - if err != nil { - panic(err) - } -} - -func TestSignTx(t *testing.T) { - if len(ARN) == 0 { - t.Skip("Skipping test, ARN for KMS key is unset") - } - value20 := big.NewInt(2000000000000000000) // in wei (2 eth) - value10 := big.NewInt(1000000000000000000) // in wei (1 eth) - - anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) - if err != nil { - panic(err) - } - anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) - anvilAddress := crypto.PubkeyToAddress(*anvilPublicKey) - - config, err := awscfg.LoadDefaultConfig(context.Background()) - if err != nil { - panic(err) - } - kms := awskms.NewFromConfig(config) - SignTx, _, KMSAddress, err := CreateAWSSignTxFn(context.Background(), kms, &ARN) - if err != nil { - panic(err) - } - - sendFunds(value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), - context.Background(), anvilAddress, KMSAddress) - sendFunds(value10, SignTx, - context.Background(), KMSAddress, anvilAddress) -} - func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { privateKey, err := crypto.GenerateKey() require.NoError(t, err) @@ -128,6 +48,37 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { require.NoError(t, client.signContext.Err()) } +func TestAWSTransactOptsFactorySignsDynamicFeeTransaction(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + chainID := big.NewInt(31337) + client := newFakeKMSClient(t, privateKey) + keyID := "alias/test-key" + factory, err := CreateAWSTransactOptsFactory( + context.Background(), client, &keyID, ethtypes.LatestSignerForChainID(chainID), + ) + require.NoError(t, err) + + opts, err := factory.NewTransactOpts(context.Background()) + require.NoError(t, err) + tx := ethtypes.NewTx(ðtypes.DynamicFeeTx{ + ChainID: chainID, + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(2), + Gas: 21000, + To: &common.Address{0x01}, + Value: big.NewInt(3), + }) + signed, err := opts.Signer(opts.From, tx) + require.NoError(t, err) + + sender, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), sender) +} + type fakeKMSClient struct { t *testing.T privateKey *ecdsa.PrivateKey diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 3d305f338..c74340642 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -83,6 +83,18 @@ services: <<: *env restart: "no" + localstack: + image: localstack/localstack:4.14.0 + networks: + - devnet + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:4566/_localstack/health"] + interval: 2s + timeout: 2s + retries: 30 + environment: + SERVICES: kms + # The node is started and managed by TestMain inside the test process. # This ensures all tests (including restart and snapshot policy tests) # run with the same infrastructure in both local and CI environments. @@ -99,6 +111,8 @@ services: condition: service_healthy dapp-builder: condition: service_completed_successfully + localstack: + condition: service_healthy volumes: - dapp_images:/var/lib/cartesi-rollups-node/dapps:ro - node_logs:/var/lib/cartesi-rollups-node/logs @@ -125,6 +139,12 @@ services: CARTESI_TEST_ERC20_WITHDRAWAL_DAPP_PATH: /var/lib/cartesi-rollups-node/dapps/erc20-withdrawal-dapp CARTESI_TEST_NODE_LOG_FILE: /var/lib/cartesi-rollups-node/logs/node.log CARTESI_INSPECT_URL: http://localhost:10012/ + # test/integration/localstack_integration_test.go + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_REGION: us-east-1 + LOCALSTACK_KMS_ENDPOINT: http://localstack:4566 + LOCALSTACK_KMS_REQUIRED: "true" volumes: dapp_images: diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go new file mode 100644 index 000000000..4bacaf7e0 --- /dev/null +++ b/test/integration/localstack_integration_test.go @@ -0,0 +1,162 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "crypto/ecdsa" + "math/big" + "os" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/aws/aws-sdk-go-v2/aws" + 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/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/spf13/viper" + "github.com/stretchr/testify/suite" +) + +type AwsKmsIntegrationSuite struct { + suite.Suite + chainID *big.Int + kmsClient *awskms.Client + kmsRegisteredKey *awskms.CreateKeyOutput + txOpts *bind.TransactOpts +} + +func (s *AwsKmsIntegrationSuite) SetupSuite() { + t := s.T() + ctx := t.Context() + + const region = "us-east-1" + endpoint := os.Getenv("LOCALSTACK_KMS_ENDPOINT") + if endpoint == "" { + t.Skip("LOCALSTACK_KMS_ENDPOINT is not set; skipping LocalStack KMS integration test") + } + cfg, err := awscfg.LoadDefaultConfig(ctx, + awscfg.WithRegion(region), + awscfg.WithBaseEndpoint(endpoint), + ) + s.Require().NoError(err) + client := awskms.NewFromConfig(cfg) + + created, err := client.CreateKey(ctx, &awskms.CreateKeyInput{ + KeyUsage: kmstypes.KeyUsageTypeSignVerify, + KeySpec: kmstypes.KeySpecEccSecgP256k1, + }) + if err != nil { + message := "unable to create key on LocalStack" + if os.Getenv("LOCALSTACK_KMS_REQUIRED") == "true" { + t.Fatalf("%s: %v", message, err) + } + t.Skipf("%s: %v", message, err) + } + s.Require().NotNil(created.KeyMetadata) + s.Require().NotNil(created.KeyMetadata.KeyId) + + viper.Set(config.AUTH_KIND, "aws") + viper.Set(config.AUTH_AWS_KMS_KEY_ID, *created.KeyMetadata.KeyId) + viper.Set(config.AUTH_AWS_KMS_REGION, region) + viper.Set(config.AUTH_AWS_KMS_ENDPOINT, endpoint) + + s.chainID = big.NewInt(31337) + factory, err := auth.GetTransactOptsFactory(ctx, s.chainID) + s.Require().NoError(err) + s.Require().NotEqual(common.Address{}, factory.From()) + opts, err := factory.NewTransactOpts(ctx) + s.Require().NoError(err) + + s.kmsClient = client + s.kmsRegisteredKey = created + s.txOpts = opts +} + +func (s *AwsKmsIntegrationSuite) TearDownSuite() { + _, _ = s.kmsClient.ScheduleKeyDeletion(s.T().Context(), &awskms.ScheduleKeyDeletionInput{ + KeyId: s.kmsRegisteredKey.KeyMetadata.KeyId, + PendingWindowInDays: aws.Int32(1), //nolint:mnd + }) + viper.Set(config.AUTH_KIND, nil) + viper.Set(config.AUTH_AWS_KMS_KEY_ID, nil) + viper.Set(config.AUTH_AWS_KMS_REGION, nil) + viper.Set(config.AUTH_AWS_KMS_ENDPOINT, nil) +} + +func (s *AwsKmsIntegrationSuite) sendFunds( + value *big.Int, + signTx bind.SignerFn, + sender common.Address, + recipient common.Address, +) { + ctx := s.T().Context() + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + s.Require().NoError(err) + client, err := ethclient.Dial(ethEndpoint.Raw()) // anvil + s.Require().NoError(err) + + nonce, err := client.PendingNonceAt(ctx, sender) + s.Require().NoError(err) + gasLimit := uint64(21000) + gasPrice, err := client.SuggestGasPrice(ctx) + s.Require().NoError(err) + var data []byte + tx := types.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + signedTx, err := signTx(sender, tx) + s.Require().NoError(err) + err = client.SendTransaction(ctx, signedTx) + s.Require().NoError(err) +} + +func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { + anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + s.Require().NoError(err) + + anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) + anvilAddress := crypto.PubkeyToAddress(*anvilPublicKey) + anvilSignTx := func(address common.Address, tx *types.Transaction) (*types.Transaction, error) { + return types.SignTx(tx, types.LatestSignerForChainID(s.chainID), anvilPrivateKey) + } + value20 := big.NewInt(2000000000000000000) // in wei (2 eth) + value10 := big.NewInt(1000000000000000000) // in wei (1 eth) + s.sendFunds(value20, anvilSignTx, anvilAddress, s.txOpts.From) + s.sendFunds(value10, s.txOpts.Signer, s.txOpts.From, anvilAddress) +} + +func (s *AwsKmsIntegrationSuite) TestLocalStackAWSTransactionOptsFactory() { + to := common.Address{0x01} + tests := map[string]*types.Transaction{ + "legacy": types.NewTx(&types.LegacyTx{ + Nonce: 1, GasPrice: big.NewInt(2), Gas: 21000, To: &to, Value: big.NewInt(3), + }), + "dynamic fee": types.NewTx(&types.DynamicFeeTx{ + ChainID: s.chainID, Nonce: 2, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(2), + Gas: 21000, To: &to, Value: big.NewInt(3), + }), + } + for name, tx := range tests { + s.T().Run(name, func(*testing.T) { + signed, err := s.txOpts.Signer(s.txOpts.From, tx) + s.Require().NoError(err) + sender, err := types.Sender(types.LatestSignerForChainID(s.chainID), signed) + s.Require().NoError(err) + s.Require().Equal(s.txOpts.From, sender) + }) + } +} + +func TestLocalStackAWSIntegration(t *testing.T) { + suite.Run(t, new(AwsKmsIntegrationSuite)) +} From 5b17f6d1c7e2350013b5bab1515bbca2e57613f8 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:23:00 -0300 Subject: [PATCH 02/18] test(kms): add unit test for AWS KMS authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exercises the real 'GetTransactOptsFactory' AWS wiring. - Uses an httptest KMS implementation—no Docker or AWS access. - Tests dynamic-fee and legacy transactions. - Verifies the recovered sender matches the KMS key. - Uses isolated dummy AWS credentials and resets Viper state. --- internal/config/auth/auth_test.go | 155 ++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 internal/config/auth/auth_test.go diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go new file mode 100644 index 000000000..01aa5b9ff --- /dev/null +++ b/internal/config/auth/auth_test.go @@ -0,0 +1,155 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package auth + +import ( + "crypto/ecdsa" + "crypto/rand" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + . "github.com/cartesi/rollups-node/internal/config" +) + +func TestGetTransactOptsFactoryAWSSignsDynamicFeeTransaction(t *testing.T) { + server := newFakeKMSServer(t) + t.Cleanup(server.Close) + setupAWSAuth(t, server.URL) + + chainID := big.NewInt(31337) + factory, err := GetTransactOptsFactory(t.Context(), chainID) + require.NoError(t, err) + opts, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + + to := common.Address{0x01} + tests := []struct { + name string + tx *types.Transaction + }{ + { + name: "dynamic fee", + tx: types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(2), + Gas: 21000, + To: &to, + Value: big.NewInt(3), + }), + }, + { + name: "legacy", + tx: types.NewTx(&types.LegacyTx{ + Nonce: 2, + GasPrice: big.NewInt(1), + Gas: 21000, + To: &to, + Value: big.NewInt(3), + }), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + signed, err := opts.Signer(opts.From, test.tx) + require.NoError(t, err) + sender, err := types.Sender(types.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + require.Equal(t, opts.From, sender) + }) + } +} + +func setupAWSAuth(t *testing.T, endpoint string) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(AUTH_KIND, "aws") + viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key") + viper.Set(AUTH_AWS_KMS_REGION, "us-east-1") + viper.Set(AUTH_AWS_KMS_ENDPOINT, endpoint) + + // Static dummy credentials keep the AWS SDK hermetic: it never consults + // shared config files, credential services, or EC2 instance metadata. + t.Setenv("AWS_ACCESS_KEY_ID", "test") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") +} + +func newFakeKMSServer(t *testing.T) *httptest.Server { + t.Helper() + + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + 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 httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + switch r.Header.Get("X-Amz-Target") { + case "TrentService.GetPublicKey": + writeKMSJSON(t, w, map[string]any{ + "KeyId": "alias/test-key", + "KeySpec": "ECC_SECG_P256K1", + "KeyUsage": "SIGN_VERIFY", + "PublicKey": base64.StdEncoding.EncodeToString(publicKey), + }) + case "TrentService.Sign": + var input struct { + Message string `json:"Message"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&input)) + digest, err := base64.StdEncoding.DecodeString(input.Message) + require.NoError(t, err) + r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest) + require.NoError(t, err) + signature, err := asn1.Marshal(struct { + R *big.Int + S *big.Int + }{R: r, S: s}) + require.NoError(t, err) + writeKMSJSON(t, w, map[string]any{ + "KeyId": "alias/test-key", + "Signature": base64.StdEncoding.EncodeToString(signature), + "SigningAlgorithm": "ECDSA_SHA_256", + }) + default: + http.Error(w, "unexpected KMS operation", http.StatusBadRequest) + } + })) +} + +func writeKMSJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + require.NoError(t, json.NewEncoder(w).Encode(value)) +} From 7e388a3d5ac8d35535ba4b4207cd53fa65b76c46 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:35:14 -0300 Subject: [PATCH 03/18] test(integration): add make target to run AWS LocalStack --- Makefile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b47816841..ec80bb8a0 100644 --- a/Makefile +++ b/Makefile @@ -516,6 +516,15 @@ start-postgres: ## Run the PostgreSQL 16 docker container @docker run --rm --name postgres -p 5432:5432 -d -e POSTGRES_PASSWORD=password -e POSTGRES_DB=rollupsdb -v $(CURDIR)/test/postgres/init-test-db.sh:/docker-entrypoint-initdb.d/init-test-db.sh postgres:18-alpine @$(MAKE) migrate +start-awslocalstack: ## Run the AWS LocalStack docker container + @echo "Starting AWS localstack" + @docker run --rm --name awslocalstack -p 127.0.0.1:4566:4566 -d -e SERVICES=kms localstack/localstack:4.14.0 + @echo "Add the following variables to run integration test with AWS services:" + @echo " export AWS_ACCESS_KEY_ID=test" + @echo " export AWS_SECRET_ACCESS_KEY=test" + @echo " export LOCALSTACK_KMS_ENDPOINT=http://localhost:4566" + @echo " export LOCALSTACK_KMS_REQUIRED=true" + start: start-postgres start-devnet ## Start the anvil devnet and PostgreSQL 16 docker containers stop-devnet: ## Stop the anvil devnet docker container @@ -524,7 +533,10 @@ stop-devnet: ## Stop the anvil devnet docker container stop-postgres: ## Stop the PostgreSQL 16 docker container @docker stop postgres || true -stop: stop-devnet stop-postgres ## Stop all running docker containers +stop-awslocalstack: ## Stop the AWS LocalStack docker container + @docker stop awslocalstack || true + +stop: stop-devnet stop-postgres ## Stop the anvil devnet and PostgreSQL 16 docker containers restart-devnet: ## Restart the anvil devnet docker container @$(MAKE) stop-devnet From ad56d200168dea8fb68d6c3afcd710b9e54a7d6f Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:48:47 -0300 Subject: [PATCH 04/18] test(integration): fix AWS KMS subtest execution - Replaced nondeterministic map iteration with an ordered test-case slice. - Replaced s.T().Run with s.Run, ensuring suite assertions target the correct subtest. - Legacy and dynamic-fee cases now run predictably and report failures independently. --- .../localstack_integration_test.go | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 4bacaf7e0..7bfe16915 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -137,18 +137,27 @@ func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { func (s *AwsKmsIntegrationSuite) TestLocalStackAWSTransactionOptsFactory() { to := common.Address{0x01} - tests := map[string]*types.Transaction{ - "legacy": types.NewTx(&types.LegacyTx{ - Nonce: 1, GasPrice: big.NewInt(2), Gas: 21000, To: &to, Value: big.NewInt(3), - }), - "dynamic fee": types.NewTx(&types.DynamicFeeTx{ - ChainID: s.chainID, Nonce: 2, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(2), - Gas: 21000, To: &to, Value: big.NewInt(3), - }), + tests := []struct { + name string + tx *types.Transaction + }{ + { + name: "legacy", + tx: types.NewTx(&types.LegacyTx{ + Nonce: 1, GasPrice: big.NewInt(2), Gas: 21000, To: &to, Value: big.NewInt(3), + }), + }, + { + name: "dynamic fee", + tx: types.NewTx(&types.DynamicFeeTx{ + ChainID: s.chainID, Nonce: 2, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(2), + Gas: 21000, To: &to, Value: big.NewInt(3), + }), + }, } - for name, tx := range tests { - s.T().Run(name, func(*testing.T) { - signed, err := s.txOpts.Signer(s.txOpts.From, tx) + for _, test := range tests { + s.Run(test.name, func() { + signed, err := s.txOpts.Signer(s.txOpts.From, test.tx) s.Require().NoError(err) sender, err := types.Sender(types.LatestSignerForChainID(s.chainID), signed) s.Require().NoError(err) From 4b17c3d473a4ca01b070cad430c90a711ae23eff Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:07:38 -0300 Subject: [PATCH 05/18] test(integration): use new style for Ethereum transactions for better test coverage - Funding transfers now use EIP-1559 `DynamicFeeTx`. - Gas tip and fee caps are derived from the current chain state. - `sendFunds` waits for transaction mining before returning. - Receipt success is asserted, removing the funding race. - Ethereum clients are closed after use. --- .../localstack_integration_test.go | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 7bfe16915..9e79c5c5f 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -106,18 +106,36 @@ func (s *AwsKmsIntegrationSuite) sendFunds( s.Require().NoError(err) client, err := ethclient.Dial(ethEndpoint.Raw()) // anvil s.Require().NoError(err) + defer client.Close() nonce, err := client.PendingNonceAt(ctx, sender) s.Require().NoError(err) gasLimit := uint64(21000) - gasPrice, err := client.SuggestGasPrice(ctx) + gasTipCap, err := client.SuggestGasTipCap(ctx) s.Require().NoError(err) - var data []byte - tx := types.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + header, err := client.HeaderByNumber(ctx, nil) + s.Require().NoError(err) + s.Require().NotNil(header.BaseFee) + gasFeeCap := new(big.Int).Add( + new(big.Int).Mul(header.BaseFee, big.NewInt(2)), //nolint:mnd // EIP-1559 base-fee headroom. + gasTipCap, + ) + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: s.chainID, + Nonce: nonce, + GasTipCap: gasTipCap, + GasFeeCap: gasFeeCap, + Gas: gasLimit, + To: &recipient, + Value: value, + }) signedTx, err := signTx(sender, tx) s.Require().NoError(err) err = client.SendTransaction(ctx, signedTx) s.Require().NoError(err) + receipt, err := bind.WaitMined(ctx, client, signedTx.Hash()) + s.Require().NoError(err) + s.Require().Equal(types.ReceiptStatusSuccessful, receipt.Status) } func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { From 4445d6c0738443731e3e5cde192207dd0866092c Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:26:58 -0300 Subject: [PATCH 06/18] test(integration): only run AWS Local Stack for required tests - LocalStack is now behind the awskms Compose profile. - Its dependency is optional when that profile is inactive. - Compose runners activate the profile only when the selected shards include awskms. - The awskms shard is excluded from the multiprocess topology. --- Makefile | 7 ++++++- test/compose/compose.integration.yaml | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ec80bb8a0..401f5ecc1 100644 --- a/Makefile +++ b/Makefile @@ -613,7 +613,7 @@ INTEGRATION_TOPOLOGIES := standalone multiprocess NODE_TOPOLOGY ?= standalone INTEGRATION_SHARDS_standalone := $(INTEGRATION_SHARDS) -INTEGRATION_SHARDS_multiprocess := $(INTEGRATION_SHARDS) +INTEGRATION_SHARDS_multiprocess := $(filter-out awskms,$(INTEGRATION_SHARDS)) # The CI matrix is the set of (shard, topology) cells, encoded "shard:topology". INTEGRATION_CELLS := $(foreach t,$(INTEGRATION_TOPOLOGIES),$(foreach s,$(INTEGRATION_SHARDS_$(t)),$(s):$(t))) @@ -637,6 +637,9 @@ TOPOLOGIES_SELECTED = $(if $(filter all,$(NODE_TOPOLOGY)),$(INTEGRATION_TOPOLOGI shards_for = $(filter $(if $(strip $(SHARD)),$(SHARD),$(INTEGRATION_SHARDS_$(1))),$(INTEGRATION_SHARDS_$(1))) # run_pattern(topology): the selected shards' -run regexes as one alternation. run_pattern = $(subst $(space),|,$(strip $(foreach s,$(call shards_for,$(1)),$(INTEGRATION_SHARD_$(s))))) +# compose_profiles(topology): activate optional infrastructure required by the +# selected shards for this topology. +compose_profiles = $(if $(filter awskms,$(call shards_for,$(1))),awskms,) # Selected (shard:topology) cells, for PARALLEL fan-out. SELECTED_CELLS = $(foreach t,$(TOPOLOGIES_SELECTED),$(foreach s,$(call shards_for,$(t)),$(s):$(t))) # Label for project/log names: the SHARD filter joined by '-', or "all". @@ -684,6 +687,7 @@ _compose-topology-%: COMPOSE_PROJECT='$(if $(filter rollups-node-integration,$(COMPOSE_PROJECT)),rollups-node-integration-$(SUITE_LABEL)-$*,$(COMPOSE_PROJECT))' \ INTEGRATION_LOGS='integration-logs-$(SUITE_LABEL)-$*.txt' \ TEST_PATTERN="$$pattern" SHARD_NAME='$(SUITE_LABEL)-$*' NODE_TOPOLOGY='$*' \ + COMPOSE_PROFILES='$(call compose_profiles,$*)' \ GOTESTSUM_FORMAT='$(COMPOSE_TOPOLOGY_GOTESTSUM_FORMAT)' \ scripts/compose-integration-run.sh @@ -694,6 +698,7 @@ _compose-cell-%: TEST_PATTERN='$(INTEGRATION_SHARD_$(firstword $(subst :, ,$*)))' \ SHARD_NAME='$(firstword $(subst :, ,$*))' \ NODE_TOPOLOGY='$(lastword $(subst :, ,$*))' \ + COMPOSE_PROFILES='$(if $(filter awskms,$(firstword $(subst :, ,$*))),awskms,)' \ GOTESTSUM_FORMAT='$(GOTESTSUM_FORMAT)' \ scripts/compose-integration-run.sh diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index c74340642..32e1ae532 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -85,6 +85,7 @@ services: localstack: image: localstack/localstack:4.14.0 + profiles: [awskms] networks: - devnet healthcheck: @@ -113,6 +114,7 @@ services: condition: service_completed_successfully localstack: condition: service_healthy + required: false volumes: - dapp_images:/var/lib/cartesi-rollups-node/dapps:ro - node_logs:/var/lib/cartesi-rollups-node/logs From e5bcf393fcee251bc58bf7ce6eeba49e7d040469 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:53:34 -0300 Subject: [PATCH 07/18] test(integration): fix clean up of configurations used in tests - Replaced `TearDownSuite` cleanup with eagerly registered `t.Cleanup` callbacks. - KMS key deletion now uses a fresh background context with a 10-second timeout. - Viper is fully reset and configuration defaults are restored. - Removed suite fields that existed only for deferred teardown. --- .../localstack_integration_test.go | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 9e79c5c5f..5523b6e36 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -6,10 +6,12 @@ package integration import ( + "context" "crypto/ecdsa" "math/big" "os" "testing" + "time" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" @@ -30,10 +32,8 @@ import ( type AwsKmsIntegrationSuite struct { suite.Suite - chainID *big.Int - kmsClient *awskms.Client - kmsRegisteredKey *awskms.CreateKeyOutput - txOpts *bind.TransactOpts + chainID *big.Int + txOpts *bind.TransactOpts } func (s *AwsKmsIntegrationSuite) SetupSuite() { @@ -65,7 +65,19 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { } s.Require().NotNil(created.KeyMetadata) s.Require().NotNil(created.KeyMetadata.KeyId) + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = client.ScheduleKeyDeletion(cleanupCtx, &awskms.ScheduleKeyDeletionInput{ + KeyId: created.KeyMetadata.KeyId, + PendingWindowInDays: aws.Int32(1), + }) + }) + t.Cleanup(func() { + viper.Reset() + config.SetDefaults() + }) viper.Set(config.AUTH_KIND, "aws") viper.Set(config.AUTH_AWS_KMS_KEY_ID, *created.KeyMetadata.KeyId) viper.Set(config.AUTH_AWS_KMS_REGION, region) @@ -78,22 +90,9 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { opts, err := factory.NewTransactOpts(ctx) s.Require().NoError(err) - s.kmsClient = client - s.kmsRegisteredKey = created s.txOpts = opts } -func (s *AwsKmsIntegrationSuite) TearDownSuite() { - _, _ = s.kmsClient.ScheduleKeyDeletion(s.T().Context(), &awskms.ScheduleKeyDeletionInput{ - KeyId: s.kmsRegisteredKey.KeyMetadata.KeyId, - PendingWindowInDays: aws.Int32(1), //nolint:mnd - }) - viper.Set(config.AUTH_KIND, nil) - viper.Set(config.AUTH_AWS_KMS_KEY_ID, nil) - viper.Set(config.AUTH_AWS_KMS_REGION, nil) - viper.Set(config.AUTH_AWS_KMS_ENDPOINT, nil) -} - func (s *AwsKmsIntegrationSuite) sendFunds( value *big.Int, signTx bind.SignerFn, From d6579d79db9d930269a404006e46fe8c3542804f Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:02:36 -0300 Subject: [PATCH 08/18] test(integration): isolate AWS KMS funding account Use a dedicated Foundry account to avoid nonce collisions with the node submitter and other integration-test actors. --- test/integration/localstack_integration_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 5523b6e36..594e04290 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -138,7 +138,10 @@ func (s *AwsKmsIntegrationSuite) sendFunds( } func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { - anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + // Keep funding transactions isolated from the node submitter (index 0) and + // the guardian/quorum accounts used by the other integration suites. + const fundingAccountIndex uint32 = 9 + anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, fundingAccountIndex) s.Require().NoError(err) anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) From c302b1859fe046256891b514e6ebf2daa9ca8319 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:11:15 -0300 Subject: [PATCH 09/18] test(integration): allow built-in readness check of AWS LocalStack image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed the custom gateway-only LocalStack healthcheck, allowing the image’s built-in per-service readiness check and start period to apply. --- test/compose/compose.integration.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 32e1ae532..20e846e03 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -88,11 +88,6 @@ services: profiles: [awskms] networks: - devnet - healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:4566/_localstack/health"] - interval: 2s - timeout: 2s - retries: 30 environment: SERVICES: kms From cd3705eeeab3cdecfa3d772a9678ce7e2676b01d Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:43:06 -0300 Subject: [PATCH 10/18] test(integration): clean up AWS KMS test resources - Hoisted the Ethereum client into SetupSuite and registered cleanup once. - Reused the client for both funding transfers. - Read AWS_REGION from the environment, defaulting to us-east-1. - Enforced the bind.SignerFn address contract with bind.ErrNotAuthorized. --- .../localstack_integration_test.go | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 594e04290..70a048db0 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -32,15 +32,19 @@ import ( type AwsKmsIntegrationSuite struct { suite.Suite - chainID *big.Int - txOpts *bind.TransactOpts + chainID *big.Int + ethClient *ethclient.Client + txOpts *bind.TransactOpts } func (s *AwsKmsIntegrationSuite) SetupSuite() { t := s.T() ctx := t.Context() - const region = "us-east-1" + region := os.Getenv("AWS_REGION") + if region == "" { + region = "us-east-1" + } endpoint := os.Getenv("LOCALSTACK_KMS_ENDPOINT") if endpoint == "" { t.Skip("LOCALSTACK_KMS_ENDPOINT is not set; skipping LocalStack KMS integration test") @@ -78,6 +82,12 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { viper.Reset() config.SetDefaults() }) + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + s.Require().NoError(err) + ethClient, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + s.Require().NoError(err) + t.Cleanup(ethClient.Close) + viper.Set(config.AUTH_KIND, "aws") viper.Set(config.AUTH_AWS_KMS_KEY_ID, *created.KeyMetadata.KeyId) viper.Set(config.AUTH_AWS_KMS_REGION, region) @@ -90,6 +100,7 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { opts, err := factory.NewTransactOpts(ctx) s.Require().NoError(err) + s.ethClient = ethClient s.txOpts = opts } @@ -101,18 +112,12 @@ func (s *AwsKmsIntegrationSuite) sendFunds( ) { ctx := s.T().Context() - ethEndpoint, err := config.GetBlockchainHttpEndpoint() - s.Require().NoError(err) - client, err := ethclient.Dial(ethEndpoint.Raw()) // anvil - s.Require().NoError(err) - defer client.Close() - - nonce, err := client.PendingNonceAt(ctx, sender) + nonce, err := s.ethClient.PendingNonceAt(ctx, sender) s.Require().NoError(err) gasLimit := uint64(21000) - gasTipCap, err := client.SuggestGasTipCap(ctx) + gasTipCap, err := s.ethClient.SuggestGasTipCap(ctx) s.Require().NoError(err) - header, err := client.HeaderByNumber(ctx, nil) + header, err := s.ethClient.HeaderByNumber(ctx, nil) s.Require().NoError(err) s.Require().NotNil(header.BaseFee) gasFeeCap := new(big.Int).Add( @@ -130,9 +135,9 @@ func (s *AwsKmsIntegrationSuite) sendFunds( }) signedTx, err := signTx(sender, tx) s.Require().NoError(err) - err = client.SendTransaction(ctx, signedTx) + err = s.ethClient.SendTransaction(ctx, signedTx) s.Require().NoError(err) - receipt, err := bind.WaitMined(ctx, client, signedTx.Hash()) + receipt, err := bind.WaitMined(ctx, s.ethClient, signedTx.Hash()) s.Require().NoError(err) s.Require().Equal(types.ReceiptStatusSuccessful, receipt.Status) } @@ -147,6 +152,9 @@ func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) anvilAddress := crypto.PubkeyToAddress(*anvilPublicKey) anvilSignTx := func(address common.Address, tx *types.Transaction) (*types.Transaction, error) { + if address != anvilAddress { + return nil, bind.ErrNotAuthorized + } return types.SignTx(tx, types.LatestSignerForChainID(s.chainID), anvilPrivateKey) } value20 := big.NewInt(2000000000000000000) // in wei (2 eth) From 71fa8fead80f7d8b40c1bfe3037d9fda7ce66498 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:03:20 -0300 Subject: [PATCH 11/18] fix(kms): remove redundant configuration variables for AWS KMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed CARTESI_AUTH_AWS_KMS_REGION and CARTESI_AUTH_AWS_KMS_ENDPOINT. - AWS KMS now uses the SDK’s standard region and endpoint resolution. - Updated tests and LocalStack instructions to use AWS_REGION and AWS_ENDPOINT_URL_KMS. - Documented AWS-provided configuration and the KMS alias risk. --- Makefile | 2 ++ internal/config/auth/auth.go | 18 +---------- internal/config/auth/auth_test.go | 4 +-- internal/config/generate/Config.toml | 26 +++++---------- internal/config/generate/docs.go | 17 +++++++++- internal/config/generated.go | 32 ------------------- .../localstack_integration_test.go | 4 +-- 7 files changed, 31 insertions(+), 72 deletions(-) diff --git a/Makefile b/Makefile index 401f5ecc1..ab05a5c36 100644 --- a/Makefile +++ b/Makefile @@ -522,6 +522,8 @@ start-awslocalstack: ## Run the AWS LocalStack docker container @echo "Add the following variables to run integration test with AWS services:" @echo " export AWS_ACCESS_KEY_ID=test" @echo " export AWS_SECRET_ACCESS_KEY=test" + @echo " export AWS_REGION=us-east-1" + @echo " export AWS_ENDPOINT_URL_KMS=http://localhost:4566" @echo " export LOCALSTACK_KMS_ENDPOINT=http://localhost:4566" @echo " export LOCALSTACK_KMS_REQUIRED=true" diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index d58f86810..8edced3e9 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -5,7 +5,6 @@ package auth import ( "context" - "errors" "fmt" "math/big" @@ -65,22 +64,7 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran if err != nil { return nil, err } - awsOpts := make([]func (*aws_cfg.LoadOptions) error, 0, 2) - kmsRegion, err := GetAuthAwsKmsRegion() - if !errors.Is(err, ErrNotDefined) { - if err != nil { - return nil, err - } - awsOpts = append(awsOpts, aws_cfg.WithRegion(kmsRegion.Value)) - } - kmsEndpoint, err := GetAuthAwsKmsEndpoint() - if !errors.Is(err, ErrNotDefined) { - if err != nil { - return nil, err - } - awsOpts = append(awsOpts, aws_cfg.WithBaseEndpoint(kmsEndpoint.Value)) - } - awsCfg, err := aws_cfg.LoadDefaultConfig(ctx, awsOpts...) + awsCfg, err := aws_cfg.LoadDefaultConfig(ctx) if err != nil { return nil, err } diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go index 01aa5b9ff..318605f78 100644 --- a/internal/config/auth/auth_test.go +++ b/internal/config/auth/auth_test.go @@ -80,13 +80,13 @@ func setupAWSAuth(t *testing.T, endpoint string) { t.Cleanup(viper.Reset) viper.Set(AUTH_KIND, "aws") viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key") - viper.Set(AUTH_AWS_KMS_REGION, "us-east-1") - viper.Set(AUTH_AWS_KMS_ENDPOINT, endpoint) // Static dummy credentials keep the AWS SDK hermetic: it never consults // shared config files, credential services, or EC2 instance metadata. t.Setenv("AWS_ACCESS_KEY_ID", "test") t.Setenv("AWS_SECRET_ACCESS_KEY", "test") + t.Setenv("AWS_REGION", "us-east-1") + t.Setenv("AWS_ENDPOINT_URL_KMS", endpoint) t.Setenv("AWS_EC2_METADATA_DISABLED", "true") } diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index 39d787b06..c77bdd183 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -360,27 +360,17 @@ used-by = ["claimer", "node", "cli", "prt"] [auth.CARTESI_AUTH_AWS_KMS_KEY_ID] go-type = "RedactedString" description = """ -If set, the node will use the AWS KMS service with this key ID to sign transactions. +An AWS KMS key ID, alias, or ARN. -Must be set alongside `CARTESI_AUTH_AWS_KMS_REGION`.""" -omit = true -used-by = ["claimer", "node", "cli", "prt"] - -[auth.CARTESI_AUTH_AWS_KMS_REGION] -go-type = "RedactedString" -description = """ -An AWS KMS Region. +If set, the node will use the AWS KMS service with this key to sign transactions. -Must be set alongside `CARTESI_AUTH_AWS_KMS_KEY_ID`.""" -omit = true -used-by = ["claimer", "node", "cli", "prt"] - -[auth.CARTESI_AUTH_AWS_KMS_ENDPOINT] -go-type = "RedactedString" -description = """ -An AWS KMS Endpoint. +Everything else about the AWS connection — region, endpoint, and credentials — is +resolved by the AWS SDK's standard chain, not by CARTESI_ variables. See the +"Externally-provided configuration" section for the variables involved. -When not provided, the default endpoint for the AWS region defined by `CARTESI_AUTH_AWS_KMS_REGION` is automatically used.""" +Prefer an ARN or a bare key ID over an alias: an alias is resolved per-region, so +the same alias in a different region names a different key and therefore a +different signing address.""" omit = true used-by = ["claimer", "node", "cli", "prt"] diff --git a/internal/config/generate/docs.go b/internal/config/generate/docs.go index 78b44d795..8820686b2 100644 --- a/internal/config/generate/docs.go +++ b/internal/config/generate/docs.go @@ -44,7 +44,8 @@ DO NOT EDIT. # Node Configuration The node is configurable through environment variables. -(There is no other way to configure it.) +Variables prefixed CARTESI_ are listed below. A few subsystems additionally read +standard variables defined by third-party SDKs; those are listed at the end. This file documents the configuration options. @@ -63,4 +64,18 @@ This file documents the configuration options. * **Used by:** {{range $i, $e := .UsedBy}}{{if $i}}, {{end}}{{$e}}{{end}} {{- end}} {{- end}} + +## Externally-provided configuration + +These are read by the AWS SDK, not by the node's own configuration layer, and +apply only when CARTESI_AUTH_KIND=aws. + +* AWS_REGION / AWS_DEFAULT_REGION — region used to resolve the KMS key. +* AWS_ENDPOINT_URL_KMS / AWS_ENDPOINT_URL — override the KMS endpoint + (VPC endpoint, PrivateLink, FIPS, or a local emulator such as LocalStack). +* AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN — static + credentials. They may instead come from the shared config file, an EC2 instance + profile, or IRSA; the node does not require any particular source. + +Full resolution order is documented by AWS; the node applies no overrides. ` diff --git a/internal/config/generated.go b/internal/config/generated.go index 929b12818..a891a79f6 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -22,9 +22,7 @@ func init() { } const ( - AUTH_AWS_KMS_ENDPOINT = "CARTESI_AUTH_AWS_KMS_ENDPOINT" AUTH_AWS_KMS_KEY_ID = "CARTESI_AUTH_AWS_KMS_KEY_ID" - AUTH_AWS_KMS_REGION = "CARTESI_AUTH_AWS_KMS_REGION" AUTH_KIND = "CARTESI_AUTH_KIND" AUTH_MNEMONIC = "CARTESI_AUTH_MNEMONIC" AUTH_MNEMONIC_ACCOUNT_INDEX = "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX" @@ -101,12 +99,8 @@ const ( func SetDefaults() { // Set defaults based on the TOML definitions. - // no default for CARTESI_AUTH_AWS_KMS_ENDPOINT - // no default for CARTESI_AUTH_AWS_KMS_KEY_ID - // no default for CARTESI_AUTH_AWS_KMS_REGION - viper.SetDefault(AUTH_KIND, "mnemonic") // no default for CARTESI_AUTH_MNEMONIC @@ -1689,19 +1683,6 @@ func (c *NodeConfig) ToValidatorConfig() *ValidatorConfig { } } -// GetAuthAwsKmsEndpoint returns the value for the environment variable CARTESI_AUTH_AWS_KMS_ENDPOINT. -func GetAuthAwsKmsEndpoint() (RedactedString, error) { - s := viper.GetString(AUTH_AWS_KMS_ENDPOINT) - if s != "" { - v, err := toRedactedString(s) - if err != nil { - return v, fmt.Errorf("failed to parse %s: %w", AUTH_AWS_KMS_ENDPOINT, err) - } - return v, nil - } - return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_AWS_KMS_ENDPOINT, ErrNotDefined) -} - // GetAuthAwsKmsKeyId returns the value for the environment variable CARTESI_AUTH_AWS_KMS_KEY_ID. func GetAuthAwsKmsKeyId() (RedactedString, error) { s := viper.GetString(AUTH_AWS_KMS_KEY_ID) @@ -1715,19 +1696,6 @@ func GetAuthAwsKmsKeyId() (RedactedString, error) { return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_AWS_KMS_KEY_ID, ErrNotDefined) } -// GetAuthAwsKmsRegion returns the value for the environment variable CARTESI_AUTH_AWS_KMS_REGION. -func GetAuthAwsKmsRegion() (RedactedString, error) { - s := viper.GetString(AUTH_AWS_KMS_REGION) - if s != "" { - v, err := toRedactedString(s) - if err != nil { - return v, fmt.Errorf("failed to parse %s: %w", AUTH_AWS_KMS_REGION, err) - } - return v, nil - } - return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_AWS_KMS_REGION, ErrNotDefined) -} - // GetAuthKind returns the value for the environment variable CARTESI_AUTH_KIND. func GetAuthKind() (AuthKind, error) { s := viper.GetString(AUTH_KIND) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 70a048db0..ae5ad6f5f 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -90,8 +90,8 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { viper.Set(config.AUTH_KIND, "aws") viper.Set(config.AUTH_AWS_KMS_KEY_ID, *created.KeyMetadata.KeyId) - viper.Set(config.AUTH_AWS_KMS_REGION, region) - viper.Set(config.AUTH_AWS_KMS_ENDPOINT, endpoint) + t.Setenv("AWS_REGION", region) + t.Setenv("AWS_ENDPOINT_URL_KMS", endpoint) s.chainID = big.NewInt(31337) factory, err := auth.GetTransactOptsFactory(ctx, s.chainID) From a9892510114c2164d2ab2e30196c49e23dc5cc44 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:51:46 -0300 Subject: [PATCH 12/18] fix(kms): handle invalid AWS KMS authentication - `assembleSignature` now rejects `r` or `s` components longer than 32 bytes with a descriptive error. - Added regression tests covering overlong `r` and `s`. --- internal/kms/signtx.go | 5 ++ internal/kms/signtx_test.go | 105 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/internal/kms/signtx.go b/internal/kms/signtx.go index 26446dae6..f9bcf9557 100644 --- a/internal/kms/signtx.go +++ b/internal/kms/signtx.go @@ -14,6 +14,7 @@ import ( "crypto/ecdsa" "encoding/asn1" "errors" + "fmt" "math/big" "reflect" @@ -71,6 +72,10 @@ func normalizeS(S []byte) []byte { * of the values of `v` will hold ecrecover(hash, sig) == publicKey, and that * is the one ethereum wants. */ func assembleSignature(r []byte, s []byte, hash []byte, key []byte) ([]byte, error) { + if len(r) > 32 || len(s) > 32 { + return nil, fmt.Errorf("malformed signature: len(r)=%d len(s)=%d", len(r), len(s)) + } + sig := make([]byte, 65) // align `s` and `r` in case they have less then 32bytes in size diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index d26975fb0..414d0dbcf 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -11,15 +11,120 @@ import ( "math/big" "testing" + "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/common" 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(_ context.Context, tx *ethtypes.Transaction, s ethtypes.Signer) (*ethtypes.Transaction, error) { + return ethtypes.SignTx(tx, s, privateKey) + } +} + +func TestAssembleSignatureRejectsOverlongComponents(t *testing.T) { + tests := []struct { + name string + r []byte + s []byte + expected string + }{ + { + name: "r", r: make([]byte, 33), s: make([]byte, 32), + expected: "malformed signature: len(r)=33 len(s)=32", + }, + { + name: "s", r: make([]byte, 32), s: make([]byte, 33), + expected: "malformed signature: len(r)=32 len(s)=33", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + signature, err := assembleSignature(test.r, test.s, nil, nil) + require.Nil(t, signature) + require.EqualError(t, err, test.expected) + }) + } +} + +func sendFunds( + value *big.Int, + SignTx SignTxFn, + ctx context.Context, + sender common.Address, + recipient common.Address, +) { + client, err := ethclient.Dial("http://127.0.0.1:8545") // anvil + if err != nil { + panic(err) + } + + nonce, err := client.PendingNonceAt(context.Background(), sender) + if err != nil { + panic(err) + } + gasLimit := uint64(21000) + gasPrice, err := client.SuggestGasPrice(ctx) + if err != nil { + panic(err) + } + var data []byte + tx := ethtypes.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + chainID, err := client.NetworkID(context.Background()) + if err != nil { + panic(err) + } + signedTx, err := SignTx(ctx, tx, ethtypes.NewEIP155Signer(chainID)) + if err != nil { + panic(err) + } + err = client.SendTransaction(context.Background(), signedTx) + if err != nil { + panic(err) + } +} + +func TestSignTx(t *testing.T) { + if len(ARN) == 0 { + t.Skip("Skipping test, ARN for KMS key is unset") + } + value20 := big.NewInt(2000000000000000000) // in wei (2 eth) + value10 := big.NewInt(1000000000000000000) // in wei (1 eth) + + anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + if err != nil { + panic(err) + } + anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) + anvilAddress := crypto.PubkeyToAddress(*anvilPublicKey) + + config, err := awscfg.LoadDefaultConfig(context.Background()) + if err != nil { + panic(err) + } + kms := awskms.NewFromConfig(config) + SignTx, _, KMSAddress, err := CreateAWSSignTxFn(context.Background(), kms, &ARN) + if err != nil { + panic(err) + } + + sendFunds(value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), + context.Background(), anvilAddress, KMSAddress) + sendFunds(value10, SignTx, + context.Background(), KMSAddress, anvilAddress) +} + func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { privateKey, err := crypto.GenerateKey() require.NoError(t, err) From 86583563f3681afe2ffa2657107db4875067d4ad Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:09:07 -0300 Subject: [PATCH 13/18] test(kms): improve test coverage of AWS KMS authentication - `normalizeR` passthrough, zero-padding trim, and malformed-padding rejection. - High-`s` normalization. - Non-canonical DER `r` and `s` handling through a fake KMS client. - Unrecoverable signature failure. - `From()` identity reporting. - Unauthorized signer rejection, verifying KMS is never called. --- internal/kms/signtx_test.go | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index 414d0dbcf..d7b5e925d 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -58,6 +59,59 @@ func TestAssembleSignatureRejectsOverlongComponents(t *testing.T) { } } +func TestNormalizeR(t *testing.T) { + t.Run("keeps components up to 32 bytes", func(t *testing.T) { + input := []byte{1, 2, 3} + + r, err := normalizeR(input) + require.NoError(t, err) + require.Equal(t, input, r) + }) + + t.Run("trims leading zero padding", func(t *testing.T) { + padded := append([]byte{0}, make([]byte, 32)...) + padded[len(padded)-1] = 1 + + r, err := normalizeR(padded) + require.NoError(t, err) + require.Len(t, r, 32) + require.Equal(t, byte(1), r[len(r)-1]) + }) + + t.Run("rejects non-padding bytes", func(t *testing.T) { + malformed := append([]byte{1}, make([]byte, 32)...) + + r, err := normalizeR(malformed) + require.Nil(t, r) + require.EqualError(t, err, "malformed `r` component") + }) +} + +func TestNormalizeSConvertsHighSToLowS(t *testing.T) { + n := crypto.S256().Params().N + halfN := new(big.Int).Div(new(big.Int).Set(n), big.NewInt(2)) //nolint:mnd + highS := new(big.Int).Add(halfN, big.NewInt(1)) + expected := new(big.Int).Sub(n, highS).Bytes() + + require.Equal(t, expected, normalizeS(highS.Bytes())) +} + +func TestAssembleSignatureRejectsUnrecoverableKey(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + otherKey, err := crypto.GenerateKey() + require.NoError(t, err) + hash := crypto.Keccak256([]byte("test transaction")) + signature, err := crypto.Sign(hash, privateKey) + require.NoError(t, err) + + assembled, err := assembleSignature( + signature[:32], signature[32:64], hash, crypto.FromECDSAPub(&otherKey.PublicKey), + ) + require.EqualError(t, err, "failed to compute signature") + require.NotNil(t, assembled) +} + func sendFunds( value *big.Int, SignTx SignTxFn, @@ -140,6 +194,7 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { ) require.NoError(t, err) cancelStartup() + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), factory.From()) type contextKey string submitCtx := context.WithValue(context.Background(), contextKey("phase"), "submit") @@ -184,11 +239,78 @@ func TestAWSTransactOptsFactorySignsDynamicFeeTransaction(t *testing.T) { require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), sender) } +func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + arn := "alias/test-key" + factory, err := CreateAWSTransactOptsFactory( + context.Background(), client, &arn, ethtypes.NewEIP155Signer(big.NewInt(1)), + ) + require.NoError(t, err) + opts, err := factory.NewTransactOpts(context.Background()) + require.NoError(t, err) + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + + signed, err := opts.Signer(common.Address{0xff}, tx) + require.Nil(t, signed) + require.ErrorIs(t, err, bind.ErrNotAuthorized) + require.Zero(t, client.signCalls) +} + +func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + arn := "alias/test-key" + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + signer := ethtypes.NewEIP155Signer(big.NewInt(1)) + + tests := []struct { + name string + r []byte + s []byte + expected string + }{ + { + name: "non-padding byte in overlong r", r: append([]byte{1}, make([]byte, 32)...), s: []byte{1}, + expected: "malformed `r` component", + }, + { + name: "non-minimal overlong s", r: []byte{1}, s: append([]byte{0}, make([]byte, 32)...), + expected: "malformed signature: len(r)=1 len(s)=33", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := newFakeKMSClient(t, privateKey) + client.signature = marshalRawECDSASignature(test.r, test.s) + signTx, _, _, err := CreateAWSSignTxFn(context.Background(), client, &arn) + require.NoError(t, err) + + signed, err := signTx(context.Background(), tx, signer) + require.Nil(t, signed) + require.EqualError(t, err, test.expected) + }) + } +} + +func marshalRawECDSASignature(r, s []byte) []byte { + content := make([]byte, 0, len(r)+len(s)+4) + content = append(content, 0x02, byte(len(r))) + content = append(content, r...) + content = append(content, 0x02, byte(len(s))) + content = append(content, s...) + return append([]byte{0x30, byte(len(content))}, content...) +} + type fakeKMSClient struct { t *testing.T privateKey *ecdsa.PrivateKey publicKey []byte signContext context.Context + signCalls int + signature []byte } func newFakeKMSClient(t *testing.T, privateKey *ecdsa.PrivateKey) *fakeKMSClient { @@ -226,7 +348,11 @@ func (f *fakeKMSClient) Sign( input *awskms.SignInput, _ ...func(*awskms.Options), ) (*awskms.SignOutput, error) { + f.signCalls++ f.signContext = ctx + if f.signature != nil { + return &awskms.SignOutput{Signature: f.signature}, nil + } r, s, err := ecdsa.Sign(rand.Reader, f.privateKey, input.Message) require.NoError(f.t, err) signature, err := asn1.Marshal(struct { From 67da6cabef3bbd6159c583e2c84659e8886b448c Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:23:38 -0300 Subject: [PATCH 14/18] refactor(kms): fix small details in implementation and tests - Added the `endtoendtests` build tag to .`golangci.yml`. - Replaced unused `ethtypes.NewEIP155Signer` with the current `ethtypes.LatestSignerForChainID` in tests. - Replaced `reflect.DeepEqual` with `bytes.Equal`. - Recovery now tries both recovery IDs when `Ecrecover` fails. - Added a regression test for that behavior. - Reused one Ethereum client in the surviving manual test and closed it after use. - Replaced deprecated `types.NewTransaction` with `types.NewTx`. --- .golangci.yml | 3 +++ internal/config/auth/auth.go | 4 ++++ internal/config/auth/auth_test.go | 20 ++++++++++++++++ internal/kms/signtx.go | 8 +++---- internal/kms/signtx_test.go | 39 +++++++++++++++++++------------ 5 files changed, 55 insertions(+), 19 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 7a94ed81d..a7b264e22 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,7 @@ version: "2" +run: + build-tags: + - endtoendtests linters: enable: - exhaustive diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index 8edced3e9..d06166d4a 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -22,6 +22,10 @@ import ( ) func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.TransactOptsFactory, error) { + if chainId == nil || chainId.Sign() <= 0 { + return nil, bind.ErrNoChainID + } + authKind, err := GetAuthKind() if err != nil { return nil, err diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go index 318605f78..ad5a57d6d 100644 --- a/internal/config/auth/auth_test.go +++ b/internal/config/auth/auth_test.go @@ -14,6 +14,7 @@ import ( "net/http/httptest" "testing" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -74,6 +75,25 @@ func TestGetTransactOptsFactoryAWSSignsDynamicFeeTransaction(t *testing.T) { } } +func TestGetTransactOptsFactoryRejectsInvalidChainID(t *testing.T) { + tests := []struct { + name string + chainID *big.Int + }{ + {name: "nil", chainID: nil}, + {name: "zero", chainID: big.NewInt(0)}, + {name: "negative", chainID: big.NewInt(-1)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, err := GetTransactOptsFactory(t.Context(), test.chainID) + require.Nil(t, factory) + require.ErrorIs(t, err, bind.ErrNoChainID) + }) + } +} + func setupAWSAuth(t *testing.T, endpoint string) { t.Helper() viper.Reset() diff --git a/internal/kms/signtx.go b/internal/kms/signtx.go index f9bcf9557..737494136 100644 --- a/internal/kms/signtx.go +++ b/internal/kms/signtx.go @@ -10,13 +10,13 @@ package kms import ( + "bytes" "context" "crypto/ecdsa" "encoding/asn1" "errors" "fmt" "math/big" - "reflect" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -86,13 +86,13 @@ func assembleSignature(r []byte, s []byte, hash []byte, key []byte) ([]byte, err sig[64] = i pub, err := crypto.Ecrecover(hash, sig[:]) if err != nil { - return nil, err + continue } - if reflect.DeepEqual(pub, key) { + if bytes.Equal(pub, key) { return sig, nil } } - return sig, errors.New("failed to compute signature") + return nil, errors.New("failed to compute signature") } /* Create a SignTxFn that uses the KMS infrastructure from AWS for signing. diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index d7b5e925d..32ad18706 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -109,21 +109,23 @@ func TestAssembleSignatureRejectsUnrecoverableKey(t *testing.T) { signature[:32], signature[32:64], hash, crypto.FromECDSAPub(&otherKey.PublicKey), ) require.EqualError(t, err, "failed to compute signature") - require.NotNil(t, assembled) + require.Nil(t, assembled) +} + +func TestAssembleSignatureTriesBothRecoveryIDs(t *testing.T) { + assembled, err := assembleSignature(make([]byte, 32), make([]byte, 32), make([]byte, 32), nil) + require.Nil(t, assembled) + require.EqualError(t, err, "failed to compute signature") } func sendFunds( + client *ethclient.Client, value *big.Int, SignTx SignTxFn, ctx context.Context, sender common.Address, recipient common.Address, ) { - client, err := ethclient.Dial("http://127.0.0.1:8545") // anvil - if err != nil { - panic(err) - } - nonce, err := client.PendingNonceAt(context.Background(), sender) if err != nil { panic(err) @@ -134,12 +136,14 @@ func sendFunds( panic(err) } var data []byte - tx := ethtypes.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: nonce, To: &recipient, Value: value, Gas: gasLimit, GasPrice: gasPrice, Data: data, + }) chainID, err := client.NetworkID(context.Background()) if err != nil { panic(err) } - signedTx, err := SignTx(ctx, tx, ethtypes.NewEIP155Signer(chainID)) + signedTx, err := SignTx(ctx, tx, ethtypes.LatestSignerForChainID(chainID)) if err != nil { panic(err) } @@ -153,8 +157,13 @@ func TestSignTx(t *testing.T) { if len(ARN) == 0 { t.Skip("Skipping test, ARN for KMS key is unset") } - value20 := big.NewInt(2000000000000000000) // in wei (2 eth) - value10 := big.NewInt(1000000000000000000) // in wei (1 eth) + value20 := big.NewInt(2000000000000000000) // in wei (2 eth) + value10 := big.NewInt(1000000000000000000) // in wei (1 eth) + client, err := ethclient.Dial("http://127.0.0.1:8545") // anvil + if err != nil { + panic(err) + } + defer client.Close() anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) if err != nil { @@ -173,9 +182,9 @@ func TestSignTx(t *testing.T) { panic(err) } - sendFunds(value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), + sendFunds(client, value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), context.Background(), anvilAddress, KMSAddress) - sendFunds(value10, SignTx, + sendFunds(client, value10, SignTx, context.Background(), KMSAddress, anvilAddress) } @@ -190,7 +199,7 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { startupCtx, client, &arn, - ethtypes.NewEIP155Signer(big.NewInt(1)), + ethtypes.LatestSignerForChainID(big.NewInt(1)), ) require.NoError(t, err) cancelStartup() @@ -245,7 +254,7 @@ func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { client := newFakeKMSClient(t, privateKey) arn := "alias/test-key" factory, err := CreateAWSTransactOptsFactory( - context.Background(), client, &arn, ethtypes.NewEIP155Signer(big.NewInt(1)), + context.Background(), client, &arn, ethtypes.LatestSignerForChainID(big.NewInt(1)), ) require.NoError(t, err) opts, err := factory.NewTransactOpts(context.Background()) @@ -263,7 +272,7 @@ func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { require.NoError(t, err) arn := "alias/test-key" tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) - signer := ethtypes.NewEIP155Signer(big.NewInt(1)) + signer := ethtypes.LatestSignerForChainID(big.NewInt(1)) tests := []struct { name string From 7db84761aaafd0ddf5e27a32f5440e0939deca36 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:43:19 -0300 Subject: [PATCH 15/18] style(kms): avoid lint errors --- internal/kms/signtx.go | 40 +++++++++++++++++++------------------ internal/kms/signtx_test.go | 35 +++++++++++++++++++------------- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/internal/kms/signtx.go b/internal/kms/signtx.go index 737494136..aad5d5c52 100644 --- a/internal/kms/signtx.go +++ b/internal/kms/signtx.go @@ -35,31 +35,33 @@ type Client interface { Sign(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error) } +const signatureComponentSize = 32 + /* AWS sometimes reply with a `r` larger than 32bytes padded on the left with * zeros. Trim it down to a total of 32bytes */ -func normalizeR(R []byte) ([]byte, error) { - if len(R) <= 32 { - return R, nil +func normalizeR(r []byte) ([]byte, error) { + if len(r) <= signatureComponentSize { + return r, nil } - for i := 0; i < len(R)-32; i++ { - if R[i] != 0 { // must be padding + for i := 0; i < len(r)-signatureComponentSize; i++ { + if r[i] != 0 { // must be padding return nil, errors.New("malformed `r` component") } } - return R[len(R)-32:], nil + return r[len(r)-signatureComponentSize:], nil } /* normalize `s` to the lower half of N according to EIP-2 * ref. https://eips.ethereum.org/EIPS/eip-2 */ -func normalizeS(S []byte) []byte { - N := crypto.S256().Params().N - halfN := new(big.Int).Div(N, big.NewInt(2)) //nolint:mnd - SBI := new(big.Int).SetBytes(S) +func normalizeS(s []byte) []byte { + n := crypto.S256().Params().N + halfN := new(big.Int).Div(n, big.NewInt(2)) //nolint:mnd + sBigInt := new(big.Int).SetBytes(s) - if SBI.Cmp(halfN) > 0 { - S = new(big.Int).Sub(N, SBI).Bytes() + if sBigInt.Cmp(halfN) > 0 { + s = new(big.Int).Sub(n, sBigInt).Bytes() } - return S + return s } /* Compute the final component `v` of the ethereum signature, one KMS doesn't @@ -72,19 +74,19 @@ func normalizeS(S []byte) []byte { * of the values of `v` will hold ecrecover(hash, sig) == publicKey, and that * is the one ethereum wants. */ func assembleSignature(r []byte, s []byte, hash []byte, key []byte) ([]byte, error) { - if len(r) > 32 || len(s) > 32 { + if len(r) > signatureComponentSize || len(s) > signatureComponentSize { return nil, fmt.Errorf("malformed signature: len(r)=%d len(s)=%d", len(r), len(s)) } sig := make([]byte, 65) // align `s` and `r` in case they have less then 32bytes in size - copy(sig[32-len(r):], r) + copy(sig[signatureComponentSize-len(r):], r) copy(sig[64-len(s):], s) for i := byte(0); i < 2; i++ { sig[64] = i - pub, err := crypto.Ecrecover(hash, sig[:]) + pub, err := crypto.Ecrecover(hash, sig) if err != nil { continue } @@ -144,13 +146,13 @@ func CreateAWSSignTxFn( if err != nil { return nil, err } - return tx.WithSignature(signer, signature[:]) + return tx.WithSignature(signer, signature) }, publicKey, crypto.PubkeyToAddress(*publicKey), nil } -func GetPublicKeyBytes(ctx context.Context, client 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, + KeyId: arn, }) if err != nil { return nil, err diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index 32ad18706..316f92e54 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -24,6 +24,8 @@ import ( "github.com/stretchr/testify/require" ) +const testKeyID = "alias/test-key" + var ARN = "" /* Create a SignTxFn from a private key. Useful for testing */ @@ -89,7 +91,7 @@ func TestNormalizeR(t *testing.T) { func TestNormalizeSConvertsHighSToLowS(t *testing.T) { n := crypto.S256().Params().N - halfN := new(big.Int).Div(new(big.Int).Set(n), big.NewInt(2)) //nolint:mnd + halfN := new(big.Int).Div(new(big.Int).Set(n), big.NewInt(2)) highS := new(big.Int).Add(halfN, big.NewInt(1)) expected := new(big.Int).Sub(n, highS).Bytes() @@ -119,10 +121,10 @@ func TestAssembleSignatureTriesBothRecoveryIDs(t *testing.T) { } func sendFunds( + ctx context.Context, client *ethclient.Client, value *big.Int, - SignTx SignTxFn, - ctx context.Context, + signTx SignTxFn, sender common.Address, recipient common.Address, ) { @@ -143,7 +145,7 @@ func sendFunds( if err != nil { panic(err) } - signedTx, err := SignTx(ctx, tx, ethtypes.LatestSignerForChainID(chainID)) + signedTx, err := signTx(ctx, tx, ethtypes.LatestSignerForChainID(chainID)) if err != nil { panic(err) } @@ -182,10 +184,10 @@ func TestSignTx(t *testing.T) { panic(err) } - sendFunds(client, value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), - context.Background(), anvilAddress, KMSAddress) - sendFunds(client, value10, SignTx, - context.Background(), KMSAddress, anvilAddress) + sendFunds(context.Background(), client, value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), + anvilAddress, KMSAddress) + sendFunds(context.Background(), client, value10, SignTx, + KMSAddress, anvilAddress) } func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { @@ -193,7 +195,7 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { require.NoError(t, err) client := newFakeKMSClient(t, privateKey) - arn := "alias/test-key" + arn := testKeyID startupCtx, cancelStartup := context.WithCancel(context.Background()) factory, err := CreateAWSTransactOptsFactory( startupCtx, @@ -252,7 +254,7 @@ func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { privateKey, err := crypto.GenerateKey() require.NoError(t, err) client := newFakeKMSClient(t, privateKey) - arn := "alias/test-key" + arn := testKeyID factory, err := CreateAWSTransactOptsFactory( context.Background(), client, &arn, ethtypes.LatestSignerForChainID(big.NewInt(1)), ) @@ -270,7 +272,7 @@ func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { privateKey, err := crypto.GenerateKey() require.NoError(t, err) - arn := "alias/test-key" + arn := testKeyID tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) signer := ethtypes.LatestSignerForChainID(big.NewInt(1)) @@ -305,12 +307,17 @@ func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { } func marshalRawECDSASignature(r, s []byte) []byte { + const maxDERLength = 255 + if len(r) > maxDERLength || len(s) > maxDERLength || len(r)+len(s)+4 > maxDERLength { + panic("test DER signature is too large for single-byte length encoding") + } + content := make([]byte, 0, len(r)+len(s)+4) - content = append(content, 0x02, byte(len(r))) + content = append(content, 0x02, byte(len(r))) //nolint:gosec // Length is bounded above. content = append(content, r...) - content = append(content, 0x02, byte(len(s))) + content = append(content, 0x02, byte(len(s))) //nolint:gosec // Length is bounded above. content = append(content, s...) - return append([]byte{0x30, byte(len(content))}, content...) + return append([]byte{0x30, byte(len(content))}, content...) //nolint:gosec // Length is bounded above. } type fakeKMSClient struct { From 2eda6ed3f4cd630aa9e7dc4d28b7a3c9615f4ea4 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:11:07 -0300 Subject: [PATCH 16/18] feat(claimer,prt): log submitter identity on service startup --- internal/claimer/service.go | 1 + internal/prt/service.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/claimer/service.go b/internal/claimer/service.go index 1a1399ed3..9f92cab4e 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -138,6 +138,7 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, fmt.Errorf("getting transaction options: %w", err) } + s.Logger.Info("Claim submitter identity", "address", txOptsFactory.From()) } s.repository = c.Repository diff --git a/internal/prt/service.go b/internal/prt/service.go index 9d9878886..3279dee9c 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -121,6 +121,7 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, err } + s.Logger.Info("PRT submitter identity", "address", s.txOptsFactory.From()) } return s, nil From 05ee583cba57393648a1da69a918ecdce0f8309b Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:13:07 -0300 Subject: [PATCH 17/18] fix(cli): use a single authentication for all deposit transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ERC-20 deposit now creates one transaction-options factory per command. - The factory’s address is reused for confirmation output. - Fresh transaction options are derived from that same factory for approval and deposit. - Existing CLI callers remain compatible through GetTransactOpts. --- cmd/cartesi-rollups-cli/root/deposit/deposit.go | 9 +++++---- internal/cli/ethereum.go | 7 +++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit.go b/cmd/cartesi-rollups-cli/root/deposit/deposit.go index 536717419..5b08cf460 100644 --- a/cmd/cartesi-rollups-cli/root/deposit/deposit.go +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit.go @@ -13,6 +13,7 @@ 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" @@ -113,7 +114,7 @@ func runERC20(cmd *cobra.Command, args []string) { cobra.CheckErr(err) chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := cli.GetTransactOpts(ctx, chainID) + txOptsFactory, err := auth.GetTransactOptsFactory(ctx, chainID) cobra.CheckErr(err) if !skipConfirmation { @@ -124,7 +125,7 @@ func runERC20(cmd *cobra.Command, args []string) { " token: %s\n"+ " amount: %s\n"+ " approve: %t\n", - txOpts.From, appAddr, portalAddr, tokenAddr, amount.String(), approveParam) + txOptsFactory.From(), appAddr, portalAddr, tokenAddr, amount.String(), approveParam) confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") cobra.CheckErr(promptErr) if !confirmed { @@ -137,7 +138,7 @@ func runERC20(cmd *cobra.Command, args []string) { if approveParam { token, err := ierc20metadata.NewIERC20Metadata(tokenAddr, client) cobra.CheckErr(err) - approveOpts, err := cli.GetTransactOpts(ctx, chainID) + approveOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) cobra.CheckErr(err) tx, err := token.Approve(approveOpts, portalAddr, amount) cobra.CheckErr(cli.DecorateRevert(err, @@ -153,7 +154,7 @@ func runERC20(cmd *cobra.Command, args []string) { portal, err := ierc20portal.NewIERC20Portal(portalAddr, client) cobra.CheckErr(err) - depositOpts, err := cli.GetTransactOpts(ctx, chainID) + depositOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) 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/internal/cli/ethereum.go b/internal/cli/ethereum.go index e44018c09..00357d81d 100644 --- a/internal/cli/ethereum.go +++ b/internal/cli/ethereum.go @@ -10,6 +10,7 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) @@ -18,7 +19,13 @@ func GetTransactOpts(ctx context.Context, chainId *big.Int) (*bind.TransactOpts, if err != nil { return nil, err } + return GetTransactOptsFromFactory(ctx, factory) +} +func GetTransactOptsFromFactory( + ctx context.Context, + factory ethutil.TransactOptsFactory, +) (*bind.TransactOpts, error) { txOpts, err := factory.NewTransactOpts(ctx) if err != nil { return nil, err From 275baf366ba7435cc63a897497c4ad3e553762b3 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:48:45 -0300 Subject: [PATCH 18/18] feat(node): delay startup of services until able to authenticate --- internal/claimer/service.go | 8 ++- internal/config/auth/auth.go | 13 +++- internal/node/node.go | 116 ++++++++++++++++++++++++++++++++++- internal/node/node_test.go | 64 +++++++++++++++++++ internal/prt/service.go | 8 ++- 5 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 internal/node/node_test.go diff --git a/internal/claimer/service.go b/internal/claimer/service.go index 9f92cab4e..9e4fc4df9 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -77,8 +77,7 @@ type PersistentConfig struct { ChainID uint64 } -func Create(ctx context.Context, c *CreateInfo) (*Service, error) { - var err error +func Create(ctx context.Context, c *CreateInfo) (_ *Service, err error) { if c == nil { return nil, errors.New("invalid CreateInfo is nil") @@ -101,6 +100,11 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, fmt.Errorf("creating base service: %w", err) } + defer func() { + if err != nil && s.Ticker != nil { + s.Ticker.Stop() + } + }() nodeConfig, err := setupPersistentConfig(ctx, s.Logger, c.Repository, &c.Config) if err != nil { diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index d06166d4a..5bfa995df 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -5,6 +5,7 @@ package auth import ( "context" + "errors" "fmt" "math/big" @@ -21,6 +22,12 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" ) +// ErrSignerUnavailable identifies transient failures while acquiring the AWS +// KMS-backed signer. Callers may use this to degrade and retry signing services +// without treating unrelated configuration or service creation errors as +// recoverable. +var ErrSignerUnavailable = errors.New("signer unavailable") + func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.TransactOptsFactory, error) { if chainId == nil || chainId.Sign() <= 0 { return nil, bind.ErrNoChainID @@ -73,12 +80,16 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran return nil, err } kmsClient := aws_kms.NewFromConfig(awsCfg) - return signtx.CreateAWSTransactOptsFactory( + factory, err := signtx.CreateAWSTransactOptsFactory( ctx, kmsClient, aws.String(keyId.Value), types.LatestSignerForChainID(chainId), ) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrSignerUnavailable, err) + } + return factory, nil default: return nil, fmt.Errorf("no valid authentication method found") } diff --git a/internal/node/node.go b/internal/node/node.go index 872ca8c26..9954bd1c8 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -5,13 +5,18 @@ package node import ( "context" + "errors" "fmt" + "log/slog" + "sync" + "time" "github.com/cartesi/rollups-node/pkg/service" "github.com/cartesi/rollups-node/internal/advancer" "github.com/cartesi/rollups-node/internal/claimer" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/internal/evmreader" "github.com/cartesi/rollups-node/internal/jsonrpc" "github.com/cartesi/rollups-node/internal/prt" @@ -26,8 +31,11 @@ import ( type serviceResult struct { service service.IService err error + create serviceCreator } +var degradedServiceRetryInterval = 5 * time.Second + type CreateInfo struct { service.CreateInfo @@ -87,7 +95,7 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { for _, create := range creators { go func() { svc, err := create(ctx, c, s) - ch <- serviceResult{service: svc, err: err} + ch <- serviceResult{service: svc, err: err, create: create} }() } @@ -95,6 +103,15 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { select { case result := <-ch: if result.err != nil { + if errors.Is(result.err, auth.ErrSignerUnavailable) { + s.Logger.Error("Signing service started in degraded state; retrying until the signer is available", + "error", result.err) + s.Children = append(s.Children, newDegradedService(ctx, s.Logger, + func(retryCtx context.Context) (service.IService, error) { + return result.create(retryCtx, c, s) + })) + continue + } stopAndDrain(s.Children, ch, len(creators)-len(s.Children)-1) return fmt.Errorf("failed to create service: %w", result.err) } @@ -107,6 +124,103 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { return nil } +// degradedService keeps a transiently unavailable signing service visible to +// readiness checks while recreating it in the background. It becomes a thin +// proxy once creation succeeds. +type degradedService struct { + ctx context.Context + cancel context.CancelFunc + logger *slog.Logger + create func(context.Context) (service.IService, error) + + mu sync.RWMutex + service service.IService +} + +func newDegradedService( + ctx context.Context, + logger *slog.Logger, + create func(context.Context) (service.IService, error), +) *degradedService { + retryCtx, cancel := context.WithCancel(ctx) + return °radedService{ctx: retryCtx, cancel: cancel, logger: logger, create: create} +} + +func (s *degradedService) current() service.IService { + s.mu.RLock() + defer s.mu.RUnlock() + return s.service +} + +func (s *degradedService) Alive() bool { + if child := s.current(); child != nil { + return child.Alive() + } + return true +} + +func (s *degradedService) Ready() bool { + if child := s.current(); child != nil { + return child.Ready() + } + return false +} + +func (s *degradedService) Reload() []error { + if child := s.current(); child != nil { + return child.Reload() + } + return nil +} + +func (s *degradedService) Tick() []error { return nil } + +func (s *degradedService) Stop(force bool) []error { + s.cancel() + if child := s.current(); child != nil { + return child.Stop(force) + } + return nil +} + +func (s *degradedService) String() string { + if child := s.current(); child != nil { + return child.String() + } + return "degraded signing service" +} + +func (s *degradedService) Serve() error { + ticker := time.NewTicker(degradedServiceRetryInterval) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return nil + case <-ticker.C: + child, err := s.create(s.ctx) + if err != nil { + if s.ctx.Err() != nil { + return nil + } + s.logger.Error("Signing service remains degraded; creation retry failed", "error", err) + continue + } + if s.ctx.Err() != nil { + child.Stop(true) + return nil + } + + s.mu.Lock() + s.service = child + s.mu.Unlock() + s.logger.Info("Signing service recovered") + return child.Serve() + } + } +} + // stopAndDrain stops already-created children and drains remaining results // from the channel, stopping any successful services to prevent resource leaks. func stopAndDrain(children []service.IService, ch <-chan serviceResult, remaining int) { diff --git a/internal/node/node_test.go b/internal/node/node_test.go new file mode 100644 index 000000000..2e2c5ca0c --- /dev/null +++ b/internal/node/node_test.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package node + +import ( + "context" + "errors" + "io" + "log/slog" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +type recoveredService struct { + served chan struct{} + stopped atomic.Bool +} + +func (s *recoveredService) Alive() bool { return true } +func (s *recoveredService) Ready() bool { return true } +func (s *recoveredService) Reload() []error { return nil } +func (s *recoveredService) Tick() []error { return nil } +func (s *recoveredService) String() string { return "recovered" } +func (s *recoveredService) Stop(bool) []error { s.stopped.Store(true); return nil } +func (s *recoveredService) Serve() error { close(s.served); return nil } + +func TestDegradedServiceRetriesUntilRecovery(t *testing.T) { + oldInterval := degradedServiceRetryInterval + degradedServiceRetryInterval = time.Millisecond + t.Cleanup(func() { degradedServiceRetryInterval = oldInterval }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + recovered := &recoveredService{served: make(chan struct{})} + var attempts atomic.Int32 + degraded := newDegradedService(ctx, logger, func(context.Context) (service.IService, error) { + if attempts.Add(1) < 2 { + return nil, errors.New("KMS unavailable") + } + return recovered, nil + }) + + require.True(t, degraded.Alive()) + require.False(t, degraded.Ready()) + + done := make(chan error, 1) + go func() { done <- degraded.Serve() }() + select { + case <-recovered.served: + case <-time.After(time.Second): + t.Fatal("degraded service did not recover") + } + + require.True(t, degraded.Ready()) + require.NoError(t, <-done) + require.Empty(t, degraded.Stop(true)) + require.True(t, recovered.stopped.Load()) +} diff --git a/internal/prt/service.go b/internal/prt/service.go index 3279dee9c..64f228b28 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -50,8 +50,7 @@ type PersistentConfig struct { ChainID uint64 } -func Create(ctx context.Context, c *CreateInfo) (*Service, error) { - var err error +func Create(ctx context.Context, c *CreateInfo) (_ *Service, err error) { if err = ctx.Err(); err != nil { return nil, err // This returns context.Canceled or context.DeadlineExceeded. } @@ -63,6 +62,11 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, err } + defer func() { + if err != nil && s.Ticker != nil { + s.Ticker.Stop() + } + }() if c.EthClient == nil { return nil, fmt.Errorf("EthClient on prt service Create is nil")