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
66 changes: 66 additions & 0 deletions funder/deployer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package funder

import (
"fmt"
"os"
"strings"

"github.com/ethereum/go-ethereum/crypto"

"github.com/sei-protocol/sei-load/config"
"github.com/sei-protocol/sei-load/types"
)

// Deployer returns the account that signs the run's contract deployments.
//
// With funding configured that account is the funding root: the one key the run
// knows to hold a balance, and the key this package already deploys Disperse
// from. Without funding — or under mock deploy, where no deployment reaches a
// chain — it is a fresh random account, payable only by a chain that credits
// unknown senders (a mock chain or a pre-funded genesis).
//
// It fails only when funding is configured and its root key cannot be read or
// parsed.
func Deployer(cfg *config.LoadConfig) (types.Account, error) {
if cfg.Funding == nil || cfg.MockDeploy {
return types.NewAccount(false), nil
}
return rootAccount(cfg.Funding)
}

// rootAccount resolves the configured root key into the account that spends it.
func rootAccount(fc *config.FundingConfig) (types.Account, error) {
rootKeyHex, err := resolveRootKey(fc)
if err != nil {
return types.Account{}, err
}
// TrimSpace: a SOPS-mounted key file commonly carries a trailing newline.
rootKey, err := crypto.HexToECDSA(strings.TrimPrefix(strings.TrimSpace(rootKeyHex), "0x"))
if err != nil {
return types.Account{}, fmt.Errorf("funder: parse root key: %w", err)
}
return types.AccountFromKey(rootKey, false), nil
}

// resolveRootKey reads the hex root key from the configured file, else from the
// configured environment variable.
func resolveRootKey(fc *config.FundingConfig) (string, error) {
if fc.RootKeyFile != "" {
b, err := os.ReadFile(fc.RootKeyFile)
if err != nil {
return "", fmt.Errorf("funder: read rootKeyFile: %w", err)
}
if len(strings.TrimSpace(string(b))) == 0 {
return "", fmt.Errorf("funder: rootKeyFile %s is empty", fc.RootKeyFile)
}
return string(b), nil
}
if fc.RootKeyEnv != "" {
v := os.Getenv(fc.RootKeyEnv)
if v == "" {
return "", fmt.Errorf("funder: env %s is empty", fc.RootKeyEnv)
}
return v, nil
}
return "", fmt.Errorf("funder: no root key (set funding.rootKeyFile or funding.rootKeyEnv)")
}
140 changes: 140 additions & 0 deletions funder/deployer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package funder_test

import (
"encoding/hex"
"os"
"path/filepath"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/require"

"github.com/sei-protocol/sei-load/config"
"github.com/sei-protocol/sei-load/funder"
"github.com/sei-protocol/sei-load/types"
)

// testKey returns a private key and its hex encoding.
func testKey(t *testing.T) (types.Account, string) {
t.Helper()
key, err := crypto.GenerateKey()
require.NoError(t, err)
return types.AccountFromKey(key, false), hex.EncodeToString(crypto.FromECDSA(key))
}

// writeKeyFile writes keyHex to a file the way a mounted secret carries it.
func writeKeyFile(t *testing.T, keyHex string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "root-key.hex")
require.NoError(t, os.WriteFile(path, []byte(keyHex), 0o600))
return path
}

// With funding configured the deployer is the root key, whichever way the key
// is supplied and however the secret is padded.
func TestDeployerIsTheFundingRoot(t *testing.T) {
root, keyHex := testKey(t)

t.Run("file", func(t *testing.T) {
cfg := &config.LoadConfig{Funding: &config.FundingConfig{
RootKeyFile: writeKeyFile(t, keyHex),
}}
deployer, err := funder.Deployer(cfg)
require.NoError(t, err)
require.Equal(t, root.Address, deployer.Address)
require.NotNil(t, deployer.PrivKey)
require.False(t, deployer.Tracked, "the deployer is not a pool account")
})

t.Run("file with 0x prefix and trailing newline", func(t *testing.T) {
cfg := &config.LoadConfig{Funding: &config.FundingConfig{
RootKeyFile: writeKeyFile(t, "0x"+keyHex+"\n"),
}}
deployer, err := funder.Deployer(cfg)
require.NoError(t, err)
require.Equal(t, root.Address, deployer.Address)
})

t.Run("env", func(t *testing.T) {
t.Setenv("SEILOAD_TEST_ROOT_KEY", keyHex)
cfg := &config.LoadConfig{Funding: &config.FundingConfig{
RootKeyEnv: "SEILOAD_TEST_ROOT_KEY",
}}
deployer, err := funder.Deployer(cfg)
require.NoError(t, err)
require.Equal(t, root.Address, deployer.Address)
})
}

// Without funding the deployer is a fresh key: a chain that credits unknown
// senders pays for it, and no two runs share it.
func TestDeployerWithoutFundingIsFresh(t *testing.T) {
cfg := &config.LoadConfig{}

first, err := funder.Deployer(cfg)
require.NoError(t, err)
second, err := funder.Deployer(cfg)
require.NoError(t, err)

require.NotNil(t, first.PrivKey)
require.NotEqual(t, common.Address{}, first.Address)
require.NotEqual(t, first.Address, second.Address)
}

