From e412f819c27145934abaa1bc3dc86d549b6523dc 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 1/3] fix(kms): make the AWS KMS signer support dynamic-fee transactions --- Makefile | 3 +- internal/config/auth/auth.go | 2 +- internal/kms/signtx_test.go | 31 +++++++ test/compose/compose.integration.yaml | 20 +++++ .../localstack_integration_test.go | 84 +++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 test/integration/localstack_integration_test.go diff --git a/Makefile b/Makefile index ef5157b11..3a2563a9c 100644 --- a/Makefile +++ b/Makefile @@ -553,7 +553,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)$$ @@ -561,6 +561,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 := ^TestLocalStackAWSTransactOptsFactory$$ # ----------------------------------------------------------------------------- # Node topology axis — orthogonal to shards. diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index 408f7b13a..06384edeb 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -73,7 +73,7 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran ctx, kmsConfig, aws.String(authAwsKmsKeyId.Value), - types.NewEIP155Signer(chainId), + types.LatestSignerForChainID(chainId), ) default: return nil, fmt.Errorf("no valid authentication method found") diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index a4d7d422d..34344edfa 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -128,6 +128,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..8c3b8aead --- /dev/null +++ b/test/integration/localstack_integration_test.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "context" + "math/big" + "os" + "testing" + + "github.com/cartesi/rollups-node/internal/kms" + + "github.com/aws/aws-sdk-go-v2/aws" + "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/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestLocalStackAWSTransactOptsFactory(t *testing.T) { + endpoint := os.Getenv("LOCALSTACK_KMS_ENDPOINT") + if endpoint == "" { + t.Skip("LOCALSTACK_KMS_ENDPOINT is not set; skipping LocalStack KMS integration test") + } + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion("us-east-1"), + config.WithBaseEndpoint(endpoint), + ) + require.NoError(t, 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) + } + require.NotNil(t, created.KeyMetadata) + require.NotNil(t, created.KeyMetadata.KeyId) + + chainID := big.NewInt(31337) + factory, err := kms.CreateAWSTransactOptsFactory( + ctx, client, created.KeyMetadata.KeyId, types.LatestSignerForChainID(chainID), + ) + require.NoError(t, err) + require.NotEqual(t, common.Address{}, factory.From()) + opts, err := factory.NewTransactOpts(ctx) + require.NoError(t, err) + + 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: chainID, Nonce: 2, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(2), + Gas: 21000, To: &to, Value: big.NewInt(3), + }), + } + for name, tx := range tests { + t.Run(name, func(t *testing.T) { + signed, err := opts.Signer(opts.From, tx) + require.NoError(t, err) + sender, err := types.Sender(types.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + require.Equal(t, factory.From(), sender) + }) + } + + _, _ = client.ScheduleKeyDeletion(ctx, &awskms.ScheduleKeyDeletionInput{ + KeyId: created.KeyMetadata.KeyId, PendingWindowInDays: aws.Int32(7), //nolint:mnd + }) +} From 7e24e657bf5dd02eb7f477fedad53400c4fb82f8 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:18:38 -0300 Subject: [PATCH 2/3] test(kms): move AWS sign test to integration with LocalStack --- Makefile | 2 +- internal/kms/signtx_test.go | 80 ------------------ .../localstack_integration_test.go | 83 ++++++++++++++++--- 3 files changed, 74 insertions(+), 91 deletions(-) diff --git a/Makefile b/Makefile index 3a2563a9c..ae2ce3e5e 100644 --- a/Makefile +++ b/Makefile @@ -561,7 +561,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 := ^TestLocalStackAWSTransactOptsFactory$$ +INTEGRATION_SHARD_awskms := ^TestLocalStackAWS(SignTx|TransactionOptsFactory)$$ # ----------------------------------------------------------------------------- # Node topology axis — orthogonal to shards. diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index 34344edfa..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) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 8c3b8aead..218b4a2d5 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -7,35 +7,41 @@ package integration import ( "context" + "crypto/ecdsa" "math/big" "os" "testing" + "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/kms" + "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" + 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/ethclient" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/require" ) -func TestLocalStackAWSTransactOptsFactory(t *testing.T) { +func setupTesting(t *testing.T) (*awskms.Client, *awskms.CreateKeyOutput) { + t.Helper() + endpoint := os.Getenv("LOCALSTACK_KMS_ENDPOINT") if endpoint == "" { t.Skip("LOCALSTACK_KMS_ENDPOINT is not set; skipping LocalStack KMS integration test") } - ctx := context.Background() - cfg, err := config.LoadDefaultConfig(ctx, - config.WithRegion("us-east-1"), - config.WithBaseEndpoint(endpoint), + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion("us-east-1"), + awscfg.WithBaseEndpoint(endpoint), ) require.NoError(t, err) client := awskms.NewFromConfig(cfg) - created, err := client.CreateKey(ctx, &awskms.CreateKeyInput{ + created, err := client.CreateKey(t.Context(), &awskms.CreateKeyInput{ KeyUsage: kmstypes.KeyUsageTypeSignVerify, KeySpec: kmstypes.KeySpecEccSecgP256k1, }) @@ -49,13 +55,70 @@ func TestLocalStackAWSTransactOptsFactory(t *testing.T) { require.NotNil(t, created.KeyMetadata) require.NotNil(t, created.KeyMetadata.KeyId) + return client, created +} + +func sendFunds( + t *testing.T, + value *big.Int, + SignTx kms.SignTxFn, + sender common.Address, + recipient common.Address, +) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + require.NoError(t, err) + client, err := ethclient.Dial(ethEndpoint.Raw()) // anvil + require.NoError(t, err) + + nonce, err := client.PendingNonceAt(t.Context(), sender) + require.NoError(t, err) + gasLimit := uint64(21000) + gasPrice, err := client.SuggestGasPrice(t.Context()) + require.NoError(t, err) + var data []byte + tx := types.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + chainID, err := client.NetworkID(t.Context()) + require.NoError(t, err) + signedTx, err := SignTx(t.Context(), tx, types.LatestSignerForChainID(chainID)) + require.NoError(t, err) + err = client.SendTransaction(t.Context(), signedTx) + require.NoError(t, err) +} + +func TestLocalStackAWSSignTx(t *testing.T) { + client, created := setupTesting(t) + + awsSignTx, _, awsAddress, err := kms.CreateAWSSignTxFn( + t.Context(), client, created.KeyMetadata.KeyId, + ) + require.NoError(t, err) + + 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) + anvilSignTx := func(_ context.Context, tx *types.Transaction, s types.Signer) (*types.Transaction, error) { + return types.SignTx(tx, s, anvilPrivateKey) + } + + sendFunds(t, value20, anvilSignTx, anvilAddress, awsAddress) + sendFunds(t, value10, awsSignTx, awsAddress, anvilAddress) +} + +func TestLocalStackAWSTransactionOptsFactory(t *testing.T) { + client, created := setupTesting(t) + chainID := big.NewInt(31337) factory, err := kms.CreateAWSTransactOptsFactory( - ctx, client, created.KeyMetadata.KeyId, types.LatestSignerForChainID(chainID), + t.Context(), client, created.KeyMetadata.KeyId, types.LatestSignerForChainID(chainID), ) require.NoError(t, err) require.NotEqual(t, common.Address{}, factory.From()) - opts, err := factory.NewTransactOpts(ctx) + opts, err := factory.NewTransactOpts(t.Context()) require.NoError(t, err) to := common.Address{0x01} @@ -78,7 +141,7 @@ func TestLocalStackAWSTransactOptsFactory(t *testing.T) { }) } - _, _ = client.ScheduleKeyDeletion(ctx, &awskms.ScheduleKeyDeletionInput{ + _, _ = client.ScheduleKeyDeletion(t.Context(), &awskms.ScheduleKeyDeletionInput{ KeyId: created.KeyMetadata.KeyId, PendingWindowInDays: aws.Int32(7), //nolint:mnd }) } From 9092af5fc633c820c41e0db8b0bb194e900329bd 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 3/3] test(integration): add make target to run AWS LocalStack --- Makefile | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Makefile b/Makefile index ae2ce3e5e..5532e9945 100644 --- a/Makefile +++ b/Makefile @@ -498,6 +498,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-aws: ## Run the AWS LocakStack 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 @@ -506,6 +515,9 @@ stop-devnet: ## Stop the anvil devnet docker container stop-postgres: ## Stop the PostgreSQL 16 docker container @docker stop postgres || true +stop-aws: ## Stop the AWS LocalStack docker container + @docker stop awslocalstack || true + stop: stop-devnet stop-postgres ## Stop all running docker containers restart-devnet: ## Restart the anvil devnet docker container @@ -516,6 +528,10 @@ restart-postgres: ## Restart the PostgreSQL 16 docker container and migrate it @$(MAKE) stop-postgres @$(MAKE) start-postgres +restart-aws: ## Restart the AWS LocalStack docker container and migrate it + @$(MAKE) stop-aws + @$(MAKE) start-aws + restart: ## Restart all running docker containers @$(MAKE) stop-devnet @$(MAKE) stop-postgres