Warning
This is an example/proof-of-concept and has not been audited. Do not use in production.
Solana program that lets Ethereum wallets sign and authorize on-chain transactions. An ETH address owns a PDA — any instruction batch signed by that key is verified on-chain via secp256k1 recovery and executed as CPIs from the PDA.
Built with Anchor 0.32.
MPC signing (via Signet network)
An EVM wallet requests a signature from the Signet MPC network. The on-chain Solana Program verifies the ECDSA signature, recovers the ETH address, and executes each inner instruction as a CPI from the PDA.
- Derive an ETH address via
evmAdapter.deriveAddressAndPublicKey(predecessor, path, keyVersion)— this is a local derivation from the MPC root public key, producing a deterministic child public key that is then converted to an ETH address - Initialize the wallet PDA on Solana with that derived address
- Build inner instructions as indexed references — every account (including program IDs) must be passed in
remaining_accounts, and inner instructions reference them byu8index instead of full pubkeys. This is mandatory to save transaction space (1-byte index vs 32-byte pubkey); the program resolves all accounts from that array at execution time. Compute the message hash over these indexed instructions (see below) - Request a signature via
createSignatureRequest({ payload: hash, path, key_version })on the SignetChainSignatureContract(Sepolia) — the MPC network produces(r, s, v) - Submit the Solana tx with the signature, nonce, and indexed inner instructions + all referenced accounts in
remaining_accounts - On-chain — ECDSA recovery, address comparison, nonce check, CPI dispatch
See the MPC e2e test for a working example. The test requires Sepolia and Solana devnet credentials — see .env.example.
The signed message hash is constructed as:
keccak256(
"\x19Ethereum Signed Message:\n32" ||
keccak256(chain_id || program_id || nonce ||
keccak256(remaining_accounts) ||
keccak256(borsh(inner_instructions)))
)
- Assert
nonce == wallet_state.nonce - Reject high-S signatures (malleability)
- Recompute message hash from tx data
secp256k1_recover(hash, signature, recovery_id)→ pubkeykeccak256(pubkey)[12..]→ recovered ETH address- Assert recovered address ==
wallet_state.eth_address - Increment nonce
invoke_signed()each inner instruction with PDA as signer
| Instruction | Discriminator | Description |
|---|---|---|
initialize_wallet |
1 |
Creates a PDA (["ecdsa_proxy", "wallet", eth_address]) storing the owner's ETH address, nonce, and bump |
execute |
2 |
Verifies ECDSA signature, then executes a batch of CPIs with the PDA as signer |
close_wallet |
3 |
Signature-gated PDA closure, returns rent to a specified recipient |
Single-byte discriminators (instead of Anchor's default 8-byte) to save transaction space.
anchor build # Build the program
anchor test # Build + run all tests
npm run test:mpc # MPC e2e tests (requires .env.example vars)
npm run check # Full lint/typecheck suite (rustfmt, clippy, tsc, eslint, knip)
npm run fix # Auto-fix formattingprograms/ecdsa-proxy/src/
├── lib.rs # Entry point, instruction dispatch
├── constants.rs # Seeds, chain ID
├── error.rs # Error codes
├── ecdsa.rs # secp256k1 recovery + low-S check
├── message.rs # Message hash construction (EIP-191)
├── state/mod.rs # WalletState account (20B addr + u64 nonce + u8 bump)
└── instructions/
├── initialize_wallet.rs
├── execute.rs
└── close_wallet.rs
tests/
├── ecdsa-proxy.ts # Integration tests (local key)
├── mpc-ecdsa-proxy.ts # Integration tests (MPC signing via Sepolia)
└── helpers/
├── evm-signer.ts # TypeScript signing (mirrors on-chain hashing)
└── mpc-signer.ts # MPC signer using signet.js ChainSignatureContract
MIT