diff --git a/MakefileEks.mk b/MakefileEks.mk index 96e62d9b..b2c3c7a3 100644 --- a/MakefileEks.mk +++ b/MakefileEks.mk @@ -121,6 +121,8 @@ build-bk-prod-morph-prod-mainnet-to-morph-token-price-oracle: if [ ! -d dist ]; then mkdir -p dist; fi env GO111MODULE=on CGO_LDFLAGS="-ldl" CGO_ENABLED=1 go build -v $(LDFLAGS) -o token-price-oracle/token-price-oracle ./token-price-oracle/cmd cp token-price-oracle/token-price-oracle dist/ + aws s3 cp s3://morph-0582-morph-technical-department-mainnet-data/morph-setup/secret-manager-wrapper.tar.gz ./ + tar -xvzf secret-manager-wrapper.tar.gz start-bk-prod-morph-prod-mainnet-to-morph-token-price-oracle: @@ -202,4 +204,4 @@ build-bk-prod-morph-prod-testnet-to-morph-staking-oracle-holesky: tar -xvzf secret-manager-wrapper.tar.gz start-bk-prod-morph-prod-testnet-to-morph-staking-oracle-holesky: - /data/secret-manager-wrapper ./staking-oracle \ No newline at end of file + /data/secret-manager-wrapper ./staking-oracle diff --git a/token-price-oracle/README.md b/token-price-oracle/README.md index 4c45911d..0fa98796 100644 --- a/token-price-oracle/README.md +++ b/token-price-oracle/README.md @@ -1,173 +1,190 @@ -# Gas Price Oracle +# Token Price Oracle -Gas Price Oracle service monitors L1 gas prices and updates the GasPriceOracle contract on L2. +Token Price Oracle service monitors token prices and updates the price ratio between tokens and ETH to L2 on-chain contracts, enabling Alt Fee Token functionality. ## Features -- **L1 Base Fee Update**: Monitors L1 base fee and blob base fee, updates to L2 -- **Scalar Update**: Calculates and updates commit scalar and blob scalar -- **Transaction Manager**: Serializes all contract updates to avoid nonce conflicts -- **Metrics Monitoring**: Exposes Prometheus metrics -- **Flags Configuration**: Uses `urfave/cli` for configuration management (supports both CLI flags and environment variables) +- **Real-time Price Monitoring**: Fetches token USD prices from exchange APIs (Bitget) +- **Price Ratio Calculation**: Computes price ratio between tokens and ETH +- **Threshold-based Updates**: Only updates on-chain when price change exceeds threshold, saving Gas +- **Batch Updates**: Updates multiple token prices in a single `batchUpdatePrices` transaction +- **Fallback Mechanism**: Supports automatic switching between multiple data sources +- **Transaction Management**: Prevents nonce conflicts, supports local and external signing +- **Prometheus Monitoring**: Provides operational metrics -## Configuration - -The service uses flags that can be set either via command line or environment variables (with `GAS_ORACLE_` prefix). - -### Required Flags - -| Flag | Env Var | Description | -| --------------------- | --------------------------- | ------------------------------- | -| `--l1-eth-rpc` | `GAS_ORACLE_L1_ETH_RPC` | L1 RPC endpoint | -| `--l2-eth-rpc` | `GAS_ORACLE_L2_ETH_RPC` | L2 RPC endpoint | -| `--l1-beacon-rpc` | `GAS_ORACLE_L1_BEACON_RPC` | L1 Beacon Chain API endpoint | -| `--l1-rollup-address` | `GAS_ORACLE_L1_ROLLUP` | L1 Rollup contract address | -| `--private-key` | `GAS_ORACLE_L2_PRIVATE_KEY` | Private key for L2 transactions | - -### Optional Flags +## Quick Start -| Flag | Env Var | Default | Description | -| ------------------------------- | ---------------------------------- | --------------- | --------------------------- | -| `--l2-gas-price-oracle-address` | `GAS_ORACLE_L2_GAS_PRICE_ORACLE` | `0x5300...0002` | L2 GasPriceOracle contract | -| `--gas-threshold` | `GAS_ORACLE_GAS_THRESHOLD` | `10` | Update threshold percentage | -| `--interval` | `GAS_ORACLE_INTERVAL` | `6s` | Base fee update interval | -| `--overhead-interval` | `GAS_ORACLE_OVERHEAD_INTERVAL` | `10` | Scalar update frequency | -| `--txn-per-batch` | `GAS_ORACLE_TXN_PER_BATCH` | `50` | Expected txs per batch | -| `--log-level` | `GAS_ORACLE_LOG_LEVEL` | `info` | Log level | -| `--log-filename` | `GAS_ORACLE_LOG_FILENAME` | - | Log file path | -| `--metrics-server-enable` | `GAS_ORACLE_METRICS_SERVER_ENABLE` | `false` | Enable metrics server | -| `--metrics-hostname` | `GAS_ORACLE_METRICS_HOSTNAME` | `0.0.0.0` | Metrics server host | -| `--metrics-port` | `GAS_ORACLE_METRICS_PORT` | `6060` | Metrics server port | - -## Usage - -### Command Line +### Environment Variables (Local Signing Mode) ```bash -./bin/token-price-oracle \ - --l1-eth-rpc https://ethereum-rpc.com \ - --l2-eth-rpc https://morph-l2-rpc.com \ - --l1-beacon-rpc https://beacon-api.com \ - --l1-rollup-address 0x... \ - --private-key 0x... \ - --metrics-server-enable \ - --log-level debug +# Required +export TOKEN_PRICE_ORACLE_L2_ETH_RPC="https://rpc.morphl2.io" +export TOKEN_PRICE_ORACLE_PRIVATE_KEY="0x..." # Required for local signing only +export TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL="https://api.bitget.com" +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET="1:BTCUSDT,2:ETHUSDT" + +# Optional +export TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL="1m" +export TOKEN_PRICE_ORACLE_PRICE_THRESHOLD="100" # 1% (100 bps) +export TOKEN_PRICE_ORACLE_METRICS_SERVER_ENABLE="true" +export TOKEN_PRICE_ORACLE_METRICS_PORT="6060" +export TOKEN_PRICE_ORACLE_LOG_LEVEL="info" ``` -### Environment Variables - -```bash -export GAS_ORACLE_L1_ETH_RPC="https://ethereum-rpc.com" -export GAS_ORACLE_L2_ETH_RPC="https://morph-l2-rpc.com" -export GAS_ORACLE_L1_BEACON_RPC="https://beacon-api.com" -export GAS_ORACLE_L1_ROLLUP="0x..." -export GAS_ORACLE_L2_PRIVATE_KEY="0x..." -export GAS_ORACLE_METRICS_SERVER_ENABLE=true -export GAS_ORACLE_LOG_LEVEL=info - -./bin/token-price-oracle -``` +> **Note**: `PRIVATE_KEY` is only required when using local signing mode. For production, use [External Signing](#external-signing-recommended-for-production) instead. -## Build and Run - -**Note**: This project uses Go workspace and depends on `../bindings` module. +### Build and Run ```bash # Build make build # Run -make run - -# Test -make test +./build/bin/token-price-oracle -# Test Bitget price feed (requires network) -go test ./client -run TestBitgetPriceFeed -v - -# Docker +# Or use Docker make docker-build docker run -d \ - -e GAS_ORACLE_L1_ETH_RPC="..." \ - -e GAS_ORACLE_L2_ETH_RPC="..." \ - -e GAS_ORACLE_L1_BEACON_RPC="..." \ - -e GAS_ORACLE_L1_ROLLUP="0x..." \ - -e GAS_ORACLE_L2_PRIVATE_KEY="0x..." \ + -e TOKEN_PRICE_ORACLE_L2_ETH_RPC="..." \ + -e TOKEN_PRICE_ORACLE_PRIVATE_KEY="..." \ + -e TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL="..." \ + -e TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET="..." \ morph/token-price-oracle:latest ``` -## Monitoring +## Configuration -When metrics server is enabled, it exposes metrics at `:/metrics`: +### Required (All Modes) -- `l1_base_fee` - L1 base fee (Gwei) -- `l1_base_fee_on_l2` - L1 base fee on L2 -- `l1_blob_base_fee_on_l2` - L1 blob base fee on L2 -- `commit_scalar` - Commit scalar value -- `blob_scalar` - Blob scalar value -- `txn_per_batch` - Transactions per batch -- `gas_oracle_owner_balance` - Oracle account balance -- `base_fee_update_count` - Total base fee updates -- `scalar_update_count` - Total scalar updates -- `update_errors_total` - Update errors by type +| Environment Variable | Description | +|---------------------|-------------| +| `TOKEN_PRICE_ORACLE_L2_ETH_RPC` | L2 node RPC endpoint | +| `TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL` | Bitget API base URL | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET` | TokenID to trading pair mapping | -Health check endpoint: `:/health` +### Required (Local Signing Mode Only) -## Architecture +| Environment Variable | Description | +|---------------------|-------------| +| `TOKEN_PRICE_ORACLE_PRIVATE_KEY` | Signing private key (not needed if using external signing) | -``` -gas-price-oracle/ -├── cmd/ # Main entry point -├── flags/ # CLI flags definitions -├── config/ # Configuration from flags -├── updater/ # Update implementations -│ ├── basefee.go # Base fee updater -│ ├── scalar.go # Scalar updater -│ └── tx_manager.go # Transaction manager (prevents nonce conflicts) -├── client/ # Client wrappers -├── calc/ # Calculation logic -└── metrics/ # Prometheus metrics +### Optional + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL` | `1m` | Price update interval | +| `TOKEN_PRICE_ORACLE_PRICE_THRESHOLD` | `100` | Update threshold (basis points, 100=1%) | +| `TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY` | `bitget` | Price feed priority | +| `TOKEN_PRICE_ORACLE_METRICS_SERVER_ENABLE` | `false` | Enable metrics server | +| `TOKEN_PRICE_ORACLE_METRICS_HOSTNAME` | `0.0.0.0` | Metrics server hostname | +| `TOKEN_PRICE_ORACLE_METRICS_PORT` | `6060` | Metrics server port | +| `TOKEN_PRICE_ORACLE_LOG_LEVEL` | `info` | Log level | +| `TOKEN_PRICE_ORACLE_LOG_FILENAME` | - | Log file path | + +### External Signing (Recommended for Production) + +| Environment Variable | Description | +|---------------------|-------------| +| `TOKEN_PRICE_ORACLE_EXTERNAL_SIGN` | Enable external signing (`true`/`false`) | +| `TOKEN_PRICE_ORACLE_EXTERNAL_SIGN_ADDRESS` | Signing account address | +| `TOKEN_PRICE_ORACLE_EXTERNAL_SIGN_APPID` | External signing service AppID | +| `TOKEN_PRICE_ORACLE_EXTERNAL_SIGN_CHAIN` | Chain identifier | +| `TOKEN_PRICE_ORACLE_EXTERNAL_SIGN_URL` | External signing service URL | +| `TOKEN_PRICE_ORACLE_EXTERNAL_SIGN_RSA_PRIV` | RSA private key (PEM format) | + +## Price Calculation -Uses: ../bindings/bindings (project root contract bindings) +### Price Ratio Formula + +``` +priceRatio = tokenScale × tokenPriceUSD × 10^(18 - tokenDecimals) / ethPriceUSD ``` -## Key Components +### Threshold -### Transaction Manager +Threshold is specified in basis points (bps): +- 1 bps = 0.01% +- 100 bps = 1% +- 10000 bps = 100% -All contract updates are serialized through `TxManager` to prevent nonce conflicts: +On-chain prices are only updated when price change exceeds the threshold, avoiding unnecessary Gas costs. -- Holds a mutex to ensure only one transaction is sent at a time -- Manages nonce retrieval and transaction confirmation -- Used by both `BaseFeeUpdater` and `ScalarUpdater` +## Monitoring -### Base Fee Updater +### Prometheus Metrics -- Runs on a fixed interval (default 6s) -- Fetches L1 base fee and blob base fee -- Updates L2 contract when threshold is exceeded +When metrics server is enabled, access `http://:/metrics`: -### Scalar Updater +| Metric | Type | Description | +|--------|------|-------------| +| `last_successful_update_timestamp` | Gauge | Last successful update timestamp | +| `updates_total{type="updated"}` | Counter | Actual update count | +| `updates_total{type="skipped"}` | Counter | Skipped update count | +| `update_errors_total{type="price"}` | Counter | Update error count | +| `account_balance_eth` | Gauge | Oracle account balance | -- Runs every N base fee update cycles (default 10) -- Reads `CommitBatch` events from L1 Rollup -- Calculates commit and blob scalars -- Updates L2 contract when necessary +### Health Check -### Blob Processing +```bash +curl http://:/health +``` -Blob data processing is partially implemented (interface defined in `calc/blob.go`). The actual blob parsing and L2 transaction extraction is deferred for future implementation. +### Suggested Alert Rules + +```yaml +# Price not updated for a long time +- alert: TokenPriceOracleStalled + expr: time() - last_successful_update_timestamp > 300 + for: 1m + labels: + severity: critical + +# Low account balance +- alert: TokenPriceOracleLowBalance + expr: account_balance_eth < 0.1 + for: 5m + labels: + severity: warning +``` + +## Project Structure -## Testing +``` +token-price-oracle/ +├── cmd/ # Entry point +├── flags/ # CLI flags definition +├── config/ # Configuration loading +├── client/ # Client wrappers +│ ├── l2_client.go # L2 chain client +│ ├── price_feed.go # Price feed interface +│ ├── bitget_sdk.go # Bitget API client +│ └── sign.go # External signing +├── updater/ # Update logic +│ ├── token_price.go # Price updater +│ ├── tx_manager.go # Transaction manager +│ └── factory.go # Factory methods +├── metrics/ # Prometheus metrics +└── README.md # This document +``` + +## Development ```bash -# Run all tests -go test ./... +# Run tests +make test # Test Bitget price feed (requires network) go test ./client -run TestBitgetPriceFeed -v -# Skip integration tests -go test ./... -short +# Format code +go fmt ./... + +# Local run +cp env.example .env +# Edit .env configuration +source .env && make run ``` +## License + +MIT diff --git a/token-price-oracle/client/l2_client.go b/token-price-oracle/client/l2_client.go index 25b1b7f6..a69ea044 100644 --- a/token-price-oracle/client/l2_client.go +++ b/token-price-oracle/client/l2_client.go @@ -5,21 +5,27 @@ import ( "fmt" "math/big" + "github.com/morph-l2/externalsign" "github.com/morph-l2/go-ethereum/accounts/abi/bind" "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/core/types" "github.com/morph-l2/go-ethereum/crypto" "github.com/morph-l2/go-ethereum/ethclient" + "github.com/morph-l2/go-ethereum/log" + "morph-l2/token-price-oracle/config" ) // L2Client wraps L2 chain client type L2Client struct { - client *ethclient.Client - chainID *big.Int - opts *bind.TransactOpts + client *ethclient.Client + chainID *big.Int + opts *bind.TransactOpts + signer *Signer + externalSign bool } // NewL2Client creates new L2 client -func NewL2Client(rpcURL string, privateKey string) (*L2Client, error) { +func NewL2Client(rpcURL string, cfg *config.Config) (*L2Client, error) { client, err := ethclient.Dial(rpcURL) if err != nil { return nil, fmt.Errorf("failed to dial L2 RPC: %w", err) @@ -38,27 +44,75 @@ func NewL2Client(rpcURL string, privateKey string) (*L2Client, error) { return nil, fmt.Errorf("failed to get chain ID: %w", err) } - // Parse private key (remove 0x prefix if present) - privateKeyHex := privateKey - if len(privateKey) > 2 && privateKey[:2] == "0x" { - privateKeyHex = privateKey[2:] - } - key, err := crypto.HexToECDSA(privateKeyHex) - if err != nil { - return nil, fmt.Errorf("failed to parse private key: %w", err) + l2Client := &L2Client{ + client: client, + chainID: chainID, + externalSign: cfg.ExternalSign, } - // Create transaction options - opts, err := bind.NewKeyedTransactorWithChainID(key, chainID) - if err != nil { - return nil, fmt.Errorf("failed to create transactor: %w", err) + if cfg.ExternalSign { + // External sign mode + rsaPriv, err := externalsign.ParseRsaPrivateKey(cfg.ExternalSignRsaPriv) + if err != nil { + return nil, fmt.Errorf("failed to parse RSA private key: %w", err) + } + + l2Client.signer = NewSigner( + true, + cfg.ExternalSignAppid, + rsaPriv, + cfg.ExternalSignAddress, + cfg.ExternalSignChain, + cfg.ExternalSignUrl, + chainID, + ) + + fromAddr := common.HexToAddress(cfg.ExternalSignAddress) + ethSigner := types.NewLondonSigner(chainID) + + // Create opts with a placeholder signer for building transactions. + // This allows contract bindings to construct transaction objects so we can + // extract the calldata and target address. The placeholder signature is never + // actually broadcast - the real signing happens via external signer. + // SAFETY: NoSend is always true, and tx_manager.go only extracts To() and Data() + // from the placeholder tx, then creates a new properly signed transaction. + l2Client.opts = &bind.TransactOpts{ + From: fromAddr, + NoSend: true, // CRITICAL: Must always be true for external signing mode + Signer: func(address common.Address, tx *types.Transaction) (*types.Transaction, error) { + // Placeholder signer - returns tx with dummy signature to satisfy bind package. + // This tx is NEVER sent; only used to extract calldata for external signing. + return tx.WithSignature(ethSigner, make([]byte, 65)) + }, + } + + log.Info("L2 client initialized with external signing", + "address", cfg.ExternalSignAddress, + "chainID", chainID) + } else { + // Local private key mode + privateKeyHex := cfg.PrivateKey + if len(cfg.PrivateKey) > 2 && cfg.PrivateKey[:2] == "0x" { + privateKeyHex = cfg.PrivateKey[2:] + } + key, err := crypto.HexToECDSA(privateKeyHex) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + // Create transaction options + opts, err := bind.NewKeyedTransactorWithChainID(key, chainID) + if err != nil { + return nil, fmt.Errorf("failed to create transactor: %w", err) + } + l2Client.opts = opts + + log.Info("L2 client initialized with local signing", + "address", opts.From.Hex(), + "chainID", chainID) } - return &L2Client{ - client: client, - chainID: chainID, - opts: opts, - }, nil + return l2Client, nil } // Close closes client connection @@ -98,3 +152,18 @@ func (c *L2Client) GetBalance(ctx context.Context, address common.Address) (*big func (c *L2Client) WalletAddress() common.Address { return c.opts.From } + +// IsExternalSign returns whether external signing is enabled +func (c *L2Client) IsExternalSign() bool { + return c.externalSign +} + +// GetSigner returns the external signer (nil if using local signing) +func (c *L2Client) GetSigner() *Signer { + return c.signer +} + +// GetChainID returns the chain ID +func (c *L2Client) GetChainID() *big.Int { + return c.chainID +} diff --git a/token-price-oracle/client/sign.go b/token-price-oracle/client/sign.go new file mode 100644 index 00000000..340e3214 --- /dev/null +++ b/token-price-oracle/client/sign.go @@ -0,0 +1,158 @@ +package client + +import ( + "context" + "crypto/rsa" + "fmt" + "math/big" + + "github.com/morph-l2/externalsign" + "github.com/morph-l2/go-ethereum" + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/core/types" + "github.com/morph-l2/go-ethereum/log" +) + +// Signer handles transaction signing with support for both local and external signing +type Signer struct { + externalSign bool + externalSigner *externalsign.ExternalSign + externalSignUrl string + externalSignAddress common.Address + chainID *big.Int + signer types.Signer +} + +// NewSigner creates a new Signer instance +func NewSigner( + externalSign bool, + externalSignAppid string, + externalRsaPriv *rsa.PrivateKey, + externalSignAddress string, + externalSignChain string, + externalSignUrl string, + chainID *big.Int, +) *Signer { + signer := types.NewLondonSigner(chainID) + + s := &Signer{ + externalSign: externalSign, + externalSignUrl: externalSignUrl, + externalSignAddress: common.HexToAddress(externalSignAddress), + chainID: chainID, + signer: signer, + } + + if externalSign { + s.externalSigner = externalsign.NewExternalSign( + externalSignAppid, + externalRsaPriv, + externalSignAddress, + externalSignChain, + signer, + ) + log.Info("External signer initialized", + "address", externalSignAddress, + "chain", externalSignChain) + } + + return s +} + +// Sign signs a transaction using either external or local signing +func (s *Signer) Sign(tx *types.Transaction) (*types.Transaction, error) { + if !s.externalSign { + return nil, fmt.Errorf("local signing not supported in Signer, use bind.TransactOpts") + } + + signedTx, err := s.externalSigner.RequestSign(s.externalSignUrl, tx) + if err != nil { + return nil, fmt.Errorf("external sign request failed: %w", err) + } + return signedTx, nil +} + +// IsExternalSign returns whether external signing is enabled +func (s *Signer) IsExternalSign() bool { + return s.externalSign +} + +// GetFromAddress returns the signer's address +func (s *Signer) GetFromAddress() common.Address { + return s.externalSignAddress +} + +// CreateAndSignTx creates a new transaction and signs it +func (s *Signer) CreateAndSignTx( + ctx context.Context, + client *L2Client, + to common.Address, + callData []byte, +) (*types.Transaction, error) { + from := s.externalSignAddress + + nonce, err := client.GetClient().NonceAt(ctx, from, nil) + if err != nil { + return nil, fmt.Errorf("failed to get nonce: %w", err) + } + + // Get gas tip cap + tip, err := client.GetClient().SuggestGasTipCap(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get gas tip cap: %w", err) + } + + // Get base fee from latest block + head, err := client.GetClient().HeaderByNumber(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to get block header: %w", err) + } + + var gasFeeCap *big.Int + if head.BaseFee != nil { + gasFeeCap = new(big.Int).Add( + tip, + new(big.Int).Mul(head.BaseFee, big.NewInt(2)), + ) + } else { + gasFeeCap = new(big.Int).Set(tip) + } + + // Estimate gas + gas, err := client.GetClient().EstimateGas(ctx, ethereum.CallMsg{ + From: from, + To: &to, + GasFeeCap: gasFeeCap, + GasTipCap: tip, + Data: callData, + }) + if err != nil { + return nil, fmt.Errorf("failed to estimate gas: %w", err) + } + + // Add 50% buffer to gas estimate + gas = gas * 3 / 2 + + // Create transaction + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: s.chainID, + Nonce: nonce, + GasTipCap: tip, + GasFeeCap: gasFeeCap, + Gas: gas, + To: &to, + Data: callData, + }) + + log.Info("Created transaction for signing", + "from", from.Hex(), + "to", to.Hex(), + "nonce", nonce, + "gas", gas, + "gasFeeCap", gasFeeCap, + "gasTipCap", tip) + + // Sign transaction + return s.Sign(tx) +} + diff --git a/token-price-oracle/cmd/main.go b/token-price-oracle/cmd/main.go index f5cf155a..f2ff3c84 100644 --- a/token-price-oracle/cmd/main.go +++ b/token-price-oracle/cmd/main.go @@ -104,7 +104,7 @@ func Main(cliCtx *cli.Context) error { } // Create L2 client - l2Client, err := client.NewL2Client(cfg.L2RPC, cfg.PrivateKey) + l2Client, err := client.NewL2Client(cfg.L2RPC, cfg) if err != nil { return fmt.Errorf("failed to create L2 client: %w", err) } diff --git a/token-price-oracle/config/config.go b/token-price-oracle/config/config.go index 9420fbb7..c66b69e9 100644 --- a/token-price-oracle/config/config.go +++ b/token-price-oracle/config/config.go @@ -65,6 +65,14 @@ type Config struct { BitgetAPIBaseURL string // Bitget API base URL BinanceAPIBaseURL string // Binance API base URL + // External sign + ExternalSign bool + ExternalSignAddress string + ExternalSignAppid string + ExternalSignChain string + ExternalSignUrl string + ExternalSignRsaPriv string + // Metrics MetricsServerEnable bool MetricsHostname string @@ -85,6 +93,14 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { L2RPC: ctx.String(flags.L2EthRPCFlag.Name), PrivateKey: ctx.String(flags.PrivateKeyFlag.Name), + // External sign + ExternalSign: ctx.Bool(flags.ExternalSignFlag.Name), + ExternalSignAddress: ctx.String(flags.ExternalSignAddressFlag.Name), + ExternalSignAppid: ctx.String(flags.ExternalSignAppidFlag.Name), + ExternalSignChain: ctx.String(flags.ExternalSignChainFlag.Name), + ExternalSignUrl: ctx.String(flags.ExternalSignUrlFlag.Name), + ExternalSignRsaPriv: ctx.String(flags.ExternalSignRsaPrivFlag.Name), + MetricsServerEnable: ctx.Bool(flags.MetricsServerEnableFlag.Name), MetricsHostname: ctx.String(flags.MetricsHostnameFlag.Name), MetricsPort: ctx.Uint64(flags.MetricsPortFlag.Name), @@ -208,6 +224,26 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { } } + // Validate external sign config + if cfg.ExternalSign { + if cfg.ExternalSignAddress == "" || cfg.ExternalSignUrl == "" || + cfg.ExternalSignAppid == "" || cfg.ExternalSignChain == "" || + cfg.ExternalSignRsaPriv == "" { + return nil, fmt.Errorf("external sign is enabled but missing required config: address=%s, url=%s, appid=%s, chain=%s, rsa_priv_set=%t", + cfg.ExternalSignAddress, cfg.ExternalSignUrl, cfg.ExternalSignAppid, cfg.ExternalSignChain, cfg.ExternalSignRsaPriv != "") + } + + // Validate address format + if !common.IsHexAddress(cfg.ExternalSignAddress) { + return nil, fmt.Errorf("invalid external sign address format: %s", cfg.ExternalSignAddress) + } + } else { + // If not using external sign, private key is required + if cfg.PrivateKey == "" { + return nil, fmt.Errorf("private key is required when external sign is not enabled") + } + } + return cfg, nil } diff --git a/token-price-oracle/flags/flags.go b/token-price-oracle/flags/flags.go index 5cd59853..785783e1 100644 --- a/token-price-oracle/flags/flags.go +++ b/token-price-oracle/flags/flags.go @@ -22,10 +22,9 @@ var ( } PrivateKeyFlag = cli.StringFlag{ - Name: "private-key", - Usage: "The private key to use for sending transactions to L2", - Required: true, - EnvVar: prefixEnvVar("PRIVATE_KEY"), + Name: "private-key", + Usage: "The private key to use for sending transactions to L2 (not required if external-sign is enabled)", + EnvVar: prefixEnvVar("PRIVATE_KEY"), } /* Optional Flags */ @@ -140,6 +139,43 @@ var ( Value: 6060, EnvVar: prefixEnvVar("METRICS_PORT"), } + + // External sign flags + ExternalSignFlag = cli.BoolFlag{ + Name: "external-sign", + Usage: "Enable external sign", + EnvVar: prefixEnvVar("EXTERNAL_SIGN"), + } + + ExternalSignAddressFlag = cli.StringFlag{ + Name: "external-sign-address", + Usage: "The address of the external signer", + EnvVar: prefixEnvVar("EXTERNAL_SIGN_ADDRESS"), + } + + ExternalSignAppidFlag = cli.StringFlag{ + Name: "external-sign-appid", + Usage: "The appid for external sign", + EnvVar: prefixEnvVar("EXTERNAL_SIGN_APPID"), + } + + ExternalSignChainFlag = cli.StringFlag{ + Name: "external-sign-chain", + Usage: "The chain identifier for external sign", + EnvVar: prefixEnvVar("EXTERNAL_SIGN_CHAIN"), + } + + ExternalSignUrlFlag = cli.StringFlag{ + Name: "external-sign-url", + Usage: "The URL of the external sign service", + EnvVar: prefixEnvVar("EXTERNAL_SIGN_URL"), + } + + ExternalSignRsaPrivFlag = cli.StringFlag{ + Name: "external-sign-rsa-priv", + Usage: "The RSA private key for external sign", + EnvVar: prefixEnvVar("EXTERNAL_SIGN_RSA_PRIV"), + } ) var requiredFlags = []cli.Flag{ @@ -166,6 +202,14 @@ var optionalFlags = []cli.Flag{ MetricsServerEnableFlag, MetricsHostnameFlag, MetricsPortFlag, + + // External sign + ExternalSignFlag, + ExternalSignAddressFlag, + ExternalSignAppidFlag, + ExternalSignChainFlag, + ExternalSignUrlFlag, + ExternalSignRsaPrivFlag, } // Flags contains the list of configuration options available to the binary. diff --git a/token-price-oracle/go.mod b/token-price-oracle/go.mod index 771a7715..135e7768 100644 --- a/token-price-oracle/go.mod +++ b/token-price-oracle/go.mod @@ -8,6 +8,7 @@ replace ( ) require ( + github.com/morph-l2/externalsign v0.3.1 github.com/morph-l2/go-ethereum v1.10.14-0.20251219060125-03910bc750a2 github.com/prometheus/client_golang v1.17.0 github.com/sirupsen/logrus v1.9.3 @@ -35,6 +36,7 @@ require ( github.com/go-kit/kit v0.12.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-resty/resty/v2 v2.13.1 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect @@ -57,7 +59,6 @@ require ( github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect diff --git a/token-price-oracle/go.sum b/token-price-oracle/go.sum index 933430e9..bd174e5a 100644 --- a/token-price-oracle/go.sum +++ b/token-price-oracle/go.sum @@ -67,6 +67,8 @@ github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KE github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= +github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= @@ -143,6 +145,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morph-l2/externalsign v0.3.1 h1:UYFDZFB0L85A4rDvuwLNBiGEi0kSmg9AZ2v8Q5O4dQo= +github.com/morph-l2/externalsign v0.3.1/go.mod h1:b6NJ4GUiiG/gcSJsp3p8ExsIs4ZdphlrVALASnVoGJE= github.com/morph-l2/go-ethereum v1.10.14-0.20251219060125-03910bc750a2 h1:FUv9gtnvF+1AVrkoNGYbVOesi7E+STjdfD2mcqVaEY0= github.com/morph-l2/go-ethereum v1.10.14-0.20251219060125-03910bc750a2/go.mod h1:tiFPeidxjoCmLj18ne9H3KQdIGTCvRC30qlef06Fd9M= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -233,24 +237,35 @@ github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3C github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -259,16 +274,26 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= @@ -276,6 +301,8 @@ golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/token-price-oracle/updater/tx_manager.go b/token-price-oracle/updater/tx_manager.go index 87a3c1dc..ebed4939 100644 --- a/token-price-oracle/updater/tx_manager.go +++ b/token-price-oracle/updater/tx_manager.go @@ -33,6 +33,14 @@ func (m *TxManager) SendTransaction(ctx context.Context, txFunc func(*bind.Trans m.mu.Lock() defer m.mu.Unlock() + if m.l2Client.IsExternalSign() { + return m.sendWithExternalSign(ctx, txFunc) + } + return m.sendWithLocalSign(ctx, txFunc) +} + +// sendWithLocalSign sends transaction using local private key signing +func (m *TxManager) sendWithLocalSign(ctx context.Context, txFunc func(*bind.TransactOpts) (*types.Transaction, error)) (*types.Receipt, error) { fromAddr := m.l2Client.WalletAddress() // Check if there are pending transactions by comparing nonces @@ -84,7 +92,7 @@ func (m *TxManager) SendTransaction(ctx context.Context, txFunc func(*bind.Trans return nil, err } - log.Info("Transaction sent", + log.Info("Transaction sent (local sign)", "tx_hash", tx.Hash().Hex(), "nonce", tx.Nonce(), "gas_limit", tx.Gas()) @@ -101,6 +109,90 @@ func (m *TxManager) SendTransaction(ctx context.Context, txFunc func(*bind.Trans return receipt, nil } +// sendWithExternalSign sends transaction using external signing service +func (m *TxManager) sendWithExternalSign(ctx context.Context, txFunc func(*bind.TransactOpts) (*types.Transaction, error)) (*types.Receipt, error) { + signer := m.l2Client.GetSigner() + if signer == nil { + return nil, fmt.Errorf("external signer is not initialized") + } + + fromAddr := m.l2Client.WalletAddress() + + // Check if there are pending transactions by comparing nonces + confirmedNonce, err := m.l2Client.GetClient().NonceAt(ctx, fromAddr, nil) + if err != nil { + return nil, fmt.Errorf("failed to get confirmed nonce: %w", err) + } + + pendingNonce, err := m.l2Client.GetClient().PendingNonceAt(ctx, fromAddr) + if err != nil { + return nil, fmt.Errorf("failed to get pending nonce: %w", err) + } + + if pendingNonce > confirmedNonce { + // There are pending transactions, don't send new one + log.Warn("Found pending transactions, skipping this round", + "address", fromAddr.Hex(), + "confirmed_nonce", confirmedNonce, + "pending_nonce", pendingNonce, + "pending_count", pendingNonce-confirmedNonce) + return nil, fmt.Errorf("pending transactions exist (confirmed: %d, pending: %d)", confirmedNonce, pendingNonce) + } + + log.Info("No pending transactions, proceeding to send", + "address", fromAddr.Hex(), + "nonce", confirmedNonce) + + // Get transaction options (returns a copy) with NoSend=true to get calldata + auth := m.l2Client.GetOpts() + auth.Context = ctx + auth.NoSend = true + auth.GasLimit = 0 + + // Call txFunc to get the transaction (this gives us the calldata and to address) + tx, err := txFunc(auth) + if err != nil { + return nil, fmt.Errorf("failed to build transaction: %w", err) + } + + // Get the target contract address and calldata from the unsigned tx + if tx.To() == nil { + return nil, fmt.Errorf("contract creation transactions are not supported") + } + toAddr := *tx.To() + callData := tx.Data() + + log.Info("Building external sign transaction", + "to", toAddr.Hex(), + "calldata_len", len(callData)) + + // Create and sign transaction using external signer + signedTx, err := signer.CreateAndSignTx(ctx, m.l2Client, toAddr, callData) + if err != nil { + return nil, fmt.Errorf("failed to create and sign transaction: %w", err) + } + + // Send the signed transaction + err = m.l2Client.GetClient().SendTransaction(ctx, signedTx) + if err != nil { + return nil, fmt.Errorf("failed to send signed transaction: %w", err) + } + + log.Info("Transaction sent (external sign)", + "tx_hash", signedTx.Hash().Hex(), + "gas_limit", signedTx.Gas()) + + // Wait for transaction to be mined with custom timeout and retry logic + receipt, err := m.waitForReceipt(ctx, signedTx.Hash(), 60*time.Second, 2*time.Second) + if err != nil { + log.Error("Failed to wait for transaction receipt", + "tx_hash", signedTx.Hash().Hex(), + "error", err) + return nil, err + } + return receipt, nil +} + // waitForReceipt waits for a transaction receipt with timeout and custom polling interval func (m *TxManager) waitForReceipt(ctx context.Context, txHash common.Hash, timeout, pollInterval time.Duration) (*types.Receipt, error) { deadline := time.Now().Add(timeout)