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
85 changes: 85 additions & 0 deletions clearnode/erc1271.go
Original file line number Diff line number Diff line change
@@ -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)...)
}
Comment on lines +37 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The manual ABI encoding for the isValidSignature call is complex and can be error-prone. For better robustness and maintainability, I recommend using the go-ethereum/accounts/abi package. This is the standard library for this purpose in Go and would make the code simpler and less likely to contain subtle bugs related to encoding.


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
}
22 changes: 21 additions & 1 deletion clearnode/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"syscall"
"time"

"github.com/ethereum/go-ethereum/ethclient"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -145,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")
}

Expand Down
2 changes: 2 additions & 0 deletions clearnode/rpc_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"gorm.io/gorm"
)

Expand All @@ -25,6 +26,7 @@ type RPCRouter struct {
RPCStore *RPCStore
wsNotifier *WSNotifier
MessageCache *MessageCache
EthClients map[uint32]*ethclient.Client

lg Logger
}
Expand Down
68 changes: 62 additions & 6 deletions clearnode/rpc_router_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand All @@ -192,18 +196,70 @@ 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,
challenge.Application,
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
}
Comment on lines +235 to +237
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When IsContract returns an error (cErr != nil), it is currently ignored and the loop continues to the next chain. This could hide persistent issues with a specific chain's RPC endpoint, making debugging difficult. I recommend adding a debug log to record the error when it occurs before continuing.

                if cErr != nil {
					logger.Debug("ERC-1271: IsContract check failed", "chainID", chainID, "address", expectedAddr, "error", cErr)
					continue
				}
				if !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
}
}
} else {
logger.Warn("failed to compute typed data hash for ERC-1271 fallback", "error", hashErr)
}

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 {
Expand Down
33 changes: 27 additions & 6 deletions clearnode/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,17 @@ 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,
application string,
allowances []Allowance,
scope string,
expiresAt uint64,
sig Signature) (string, error) {
) ([]byte, error) {
convertedAllowances := convertAllowances(allowances)

typedData := apitypes.TypedData{
Expand Down Expand Up @@ -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
Expand Down