// Mock deploy never reaches a chain, so it never reads the root key: a dry run
// works on a host that has no key mounted.
func TestDeployerUnderMockDeployIgnoresTheRootKey(t *testing.T) {
root, keyHex := testKey(t)
cfg := &config.LoadConfig{
MockDeploy: true,
Funding: &config.FundingConfig{RootKeyFile: writeKeyFile(t, keyHex) + ".absent"},
}

deployer, err := funder.Deployer(cfg)
require.NoError(t, err)
require.NotNil(t, deployer.PrivKey)
require.NotEqual(t, root.Address, deployer.Address)
}

// An unusable root key fails at resolution, where the run can still report it.
func TestDeployerRootKeyErrors(t *testing.T) {
_, keyHex := testKey(t)

for _, tc := range []struct {
name string
funding *config.FundingConfig
want string
}{
{
name: "no key source",
funding: &config.FundingConfig{},
want: "no root key",
},
{
name: "missing file",
funding: &config.FundingConfig{RootKeyFile: writeKeyFile(t, keyHex) + ".absent"},
want: "read rootKeyFile",
},
{
name: "empty file",
funding: &config.FundingConfig{RootKeyFile: writeKeyFile(t, "\n")},
want: "is empty",
},
{
name: "not a key",
funding: &config.FundingConfig{RootKeyFile: writeKeyFile(t, "not-a-key")},
want: "parse root key",
},
{
name: "empty env",
funding: &config.FundingConfig{RootKeyEnv: "SEILOAD_TEST_ROOT_KEY_UNSET"},
want: "is empty",
},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := funder.Deployer(&config.LoadConfig{Funding: tc.funding})
require.ErrorContains(t, err, tc.want)
})
}
}
45 changes: 33 additions & 12 deletions funder/doc.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Package funder funds seiload's generated account pool from a single root key
// so a load run can execute against a real chain.
// Package funder owns seiload's funded identity: it resolves the root key and
// spends it, both to fund the generated account pool and to name the account
// that signs the run's contract deployments, so a load run can execute against
// a real chain.
//
// # Why
//
Expand All @@ -12,9 +14,8 @@
//
// # Flow
//
// FundAccounts runs once at startup, after the generator and sender are built
// and before prewarm and dispatch (both spend gas the accounts don't have until
// funded):
// FundAccounts runs once at startup, after the generator is built and before
// prewarm and dispatch (both spend gas the accounts don't have until funded):
//
// 1. Resolve the root key (rootKeyFile, preferred; or rootKeyEnv).
// 2. Dial the EVM RPC and enumerate every account across the pools.
Expand All @@ -23,17 +24,37 @@
// 4. Deploy a fresh Disperse contract.
// 5. disperseEther the per-account amount to the underfunded set, in batches.
//
// # The contract deployer
//
// Deployer names the account that signs the run's contract deployments, and the
// generator receives it (see the generator package doc). With funding configured
// that account is the root: paying for a deployment is a funding concern, and
// the root is the one key a run knows to hold a balance. Without funding it is a
// fresh random account, payable only by a chain that credits unknown senders.
// Scenario contracts are deployer-neutral — none of them gates a load
// transaction on who deployed it — so the choice costs the workload nothing.
//
// # One nonce stream
//
// When the root is also the deployer, the scenario deployments and this
// package's Disperse deployment plus disperseEther batches are one EVM nonce
// stream on one key. Both phases run on the startup goroutine, in sequence, and
// each awaits its receipt before it sends the next tx, so every tx reads a
// pending nonce that already counts the one before it. Overlapping the phases,
// or pinning auth.Nonce in either, collides them.
//
// # Cosmos to EVM association
//
// The root is a single secp256k1 key with both a cosmos (sei1) and an EVM (0x)
// representation. Its usei must be EVM-spendable, which on Sei requires the
// account to be associated. The Disperse deploy is the root's first EVM tx, and
// the Sei ante handler auto-associates the sender on its first EVM tx — pulling
// the cosmos balance to the EVM side within that tx. So no explicit association
// step is needed, provided the root is funded at its EVM (cast) address or is
// already associated. Recipients receive native value via the Disperse
// contract, which credits their EVM balance directly; each self-associates on
// its own first load tx.
// account to be associated. The Sei ante handler auto-associates the sender on
// its first EVM tx — pulling the cosmos balance to the EVM side within that tx
// — and the root's first EVM tx of a run is a deploy: a scenario contract when
// the profile has one, else Disperse. So no explicit association step is
// needed, provided the root is funded at its EVM (cast) address or is already
// associated. Recipients receive native value via the Disperse contract, which
// credits their EVM balance directly; each self-associates on its own first
// load tx.
//
// # Self-deploy, not a configured address
//
Expand Down
Loading
Loading