Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 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,6 +535,9 @@ stop-devnet: ## Stop the anvil devnet docker container
stop-postgres: ## Stop the PostgreSQL 16 docker container
@docker stop postgres || true

stop-awslocalstack: ## 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
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
9 changes: 7 additions & 2 deletions internal/claimer/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -138,6 +142,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
29 changes: 22 additions & 7 deletions internal/config/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package auth

import (
"context"
"errors"
"fmt"
"math/big"

Expand All @@ -21,7 +22,17 @@ 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
}

authKind, err := GetAuthKind()
if err != nil {
return nil, err
Expand Down Expand Up @@ -60,21 +71,25 @@ 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
}
return signtx.CreateAWSTransactOptsFactory(
kmsClient := aws_kms.NewFromConfig(awsCfg)
factory, err := signtx.CreateAWSTransactOptsFactory(
ctx,
kmsConfig,
aws.String(authAwsKmsKeyId.Value),
types.NewEIP155Signer(chainId),
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")
}
Expand Down
175 changes: 175 additions & 0 deletions internal/config/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// (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()
t.Cleanup(viper.Reset)
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))
}
17 changes: 8 additions & 9 deletions internal/config/generate/Config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -360,18 +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"]
If set, the node will use the AWS KMS service with this key to sign transactions.

[auth.CARTESI_AUTH_AWS_KMS_REGION]
go-type = "RedactedString"
description = """
An AWS KMS Region.
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.

Must be set alongside `CARTESI_AUTH_AWS_KMS_KEY_ID`."""
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"]

Expand Down
Loading
Loading