From bdde70f1a2e791911f41aa551aaf82a3cff6362e Mon Sep 17 00:00:00 2001 From: Levan Ilashvili Date: Mon, 9 Feb 2026 02:56:17 +0400 Subject: [PATCH 1/2] feat(clearnode): add ERC-1271 smart contract wallet authentication Add support for ERC-1271 (isValidSignature) as a fallback authentication method when ECDSA signature recovery fails or the recovered address doesn't match the claimed wallet address. This enables smart contract wallets (e.g. Account Kit LightAccount, Safe, etc.) to authenticate with ClearNode. Changes: - New erc1271.go: IsContract() and VerifyERC1271Signature() helpers - signer.go: Extract ComputeAuthTypedDataHash() for reuse across both ECDSA and ERC-1271 verification paths - rpc_router_auth.go: ERC-1271 fallback in handleAuthSigVerify() - copies signature before ECDSA recovery to preserve original bytes, iterates configured chains to find and verify the contract wallet - rpc_router.go: Add EthClients field to RPCRouter - main.go: Initialize ethClients from configured blockchain RPCs --- clearnode/erc1271.go | 85 ++++++++++++++++++++++++++++++++++++ clearnode/main.go | 16 ++++++- clearnode/rpc_router.go | 2 + clearnode/rpc_router_auth.go | 66 +++++++++++++++++++++++++--- clearnode/signer.go | 33 +++++++++++--- 5 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 clearnode/erc1271.go diff --git a/clearnode/erc1271.go b/clearnode/erc1271.go new file mode 100644 index 000000000..dc97fc76f --- /dev/null +++ b/clearnode/erc1271.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// ERC-1271 magic value returned by isValidSignature when the signature is valid. +// bytes4(keccak256("isValidSignature(bytes32,bytes)")) +var erc1271MagicValue = [4]byte{0x16, 0x26, 0xba, 0x7e} + +// isValidSignature(bytes32,bytes) selector +var isValidSignatureSelector = crypto.Keccak256([]byte("isValidSignature(bytes32,bytes)"))[:4] + +// Ethereum interface for ERC-1271 verification (matches existing Ethereum interface in the codebase). +type ERC1271Verifier interface { + CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) + CodeAt(ctx context.Context, account common.Address, blockNumber *big.Int) ([]byte, error) +} + +// IsContract checks if the given address is a smart contract (has code deployed). +func IsContract(ctx context.Context, client ERC1271Verifier, addr common.Address) (bool, error) { + code, err := client.CodeAt(ctx, addr, nil) + if err != nil { + return false, fmt.Errorf("failed to check contract code: %w", err) + } + return len(code) > 0, nil +} + +// VerifyERC1271Signature calls isValidSignature(bytes32,bytes) on a smart contract +// wallet and returns true if the contract considers the signature valid. +func VerifyERC1271Signature(ctx context.Context, client ERC1271Verifier, contractAddr common.Address, hash []byte, signature []byte) (bool, error) { + // ABI-encode the call: isValidSignature(bytes32 hash, bytes signature) + // Selector (4 bytes) + hash (32 bytes padded) + offset to bytes (32 bytes) + length (32 bytes) + signature data (padded to 32) + callData := make([]byte, 0, 4+32+32+32+len(signature)+32) + + // Function selector + callData = append(callData, isValidSignatureSelector...) + + // bytes32 hash (already 32 bytes) + if len(hash) != 32 { + return false, fmt.Errorf("hash must be 32 bytes, got %d", len(hash)) + } + callData = append(callData, hash...) + + // Offset to bytes parameter (always 64 = 0x40 for two fixed params) + offset := make([]byte, 32) + offset[31] = 64 + callData = append(callData, offset...) + + // Length of signature bytes + sigLen := make([]byte, 32) + bigLen := big.NewInt(int64(len(signature))) + bigLen.FillBytes(sigLen) + callData = append(callData, sigLen...) + + // Signature data (padded to 32-byte boundary) + callData = append(callData, signature...) + if pad := len(signature) % 32; pad != 0 { + callData = append(callData, make([]byte, 32-pad)...) + } + + result, err := client.CallContract(ctx, ethereum.CallMsg{ + To: &contractAddr, + Data: callData, + }, nil) + if err != nil { + return false, fmt.Errorf("isValidSignature call failed: %w", err) + } + + if len(result) < 32 { + return false, fmt.Errorf("isValidSignature returned %d bytes, expected 32", len(result)) + } + + // The magic value is in the first 4 bytes of the 32-byte return value + return result[0] == erc1271MagicValue[0] && + result[1] == erc1271MagicValue[1] && + result[2] == erc1271MagicValue[2] && + result[3] == erc1271MagicValue[3], nil +} diff --git a/clearnode/main.go b/clearnode/main.go index 24cb2cde6..519c2a076 100644 --- a/clearnode/main.go +++ b/clearnode/main.go @@ -9,6 +9,7 @@ import ( "syscall" "time" + "github.com/ethereum/go-ethereum/ethclient" "github.com/prometheus/client_golang/prometheus/promhttp" ) @@ -61,7 +62,20 @@ func main() { appSessionService := NewAppSessionService(db, wsNotifier) channelService := NewChannelService(db, config.blockchains, &config.assets, signer) - NewRPCRouter(rpcNode, config, signer, appSessionService, channelService, db, authManager, metrics, rpcStore, wsNotifier, logger) + // Create ethClients for ERC-1271 smart wallet signature verification + ethClients := make(map[uint32]*ethclient.Client) + for chainID, blockchain := range config.blockchains { + client, err := ethclient.Dial(blockchain.BlockchainRPC) + if err != nil { + logger.Warn("failed to create ethClient for ERC-1271 verification", "chainID", chainID, "error", err) + continue + } + ethClients[chainID] = client + logger.Info("ethClient for ERC-1271 ready", "chainID", chainID) + } + + router := NewRPCRouter(rpcNode, config, signer, appSessionService, channelService, db, authManager, metrics, rpcStore, wsNotifier, logger) + router.EthClients = ethClients rpcListenAddr := ":8000" rpcListenEndpoint := "/ws" diff --git a/clearnode/rpc_router.go b/clearnode/rpc_router.go index 5889bca4a..c56ebed61 100644 --- a/clearnode/rpc_router.go +++ b/clearnode/rpc_router.go @@ -6,6 +6,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" "gorm.io/gorm" ) @@ -25,6 +26,7 @@ type RPCRouter struct { RPCStore *RPCStore wsNotifier *WSNotifier MessageCache *MessageCache + EthClients map[uint32]*ethclient.Client lg Logger } diff --git a/clearnode/rpc_router_auth.go b/clearnode/rpc_router_auth.go index a97a3a6e3..182209bd9 100644 --- a/clearnode/rpc_router_auth.go +++ b/clearnode/rpc_router_auth.go @@ -3,8 +3,10 @@ package main import ( "context" "fmt" + "strings" "time" + "github.com/ethereum/go-ethereum/common" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/shopspring/decimal" @@ -183,7 +185,9 @@ func (r *RPCRouter) handleAuthJWTVerify(ctx context.Context, authParams AuthVeri }, nil } -// handleAuthJWTVerify verifies the challenge signature and returns the policy, response data and error. +// handleAuthSigVerify verifies the challenge signature and returns the policy, response data and error. +// It first attempts ECDSA recovery. If that fails or the recovered address doesn't match, +// it falls back to ERC-1271 smart contract wallet verification. func (r *RPCRouter) handleAuthSigVerify(ctx context.Context, sig Signature, authParams AuthVerifyParams) (*Policy, any, error) { logger := LoggerFromContext(ctx) @@ -192,7 +196,13 @@ func (r *RPCRouter) handleAuthSigVerify(ctx context.Context, sig Signature, auth logger.Error("failed to get challenge", "error", err) return nil, nil, RPCErrorf("invalid challenge") } - recoveredAddress, err := RecoverAddressFromEip712Signature( + + // Copy sig so ECDSA recovery's V-byte mutation (27/28 → 0/1) doesn't + // corrupt the original for the ERC-1271 fallback path. + ecdsaSig := make(Signature, len(sig)) + copy(ecdsaSig, sig) + + recoveredAddress, ecdsaErr := RecoverAddressFromEip712Signature( challenge.Address, challenge.Token.String(), challenge.SessionKey, @@ -200,10 +210,54 @@ func (r *RPCRouter) handleAuthSigVerify(ctx context.Context, sig Signature, auth challenge.Allowances, challenge.Scope, challenge.SessionKeyExpiresAt, - sig) - if err != nil { - logger.Error("failed to recover address from signature", "error", err) - return nil, nil, RPCErrorf("invalid signature") + ecdsaSig) + + // If ECDSA recovery failed or recovered address doesn't match, try ERC-1271 + ecdsaMatch := ecdsaErr == nil && strings.EqualFold(recoveredAddress, challenge.Address) + if !ecdsaMatch { + erc1271Verified := false + expectedAddr := common.HexToAddress(challenge.Address) + + typedDataHash, hashErr := ComputeAuthTypedDataHash( + challenge.Address, + challenge.Token.String(), + challenge.SessionKey, + challenge.Application, + challenge.Allowances, + challenge.Scope, + challenge.SessionKeyExpiresAt, + ) + + if hashErr == nil { + // Try ERC-1271 verification on all configured chains + for chainID, client := range r.EthClients { + isContract, cErr := IsContract(ctx, client, expectedAddr) + if cErr != nil || !isContract { + continue + } + + verified, vErr := VerifyERC1271Signature(ctx, client, expectedAddr, typedDataHash, sig) + if vErr == nil && verified { + logger.Info("ERC-1271 signature verified for smart wallet", + "address", challenge.Address, "chainID", chainID) + erc1271Verified = true + break + } + } + } + + if !erc1271Verified { + if ecdsaErr != nil { + logger.Error("failed to recover address from signature", "error", ecdsaErr) + } else { + logger.Debug("signature address mismatch and ERC-1271 verification failed", + "expected", challenge.Address, "recovered", recoveredAddress) + } + return nil, nil, RPCErrorf("invalid signature") + } + + // ERC-1271 verified — use the challenge address as the recovered address + recoveredAddress = challenge.Address } if err := r.AuthManager.ValidateChallenge(authParams.Challenge, recoveredAddress); err != nil { diff --git a/clearnode/signer.go b/clearnode/signer.go index 4642214a0..1c1056ed1 100644 --- a/clearnode/signer.go +++ b/clearnode/signer.go @@ -79,7 +79,9 @@ func RecoverAddress(message []byte, sig Signature) (string, error) { return addr.Hex(), nil } -func RecoverAddressFromEip712Signature( +// ComputeAuthTypedDataHash computes the EIP-712 typed data hash for the auth +// challenge. This hash is used both for ECDSA recovery and ERC-1271 verification. +func ComputeAuthTypedDataHash( walletAddress string, challengeToken string, sessionKey string, @@ -87,7 +89,7 @@ func RecoverAddressFromEip712Signature( allowances []Allowance, scope string, expiresAt uint64, - sig Signature) (string, error) { +) ([]byte, error) { convertedAllowances := convertAllowances(allowances) typedData := apitypes.TypedData{ @@ -121,18 +123,37 @@ func RecoverAddressFromEip712Signature( }, } - // 1. Hash the typed data (domain separator + message struct hash) - typedDataHash, _, err := apitypes.TypedDataAndHash(typedData) + hash, _, err := apitypes.TypedDataAndHash(typedData) + if err != nil { + return nil, err + } + return hash, nil +} + +func RecoverAddressFromEip712Signature( + walletAddress string, + challengeToken string, + sessionKey string, + application string, + allowances []Allowance, + scope string, + expiresAt uint64, + sig Signature) (string, error) { + + typedDataHash, err := ComputeAuthTypedDataHash( + walletAddress, challengeToken, sessionKey, application, + allowances, scope, expiresAt, + ) if err != nil { return "", err } - // 2. Fix V if needed (Ethereum uses 27/28, go-ethereum expects 0/1) + // Fix V if needed (Ethereum uses 27/28, go-ethereum expects 0/1) if sig[64] >= 27 { sig[64] -= 27 } - // 3. Recover public key + // Recover public key via ECDSA pubKey, err := crypto.SigToPub(typedDataHash, sig) if err != nil { return "", err From 0c67deee86ac0392424441147ccc984887ce358f Mon Sep 17 00:00:00 2001 From: Levan Ilashvili Date: Mon, 9 Feb 2026 10:53:09 +0400 Subject: [PATCH 2/2] close ethClients on shutdown and log hashErr in ERC-1271 fallback --- clearnode/main.go | 6 ++++++ clearnode/rpc_router_auth.go | 2 ++ 2 files changed, 8 insertions(+) diff --git a/clearnode/main.go b/clearnode/main.go index 519c2a076..1f1c5a4b0 100644 --- a/clearnode/main.go +++ b/clearnode/main.go @@ -159,6 +159,12 @@ func main() { logger.Error("failed to shut down RPC server", "error", err) } + // Close ethClients used for ERC-1271 verification + for chainID, client := range ethClients { + client.Close() + logger.Info("ethClient closed", "chainID", chainID) + } + logger.Info("shutdown complete") } diff --git a/clearnode/rpc_router_auth.go b/clearnode/rpc_router_auth.go index 182209bd9..f4c19199b 100644 --- a/clearnode/rpc_router_auth.go +++ b/clearnode/rpc_router_auth.go @@ -244,6 +244,8 @@ func (r *RPCRouter) handleAuthSigVerify(ctx context.Context, sig Signature, auth break } } + } else { + logger.Warn("failed to compute typed data hash for ERC-1271 fallback", "error", hashErr) } if !erc1271Verified {