Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4502af1
fix(kms): make the AWS KMS signer support dynamic-fee transactions
renatomaia Jul 21, 2026
7c06bd0
test(kms): add unit test for AWS KMS authentication
renatomaia Aug 25, 2026
38ad265
test(integration): add make target to run AWS LocalStack
renatomaia Jul 22, 2026
3abc81a
test(integration): fix AWS KMS subtest execution
renatomaia Aug 27, 2026
186055b
test(integration): use new style for Ethereum transactions for better…
renatomaia Aug 27, 2026
48c2a3a
test(integration): only run AWS Local Stack for required tests
renatomaia Aug 27, 2026
7b1d009
test(integration): fix clean up of configurations used in tests
renatomaia Aug 27, 2026
914dace
test(integration): isolate AWS KMS funding account
renatomaia Aug 27, 2026
78d2ed4
test(integration): allow built-in readness check of AWS LocalStack image
renatomaia Aug 27, 2026
588fec0
test(integration): clean up AWS KMS test resources
renatomaia Aug 27, 2026
8eac70d
test(integration): fail AWS KMS test when misconfigured
renatomaia Sep 1, 2026
2531d77
fix(kms): remove redundant configuration variables for AWS KMS
renatomaia Aug 25, 2026
b0057fb
fix(kms): handle invalid AWS KMS authentication
renatomaia Aug 26, 2026
d2b092f
test(kms): improve test coverage of AWS KMS authentication
renatomaia Aug 26, 2026
7c354c8
test(kms): fix test environment config cleanup
renatomaia Sep 1, 2026
1ea8e25
refactor(kms): fix small details in implementation and tests
renatomaia Aug 26, 2026
763847d
style(kms): avoid lint errors
renatomaia Aug 26, 2026
68f345a
feat(claimer,prt): log submitter identity on service startup
renatomaia Aug 24, 2026
550b164
fix(cli): use a single authentication for all deposit transactions
renatomaia Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
version: "2"
run:
build-tags:
- endtoendtests
linters:
enable:
- exhaustive
Expand Down
34 changes: 31 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,17 @@ 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 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"

start: start-postgres start-devnet ## Start the anvil devnet and PostgreSQL 16 docker containers

stop-devnet: ## Stop the anvil devnet docker container
Expand All @@ -524,7 +535,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
Expand Down Expand Up @@ -571,14 +585,15 @@ 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)$$
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.
Expand All @@ -600,7 +615,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)))
Expand All @@ -624,6 +639,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".
Expand Down Expand Up @@ -671,6 +689,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

Expand All @@ -681,6 +700,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

Expand Down Expand Up @@ -714,6 +734,14 @@ integration-test-local: build cartesi-rollups-machine-tool echo-dapp reject-loop
_local-topology-%:
@pattern='$(call run_pattern,$*)'; \
if [ -z "$$pattern" ]; then echo "skip: no applicable shards for topology '$*' (SHARD filter excludes all)"; exit 0; fi; \
if [ -n "$(filter awskms,$(SHARD))" ]; then \
if [ -z "$$LOCALSTACK_KMS_ENDPOINT" ]; then \
echo "ERROR: LOCALSTACK_KMS_ENDPOINT is required when SHARD includes awskms." >&2; \
echo "Run 'make start-awslocalstack' and export the variables it prints." >&2; \
exit 1; \
fi; \
export LOCALSTACK_KMS_REQUIRED=true; \
fi; \
cartesi-rollups-cli db init; \
test_ports="10000 10001 10002 10003 10004 10005 10006 10011 10012"; \
busy_pids="$$(for p in $$test_ports; do lsof -tiTCP:$$p -sTCP:LISTEN 2>/dev/null || true; done | sort -u | tr '\n' ' ')"; \
Expand Down
9 changes: 5 additions & 4 deletions cmd/cartesi-rollups-cli/root/deposit/deposit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/claimer/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/ethereum.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down
16 changes: 10 additions & 6 deletions internal/config/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,20 +64,20 @@ 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()
awsCfg, err := aws_cfg.LoadDefaultConfig(ctx)
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")
Expand Down
180 changes: 180 additions & 0 deletions internal/config/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// (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/accounts/abi/bind"
"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 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()
viper.AutomaticEnv()
t.Cleanup(func() {
viper.Reset()
viper.AutomaticEnv()
SetDefaults()
})
viper.Set(AUTH_KIND, "aws")
viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key")

// 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")
}

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))
}
Loading